缓存的问题从来不在缓存本身
Redis 的命令简单,难的是它挂了怎么办、数据和库对不上怎么办、一个慢命令把整个实例卡住怎么办。这份把每个"怎么办"拆成可以照着答的步骤。命令示例按 Redis 7.2 的行为写。
01先把模型想清楚单线程执行、内存有限、可能丢数据——后面所有设计都是这三条的推论。
02再看故障场景穿透、击穿、雪崩、一致性、锁失效,每个都给出触发条件和兜底方案。
03最后落到选型什么该放 Redis、什么不该放,比记住命令更重要。
1
单线程的 Redis 为什么还能这么快
not because it is single-threaded, but despite it
Redis 快的原因和"单线程"没关系,它是在单线程的前提下还这么快:纯内存访问、没有锁和上下文切换的开销、epoll 多路复用、以及为每种类型定制的编码。单线程换来的确定性(每条命令原子执行)才是它真正的价值,代价是一条慢命令会卡住所有人。
- 纯内存:绝大多数操作就是读写内存里的一块结构,比磁盘快 4~5 个数量级。这一条贡献最大,但它和线程模型无关。
- 没有锁,也没有上下文切换:单线程执行命令,天然不存在两个命令同时改一个 key 的情况,所以不需要任何加锁和解锁。多线程版本光是锁竞争就能吃掉大半收益。
- IO 多路复用:一个线程用 epoll 同时监听几万个连接,谁有数据就处理谁,不需要为每个连接开一个线程。这是"单线程还能扛高并发连接"的关键——瓶颈本来就不在 CPU,而在等待网络。
- 数据结构是定制过的:小对象用 listpack 连续存、大对象用跳表,每种类型都按数据量切换编码,为的就是把常数项压下去。这一块在第 2 节细讲。
- 6.0 的多线程 IO 改的是什么:只把"读 socket、解析协议、写回结果"这些搬运工作分给多个线程,命令执行仍然是单线程。所以它不会让单条命令变快,只是让网络搬运不再是瓶颈(默认还关着,
io-threads 4才开)。
✕ 面试里容易说错的
- Redis 是单线程的 —— 不准确,6.0 起网络 IO 是多线程,还有后台线程做持久化和异步删除
- 单线程所以快 —— 反了,单线程是限制,不是原因
- Redis 比 MySQL 快是因为数据结构好 —— 主因是内存和磁盘的差别
✓ 准确的说法
- 命令执行是单线程的,所以每条命令天然原子
- 快是因为纯内存 + 无锁无切换 + epoll + 编码优化
- 代价是慢命令会阻塞全局,所以生产环境要禁用 KEYS、FLUSHALL 这类命令
面试怎么答Redis 快有三个原因:一是纯内存操作,这是最主要的;二是单线程执行命令,省掉了锁竞争和线程上下文切换的开销,而且天然保证了单条命令的原子性;三是用了 epoll 的 IO 多路复用,一个线程就能盯着几万个连接。再加上每种数据类型都针对数据量做了编码优化。要注意 6.0 之后网络 IO 是多线程的,但命令执行仍然是单线程,所以"单线程"这个说法指的是执行模型。单线程的代价是一条慢命令会阻塞所有客户端,所以生产上要用 SCAN 代替 KEYS,用 UNLINK 代替 DEL 删大 key,并且配好慢查询日志。
2
五种类型,和它们的底层编码
each type has two or three encodings
面试问"Redis 有哪些数据结构",答五种类型只是及格。真正拉开差距的是同一种类型在不同数据量下用不同编码:小的时候用一块连续内存(listpack / intset),大了才换成哈希表和跳表。转换是单向的,一旦转过去就不会退回来。
- String:三种编码。
int(能解析成 64 位整数)、embstr(≤44 字节,头和内容一次分配)、raw(>44 字节,分开分配)。44 这个数字不是随便定的,是 64 字节的 object 头减去剩余空间算出来的。 - List:7.0 之后统一是
quicklist——一个双向链表,每个节点里装一块 listpack。相当于把"链表好插入"和"数组缓存友好"捏在一起。 - Hash:元素少且 value 小 →
listpack(连续内存,顺序扫);超阈值 →hashtable,此时HGET变成 O(1)。 - Set:全是整数 →
intset(有序数组,二分查找);元素少 → listpack;否则 hashtable。 - ZSet:元素少 → listpack;多了 →
skiplist+ 一个 dict。dict 存 member→score 用于 O(1) 查分数,跳表按 score 排序用于范围查询,两份结构共享同一份 member 字符串,不会翻倍。 - 编码只升不降:一个 hash 从 200 个字段删到 10 个,编码不会退回 listpack。因为降级要重新分配整块内存,Redis 选择不做。所以"把大 key 删小"不会立刻省内存。
✕ listpack 换成 hashtable 就回不去了
- 以为删元素能省内存 —— 编码不降级,内存不会还给系统
- 以为 listpack 慢 —— 128 个元素以内,一次内存扫描比哈希还快,缓存命中率高
- 以为 ZSet 一定用跳表 —— 小 ZSet 用的是 listpack,ZRANGE 直接顺序扫
✓ 实际用起来要注意
- 知道阈值在哪,才能判断一次写入会不会触发编码转换(转换是 O(N) 的,会短暂阻塞)
- 大 key 删完记得用 MEMORY USAGE 复查,必要时重建 key
- 榜单类数据超过 128 条就自动变跳表,内存大概翻 2~3 倍,要提前算容量
面试怎么答五种基本类型是 string、list、hash、set、zset,但真正要答的是它们的编码。string 有 int、embstr、raw 三种,44 字节是分界线。list 在 7.0 之后统一用 quicklist,链表节点里装 listpack。hash 和 zset 在元素少的时候都用 listpack,超过 128 个元素或者单个 value 超过 64 字节就转成 hashtable 和 skiplist。zset 用跳表是因为要支持范围查询,同时还有一个 dict 用来 O(1) 查分数。set 全整数时用 intset。要注意编码转换是单向的,转过去不会退回来,所以删元素不会降内存。
3
跳表:为什么有序集合不用红黑树
skiplist beats red-black tree here
ZSet 要按分数排序、要范围查询、要能快速定位排名。红黑树也能做到 O(log N),但 Redis 选了跳表,原因有三个:范围查询只需要顺着最底层链表走、实现简单到不用背平衡规则、插入删除只影响局部指针。
和红黑树的对比范围查询 ZRANGEBYSCORE 10 30
跳表:定位到 10 之后,沿着底层链表一路往右走,命中即收集
红黑树:同样能做,但要中序遍历,实现更绕
实现复杂度
跳表:插入时随机掷层数,然后改几个指针,没有旋转、没有变色
红黑树:插入删除要处理 5 种失衡情况和一堆旋转
排名 ZRANK
跳表的每个节点存了 span(跨越了几个节点),累加就是排名
红黑树要额外维护子树大小,插入删除时一路更新
- 层数是随机的:每插入一个节点,用
1/4的概率往上长一层(Redis 里ZSKIPLIST_P = 0.25),最高 32 层。所以跳表不需要平衡操作,最坏情况是 O(N) 但概率极低,期望是 O(log N)。 - 为什么范围查询更快:跳表最底层就是一条完整的有序链表,定位到起点后一路往右走就行。红黑树得做中序遍历,还要维护栈。ZSet 最高频的操作就是范围查询,这一个理由就够了。
- span 是排名的关键:每个节点记录"我跨过了几个节点",查找时把沿途的 span 加起来就是
ZRANK的结果。这就是排行榜能 O(log N) 拿到名次的原因。 - 红黑树不是不能做:Java 的 TreeMap 就是红黑树。Redis 选跳表的现实理由还有一条——代码好写、好调试,Antirez 自己说过跳表实现起来更简单,出错概率低。
- listpack 阶段的 ZSet 没有跳表:128 个元素以内,ZSet 就是一块连续内存按 score 排好序,
ZRANGE直接顺序扫,比跳表还快。所以"ZSet 用跳表"这句话只在元素多的时候成立。
面试怎么答ZSet 用跳表而不是红黑树,主要三个原因。第一是范围查询:跳表最底层就是一条有序链表,定位到起点顺着走就行,而 ZSet 最高频的操作恰恰是 ZRANGEBYSCORE 这类范围查询。第二是实现简单:跳表靠随机层数维持平衡,插入删除只改局部指针,不需要旋转和变色,代码量和出错概率都小得多。第三是排名:跳表节点里存了 span,累加就能得到 ZRANK,红黑树要额外维护子树大小。跳表的期望复杂度是 O(log N),层数按 1/4 的概率向上增长,最高 32 层。要注意小 ZSet 用的是 listpack,不是跳表。
4
过期删除与内存淘汰
two different questions, often confused
这是两个不同的问题:过期删除问的是"这个 key 到点了怎么清掉",内存淘汰问的是"内存满了该扔谁"。前者靠惰性删除 + 定期删除,后者靠八种策略。面试里最常被混着问,答的时候要先把这两件事分开。
- 惰性删除:key 到期了不主动清,等你访问它的时候发现过期了才删。省 CPU,但没人访问的 key 会一直占着内存。
- 定期删除:每 100ms 跑一次,随机抽 20 个设了过期时间的 key,删掉其中过期的;如果这 20 个里过期比例超过 25%,就再来一轮。抽样而不是全扫,是为了不让删除把主线程占住。
- 所以过期 != 立刻消失:一个 key 过期后,可能还占着内存一段时间。这也是为什么内存监控和 key 数量对不上——不是 bug。
- 淘汰策略共八种:
noeviction(默认,满了直接报错)、allkeys-lru、allkeys-lfu、allkeys-random、volatile-lru、volatile-lfu、volatile-random、volatile-ttl。volatile 系列只淘汰设了过期时间的 key。 - LRU 是近似的:Redis 不做真正的 LRU 链表(那要维护双向链表,太贵),而是随机采样
maxmemory-samples(默认 5)个 key,淘汰其中最久没用的。采样数越大越接近真 LRU。 - LFU 比 LRU 更适合缓存:LRU 会被"扫一遍全表"这种操作污染(把冷数据全刷成热的),LFU 按访问频率淘汰,抗扫描。4.0 之后才有,用
allkeys-lfu时注意新 key 需要时间积累计数。
✕ 常见的错误答案
- "过期了就立刻删" —— 惰性 + 定期抽样,不是实时
- "内存满了用 LRU" —— 默认是 noeviction,会直接报 OOM 错误
- "淘汰策略能删任何 key" —— volatile 系列只删有 TTL 的
✓ 怎么选
- 纯缓存场景:
allkeys-lru或allkeys-lfu - 缓存和持久数据混在一个实例:用
volatile-lru,并且只给缓存 key 设 TTL - 绝对不能丢的数据:
noeviction,让写入直接失败,由业务决定怎么办
面试怎么答过期删除和内存淘汰是两件事。过期删除用惰性删除加定期删除:访问时才判断是否过期,同时每 100 毫秒随机抽 20 个带 TTL 的 key 检查,过期比例超过 25% 就重复抽样。所以过期 key 不一定会立刻释放内存。内存淘汰是在 maxmemory 满了之后触发,一共八种策略,默认 noeviction 会直接报错。allkeys 系列可以淘汰所有 key,volatile 系列只淘汰设了过期时间的。LRU 是近似实现,靠随机采样 maxmemory-samples 个 key 挑最久未使用的,采样数越大越准。LFU 按访问频率淘汰,能抗住全表扫描带来的缓存污染。
5
持久化:RDB、AOF 与混合
snapshot vs log, and why you want both
RDB 是某一刻的全量快照,恢复快但会丢数据;AOF 是每条写命令的日志,丢得少但文件大、恢复慢。4.0 之后推荐混合持久化:AOF 文件的开头是一段 RDB 快照,后面追加增量命令,兼顾恢复速度和数据安全。
三条路线怎么选只要 RDB
重启恢复最快,文件小
但可能丢几分钟数据 —— 只能用于纯缓存
只要 AOF everysec
最多丢 1 秒
但文件比 RDB 大几倍,重启时要重放全部命令,几十 GB 的实例能恢复几分钟
RDB + AOF 混合(推荐)
文件前半段是 RDB,直接加载;后半段是增量命令
恢复快 + 丢得少,代价是文件不可读
- fork 不复制内存,靠 COW:bgsave 时 fork 出子进程,父子共享同一份物理内存,只有被修改的页才复制。所以写越多,额外内存占用越大。一台 32G 的机器,如果写入很猛,做快照时峰值内存可能翻倍,这是线上"做备份把机器打挂"的原因。
- AOF 重写不是读旧文件:它是 fork 一个子进程,直接把当前内存里的数据写成最少的命令(一个 100 万次 INCR 的 key,重写成一条 SET)。重写期间新的写命令会同时写进旧 AOF 和一个重写缓冲区,重写完成后拼起来。
- always 不等于绝对不丢:即使每条都刷盘,如果 Redis 进程崩溃在"写内存"和"写日志"之间,还是会丢。而且 always 的性能会掉一个数量级,生产上极少用。
- RDB 的坑:fork 期间如果写入量大,COW 会吃掉大量内存;另外 RDB 是二进制格式,不同大版本之间不保证兼容,升级前要做一次全量验证。
- 主从复制用的就是 RDB:从库第一次同步时,主库 bgsave 一份 RDB 传过去。所以主库的内存和 fork 开销,会被"新加一个从库"这件事放大。
面试怎么答RDB 是定时快照,fork 子进程写盘,靠 COW 省内存,文件小、恢复快,但两次快照之间的数据会丢。AOF 是写命令日志,appendfsync 有三种:no 最多丢 30 秒、everysec 最多丢 1 秒、always 每条刷盘但性能差很多。AOF 文件会越来越大,所以要重写,重写不是压缩旧文件,而是根据当前内存里的数据重新生成最少的命令,重写期间的新命令先写进缓冲区,完成后追加。4.0 之后推荐混合持久化,AOF 文件前半部分是 RDB 格式,加载快,后半部分是增量命令,兼顾两者。要注意 bgsave 的 fork 在写入量大的时候会因为 COW 占用大量额外内存,这是线上做备份容易踩的坑。
6
主从复制:全量、增量与那个 backlog
replid + offset decides which path
从库连上来先问一句"我上次同步到哪儿了",主库看 replid 和 offset 决定走哪条路:对不上就全量(bgsave 一份 RDB 传过去),对得上且数据还在 repl backlog 里就增量(只补那段命令)。backlog 是个固定大小的环形缓冲区,太小就会频繁触发全量同步。
- 全量同步的完整流程:从库发
PSYNC ? -1→ 主库 bgsave 生成 RDB → 同时把这段时间的新命令写进 replication buffer → RDB 传完,再把 buffer 里的命令补发 → 从库加载完成,进入命令传播阶段。 - 增量同步靠 replid + offset:从库发
PSYNC <replid> <offset>,主库一看 replid 是自己的、offset 又落在 backlog 范围内,就只把 offset 之后的命令发过去。 - replid 为什么会变:从库被提升为新的主库时,会生成一个新的 replid。其他从库切过来时 replid 对不上,只能走全量。这也是主库故障切换后经常发生全量同步的原因。
- backlog 是个环形缓冲区:大小固定(默认 1MB)。写入速度 × 断线时长超过它,旧数据就被覆盖,从库回来只能全量同步。所以"主从频繁全量同步"第一件事就是调大
repl-backlog-size。 - 主从延迟是必然的:复制是异步的,从库永远落后主库一点。写完立刻去从库读,可能读不到——这是"读写分离读到旧数据"的根因。
- WAIT 只能缓解不能解决:
WAIT 1 100表示"等到至少 1 个从库确认,最多等 100ms",它是同步等待,会拖慢写入,而且不保证强一致(超时了就返回没等到)。
✕ 读写分离的常见幻觉
- 写完立刻读从库,一定读得到 —— 异步复制,可能读不到
- 从库越多读得越快 —— 从库要接收全量写流量,太多会拖累主库
- 主从切换后立刻恢复 —— 新主库 replid 变了,其他从库要全量重同步
✓ 实际该怎么做
- 强一致的读走主库,或者写完延时再读(延时不靠谱,但简单)
- 用 WAIT 或半同步复制换取"至少一个从库收到",但要接受延迟
- 切换前评估全量同步的代价:新主库会被 bgsave 和传输打一波
面试怎么答从库第一次连上来走全量同步:主库 bgsave 生成 RDB 发给从库,同时把期间的新命令写进 replication buffer,传完再补发,从库加载完进入命令传播。断线重连时用 PSYNC 带上 replid 和 offset,如果 replid 匹配且 offset 还在 repl backlog 范围内,就只补增量;否则退回全量。backlog 是固定大小的环形缓冲区,默认 1MB,太小会导致频繁全量同步,生产上要按写入量调大。要注意主从复制是异步的,从库必然有延迟,写完立刻读从库可能读到旧数据;主库故障切换后新主库的 replid 会变,其他从库常常被迫全量同步。
7
哨兵与 Cluster:两种完全不同的扩容
sentinel for HA, cluster for sharding
哨兵解决"主库挂了怎么办",Cluster 解决"一台机器装不下怎么办"。哨兵是独立进程,盯着主从集群做自动故障转移,但数据仍然只有一份;Cluster 是把数据按 16384 个槽切到多个主节点上,每个主节点自带从节点。两者不能混着用。
两种错误的处理方式客户端请求的 key 不在这个节点上:
MOVED 3999 10.0.0.12:6379
→ 槽 3999 永久归那个节点,客户端要更新本地槽位表,然后重试
ASK 3999 10.0.0.12:6379
→ 槽 3999 正在迁移中,这次你先去那个节点问一下,但别更新本地表
区别:MOVED 是"以后都去那儿",ASK 是"就这一次"
- 哨兵的三步判断:单个哨兵认为主库没响应 → 主观下线;达到 quorum 个哨兵都这么认为 → 客观下线;然后哨兵之间用 Raft 选出一个 leader,由它执行故障转移(挑新主库、让其他从库 REPLICAOF 新主库、通知客户端)。
- 为什么是 16384 个槽:心跳包要带槽位图,16384 位 = 2KB,如果改成 65536 就是 8KB,gossip 心跳会很占带宽。而 Redis Cluster 设计上最多约 1000 个节点,16384 个槽足够分。
- Cluster 没有中心节点:每个节点都保存完整的槽位映射,通过 gossip 协议互相传播。客户端随便连一个节点,拿不到就按 MOVED 跳转。
- 多 key 操作受槽限制:
MGET的多个 key 必须在同一个槽,否则报 CROSSSLOT。解决办法是 hash tag,用{}把要同槽的部分括起来。 - Cluster 的故障转移不依赖哨兵:每个主节点有从节点,主节点挂了由集群内部投票提升从节点,前提是该主节点的槽必须全部有主,否则整个集群拒绝服务(
cluster-require-full-coverage yes时)。 - 选型:数据能装进一台机器、只要高可用 → 哨兵;单机内存装不下、要水平扩展 → Cluster。Cluster 的代价是不能用多 key 事务、不能跨槽 Lua、客户端要支持集群协议。
面试怎么答哨兵和 Cluster 解决的是不同问题。哨兵是独立的监控进程,负责自动故障转移:单个哨兵发现主库无响应是主观下线,达到 quorum 数量是客观下线,然后哨兵之间选一个 leader 执行切换,选新主库、让从库指向新主库、通知客户端。数据仍然只有一份,没有分片。Cluster 是分片方案,把 key 用 CRC16 算出 0 到 16383 的槽,每个主节点负责一段槽,每个主节点再带从节点做高可用。选 16384 是因为心跳要带槽位图,2KB 比较合适,65536 会到 8KB 太占带宽,而集群规模一般不超过一千个节点。Cluster 下多 key 操作必须在同一个槽,要用 hash tag 保证。
8
穿透、击穿、雪崩:三个词别搞混
miss on nonexistent / hot key expiry / mass expiry
三个词对应三种完全不同的故障:穿透是查一个根本不存在的数据,缓存和数据库都没有;击穿是一个热点 key 刚好过期,瞬间全部打到库上;雪崩是大量 key 同时过期或者缓存整体不可用。名字像,解法完全不同。
三种问题的判断方式穿透:命中率异常低,且请求的 key 高度重复(可能被恶意刷)
→ 看监控里的 cache_miss 里有多少是"库里也没有"
击穿:某个特定 key 的 QPS 突然打到数据库,其他 key 正常
→ 看慢查询,会发现同一条 SQL 在短时间内被打了成千上万次
雪崩:数据库 QPS 整体飙升,缓存命中率断崖式下跌
→ 通常伴随"某个时间点"或"缓存实例重启"
- 穿透的两种解法:一是缓存空值(简单,但要注意 TTL,且被恶意刷不同 key 时会占满内存);二是布隆过滤器(把所有存在的 key 预先写进去,不存在的直接挡掉,代价是有假阳性但绝无假阴性,且删除麻烦)。
- 击穿的核心是"只放一个进去":用
SET NX抢锁,抢到的查库并回填缓存,抢不到的短暂等待后重读缓存。要注意锁的超时时间必须大于一次查库耗时,否则锁提前释放会退化成击穿。 - 击穿还有个更优雅的解法:逻辑过期:value 里存一个过期时间字段,但 key 本身不过期。发现逻辑过期时,异步起一个线程去更新,当前请求先返回旧值。牺牲一致性换可用性,适合对实时性要求不高的场景。
- 雪崩的三层防御:过期时间加随机抖动(治"同时过期")、多级缓存(本地缓存 + Redis,治"Redis 挂了")、限流降级(治"无论如何还是打过来了")。
- 热 key 重建时的并发问题:就算加了锁,如果这个 key 的 QPS 是 10 万,锁本身就成瓶颈。这时要用"本地缓存 + 定期刷新",让绝大多数请求根本不碰 Redis。
✕ 别把这几个混着答
- 穿透 = 数据库里也没有,属于"无效查询"
- 击穿 = 数据存在,只是缓存刚好失效
- 雪崩 = 大面积失效,或者缓存整体挂了
✓ 对症下药
- 穿透 → 布隆过滤器 + 空值缓存
- 击穿 → 互斥锁重建 + 逻辑过期
- 雪崩 → 随机 TTL + 多级缓存 + 熔断限流
面试怎么答穿透是查询一个数据库里也不存在的 key,每次都会穿透到库,解法是缓存空值或者用布隆过滤器提前挡掉。击穿是一个热点 key 过期的瞬间,大量请求同时打到数据库,解法是用 SET NX 加锁只让一个请求去重建,或者用逻辑过期让请求先拿旧值、后台异步更新。雪崩是大量 key 同时过期或者缓存服务整体不可用,解法是过期时间加随机值、多级缓存、以及限流降级兜底。三个问题的共同点是都可能把数据库打挂,但触发条件和解法完全不同,答的时候要分开讲。
9
缓存和数据库怎么保持一致
delete the cache, do not update it
只要用了缓存,就不存在强一致,只能做到"最终一致 + 短暂不一致窗口足够小"。业界的主流做法是 Cache Aside:先更新数据库,再删缓存。注意是删不是更新——更新的问题是并发写会互相覆盖,删掉让下次读重新加载反而安全。
不一致窗口怎么进一步压缩方案一:延迟双删
更新库 → DEL cache → sleep 500ms → DEL cache
第二次删是为了清掉"窗口期被读请求写回去的旧值"
缺点:sleep 时间靠猜,且拖慢写接口
方案二:订阅 binlog(Canal / Debezium)
数据库变更后异步删缓存,不依赖业务代码
缺点:多一个组件,有秒级延迟
方案三:给缓存设短 TTL 兜底
最终一致的最后一道保险,TTL 到了自然就对了
- 为什么是删除而不是更新缓存:更新缓存要先把新值算出来,如果这个值需要多表关联或者复杂计算,写多读少的场景下就是纯浪费;更严重的是并发写——两个写请求按不同顺序到达,缓存和库可能就反了。删掉最省事。
- "先删缓存再更库"为什么不行:删完到更库之间有空窗,读请求会把旧值写回缓存,之后没人再删它,脏数据会一直留到 TTL 到期。除非你能保证这个窗口没有任何读——不可能。
- 延迟双删的 sleep 该多久:要大于"一次读请求查库 + 写缓存"的耗时。这个值没法精确算,所以它是个经验做法,不是银弹。
- 强一致就別用缓存:如果业务真的要求读到的必须是最新值(比如余额、库存扣减),正确做法是不走缓存,直接读主库,或者在缓存上做版本号校验。
- 库存这类场景:通常是"缓存只用来挡读,扣减走数据库的原子操作(UPDATE ... WHERE stock >= 1)",扣完把缓存删掉。绝对不能"读缓存判断够不够,再写库"。
面试怎么答缓存和数据库只能做到最终一致。主流方案是 Cache Aside,先更新数据库再删除缓存。之所以是删除而不是更新,一是更新缓存可能算了很多没人用的值,二是并发写的时候两个请求的到达顺序不同,可能把旧值写进缓存。之所以不能先删缓存再更库,是因为删完到更库之间有空窗期,读请求会把旧值重新加载进缓存,而且之后不会再有人删它。先更库再删缓存也有一个极短的并发窗口,可以用延迟双删或者订阅 binlog 来缩窗,最后用 TTL 兜底。如果业务要求强一致,那就不该用缓存。
10
分布式锁:从 SET NX 到 Redlock 的争议
the lock is easy, the failure modes are not
加锁只要一行
SET key val NX PX 30000,难的是释放时不能删别人的锁、业务没跑完锁就过期、主从切换导致两个客户端同时持锁。前两个是工程问题,第三个是 Redlock 争论的核心。三个必须处理的失效场景1. 业务没跑完,锁过期了
客户端 A 拿到锁,处理了 40 秒(锁 30 秒)
→ 锁自动释放,B 拿到锁,A 和 B 同时改同一份数据
解法:看门狗(Redisson 的 watchdog),每 10 秒续一次期
2. 释放时删了别人的锁
A 的锁过期,B 拿到锁;A 执行完 DEL,把 B 的锁删了
→ 解法:value 存唯一标识,用 Lua 比对后再删
3. 主从切换导致双持锁
A 在主库加锁成功,主库还没同步到从库就挂了
从库被提升,B 在新主库上加锁也成功
→ 两个客户端同时认为自己持有锁
- NX 和 PX 必须一起用:只加 NX 不设过期,客户端崩溃后锁永远不释放;只在客户端本地设超时,无法阻止别人在你超时后加锁。两个参数在一条命令里发出,才不会被拆开。
- value 必须是唯一标识:用 UUID 或者"机器 IP + 线程 ID"。这是"不删别人的锁"的唯一依据。
- 看门狗解决的是"业务比锁活得久":Redisson 默认锁 30 秒,后台每 10 秒检查一次,如果业务还在跑就续到 30 秒。注意它只能续持有锁的那个客户端,客户端真的挂了就续不了,锁自然过期——这是对的。
- Redlock 的争议:Antirez 提出向 N 个独立 Redis 实例依次加锁,超过半数成功就算成功。Martin Kleppmann 反驳说,这依赖各节点的时钟不会漂移,而分布式系统里时钟不可信;而且持锁期间如果发生 GC 停顿,锁可能已经过期,客户端自己不知道。
- 实际怎么选:绝大多数业务用单实例 Redis + 唯一 value + Lua 释放 + 看门狗就够了;要求更高就用 ZooKeeper(临时顺序节点天然解决会话失效)或 etcd(基于 Raft,有 fencing token)。Redlock 在需要严格正确性的场景下不推荐。
- fencing token 才是根治:给每次加锁发一个单调递增的编号,写数据时带上编号,存储层只接受更大的编号。这样即使两个客户端同时持锁,后到的会被存储层拒绝。
面试怎么答分布式锁用 SET key value NX PX 毫秒 一条命令完成,value 必须是每个客户端唯一的标识。释放时不能直接 DEL,要先 GET 比对 value 再用 Lua 原子地删除,否则会把别人的锁删掉。锁的过期时间要覆盖业务耗时,不够就用看门狗续期,Redisson 默认 30 秒、每 10 秒续一次。主从架构下还有个问题:主库加锁成功但还没同步就故障切换,新主库上另一个客户端也能加锁成功,于是两个客户端同时持锁。Redlock 想用多实例多数派解决,但它依赖时钟假设,而且在客户端发生 GC 停顿时无法感知锁已过期,这个方案在需要严格正确性的场景并不被推荐。要求更高可以用 ZooKeeper 或 etcd,本质是要有单调递增的 fencing token 让存储层兜底。
11
事务、Lua 与 Pipeline 的边界
three things people call "transaction"
这三样经常被混为一谈,但保证的东西完全不同:Pipeline 只是把多条命令打包发出去,减少网络往返,不保证原子性;MULTI/EXEC 保证"一起执行、中间不插队",但出错不回滚;Lua 才是真正的"原子执行一段逻辑"。
三者对比Pipeline
多条命令一次发出,攒着一起收结果
减少 RTT,10 条命令 1 次往返
不保证原子:中间可能插进别的客户端的命令
MULTI / EXEC
命令入队,EXEC 时按顺序连续执行,不被打断
★ 运行期错误不回滚:语法错在入队时就报,运行错(比如对 string 做 LPUSH)只影响那一条
★ 不支持条件判断,你没法在事务里"先读再决定写什么"
Lua
整段脚本在 Redis 里一次执行完,期间不处理其他命令
可以做条件判断、可以循环
★ 脚本执行时间默认上限 5 秒(busy-reply-threshold),超了会阻塞
★ 脚本必须短小,别在里面做重活
- 为什么 MULTI 不支持回滚:Antirez 的设计取舍——回滚需要记录 undo log,而且要处理"回滚本身失败"的情况,成本高。Redis 的立场是:运行期错误通常是程序 bug,应该在开发阶段发现,而不是靠数据库兜底。
- WATCH 是乐观锁:
WATCH key之后如果这个 key 被别人改了,EXEC 会返回 nil(整个事务不执行)。适合"读-改-写"的场景,但并发高时重试率会很高。 - Lua 的原子性来自单线程:脚本执行期间 Redis 不处理其他命令,所以脚本里的多条命令天然原子。这也是为什么脚本必须快——它会把整个实例卡住。
- Lua 里不要用 KEYS * :脚本里的
redis.call('KEYS', '*')会扫全库,直接卡死。所有要访问的 key 必须通过KEYS[]参数传进来,这在 Cluster 下也是硬性要求(脚本只能操作同槽的 key)。 - Pipeline 不是事务:它只是批量发送。你发 100 条命令,中间可能有别的客户端的命令插进来。但它能极大提升吞吐——1 万次 SET 逐条发要几秒,用 pipeline 只要几十毫秒。
面试怎么答三者的保证强度依次递增。Pipeline 只是把多条命令一次发送、一次收结果,目的是减少网络往返,不保证原子性,中间可能插入其他客户端的命令。MULTI/EXEC 保证事务里的命令连续执行不被打断,但它不支持回滚,运行期错误只影响出错的那一条,语法错误则在入队阶段就会报错;它也不支持条件判断,所以没法"先读再决定写什么"。Lua 脚本在 Redis 里一次执行完,期间不处理其他命令,所以是真正原子的,还能做条件判断和循环,常用来实现分布式锁释放和库存扣减。要注意脚本必须短小,执行期间会阻塞整个实例,而且所有 key 要通过 KEYS 传参,Cluster 下还要求同槽。
12
大 key 与热 key:两个相反的病
big key kills latency, hot key kills throughput
大 key 是单个 key 的数据量太大,危害是一次操作就能卡住主线程(删一个几百万元素的 hash 要几百毫秒);热 key 是单个 key 的访问量太高,危害是把一个分片的 CPU 打满。前者是空间问题,后者是流量问题,治理手段完全不同。
危险动作与替代方案DEL 一个 100 万元素的 hash
→ 主线程逐个释放,阻塞几百毫秒到几秒
→ 换成 UNLINK:只解除引用,实际内存在后台线程慢慢释放
HGETALL 一个 10 万字段的 hash
→ 一次返回几百 MB,网络和客户端内存都会崩
→ 换成 HSCAN 分批,或用 HMGET 只取需要的字段
KEYS *
→ 全库扫描,直接阻塞
→ 换成 SCAN 游标分批
- 大 key 的判定标准:string 超过 10KB(有人用 1MB),集合类型元素超过 5000 个。这是个经验值,真正的标准是"这个 key 的操作会不会导致慢查询"。
- 大 key 的四个危害:① 删除阻塞主线程;② 网络传输打满带宽;③ 客户端反序列化耗内存(一个 100MB 的 value 反序列化后可能占几百 MB);④ 集群下会造成数据倾斜,某个分片内存远高于其他。
- 热 key 的判定:单 key QPS 占比超过 10%,或者单个分片的 CPU 明显高于其他。用
--hotkeys(需要 LFU 策略)或者监控代理的统计。 - 热 key 的三层解法:① 本地缓存(JVM 内 Caffeine,把 QPS 挡在 Redis 之外,代价是一致性);② 读写分离,把读分散到多个从库;③ 复制多份 key 到不同节点(比如
hot:key:0到hot:key:9,随机读一个)。 - 热 key 和大 key 会叠加:一个热点 key 同时又很大,是最坏情况——每次读取都要传大量数据,且全部集中在一个节点上。这种设计一定要在事前避免。
✕ 常见处理方式的问题
- 用 DEL 删大 key —— 主线程阻塞,线上会抖动
- 用 KEYS 找大 key —— 本身就会阻塞,比大 key 更危险
- 给热 key 加锁 —— 锁会成为新的瓶颈,QPS 越高越糟
✓ 正确姿势
- UNLINK 异步删除,或者 HSCAN 分批删字段
- 用 --bigkeys / SCAN 在低峰期扫描,或用离线分析 RDB 文件
- 热 key 用本地缓存挡在前面,而不是在 Redis 层想办法
面试怎么答大 key 是单个 key 的数据量太大,危害主要是操作时会阻塞主线程,比如 DEL 一个上百万元素的 hash 要几百毫秒,期间整个实例都卡住;还有网络传输和客户端内存的开销,集群下还会导致数据倾斜。治理方法是拆分、用 UNLINK 异步删除、用 HSCAN 分批读,扫描用 --bigkeys 或 SCAN 而不是 KEYS。热 key 是单个 key 的访问量过高,危害是把某个分片的 CPU 打满,治理方法是加本地缓存把请求挡在 Redis 之外、读写分离分散到从库、或者把 key 复制多份打散。这两个问题一个是空间一个是流量,手段完全不同。
13
典型场景:别只会说"缓存"
rank, counter, rate limit, session
面试最后常问"你项目里 Redis 用来干什么"。能说清为什么这个场景适合 Redis,比报出十个场景有用。判断标准就一条:这个操作是不是高频、对延迟敏感、且能接受最终一致。
几个场景为什么适合 Redis排行榜
ZSet 的跳表按分数有序,ZREVRANGE 拿 TopN、ZREVRANK 拿名次都是 O(log N)
换 MySQL:要么 ORDER BY 全表排序,要么维护排名表,都远不如这个
分布式 Session
多个服务实例共享登录态,读写都在内存里
过期时间就是 TTL,天然不用清理
分布式 ID / 计数器
INCR 是原子的,不会出现"两个请求拿到同一个号"
简单队列
List 的 LPUSH + BRPOP 就是阻塞队列
但要注意:没有 ACK 机制,消费者崩了消息就丢了
→ 要可靠投递用 Stream(有消费者组和 ACK)
- 排行榜的坑:ZSet 超过 128 个元素就从 listpack 变成 skiplist,内存大概翻 2~3 倍。一个千万级的榜单要提前算好内存,通常做法是只保留 TopN(比如定时把 Top 1000 落库,Redis 里只留这一千)。
- 限流的三种实现:固定窗口(INCR + EXPIRE,最简单,但窗口边界会放过去两倍流量)、滑动窗口(ZSet 存时间戳,精确但内存高)、令牌桶(Lua 脚本,平滑限流,最常用)。
- Session 共享的真实价值:不是为了"把 session 放 Redis 显得高级",而是为了让服务实例无状态——任何一台机器都能处理任何请求,扩缩容不用考虑粘性会话。
- 别用 List 当可靠队列:
BRPOP取走消息后,如果消费者在处理时崩溃,这条消息就永久丢失了。要可靠就用 Stream,它支持消费者组、ACK、以及失败重投(XPENDING + XCLAIM)。 - 不适合放 Redis 的:需要复杂查询和事务的数据(用数据库)、需要强一致的金额(用数据库)、单条几百 KB 的大对象(传输和内存都亏)。
面试怎么答Redis 适合高频、低延迟、能接受最终一致的场景。排行榜用 ZSet,因为跳表按分数有序,ZREVRANGE 取 TopN、ZREVRANK 取个人名次都是 O(log N),比数据库排序高效得多。计数器用 INCR,单条命令原子,避免了读改写之间的并发问题。限流可以用 INCR 加 EXPIRE 实现固定窗口,精确一点用 ZSet 做滑动窗口,生产上更常用 Lua 实现的令牌桶。分布式 Session 让服务实例变成无状态,扩缩容不用粘性会话。队列可以用 List 的 BRPOP,但它没有 ACK,消费者崩溃会丢消息,要可靠投递得用 Stream。反过来,需要复杂查询、强一致的金额、或者单个几百 KB 的大对象,都不适合放 Redis。
14
速答清单
last 30 minutes before the interview
考前扫一遍。答不上来的,回到对应那一节看推导,别背结论。
01Redis 是单线程的吗?
命令执行是单线程的,但 6.0 之后网络 IO 是多线程的,持久化、异步删除这些也有后台线程。说"单线程"指的是执行模型,所以每条命令天然原子。
02单线程为什么还这么快?
主要是纯内存操作,其次是省掉了锁竞争和线程上下文切换,再加上 epoll 多路复用让一个线程能盯几万个连接,还有针对每种类型的编码优化。快不是因为单线程,而是"在单线程的前提下还快"。
03单线程的代价是什么?
一条慢命令会阻塞所有客户端。所以生产环境禁用 KEYS、FLUSHALL,删除大 key 用 UNLINK,Lua 脚本必须短小,并且配好慢查询日志。
04五种基本类型分别是什么?
string、list、hash、set、zset。追问要能说出各自的编码:string 有 int/embstr/raw,list 是 quicklist,hash 小的时候 listpack、大了 hashtable,set 全整数时 intset,zset 小的时候 listpack、大了 skiplist。
05string 的 44 字节是什么意思?
embstr 和 raw 的分界线。44 字节以内用 embstr,把 redisObject 头和内容一次性分配,只做一次内存分配和释放;超过 44 字节用 raw,分开分配。
06zset 为什么用跳表不用红黑树?
范围查询更方便,最底层就是有序链表;实现简单,不需要旋转和变色;节点里存了 span,可以 O(log N) 算排名。红黑树都能做,但复杂度和代码量都更高。
07跳表的层数是怎么定的?
每次插入随机决定,Redis 里以 1/4 的概率向上长一层,最高 32 层。所以不需要任何平衡操作,期望复杂度是 O(log N)。
08过期 key 是怎么删除的?
惰性删除加定期删除。访问时判断是否过期,同时每 100ms 随机抽 20 个带 TTL 的 key 检查,过期比例超过 25% 就再抽一轮。所以过期 key 不一定立刻释放内存。
09内存淘汰有哪几种策略?
八种:noeviction、allkeys-lru、allkeys-lfu、allkeys-random、volatile-lru、volatile-lfu、volatile-random、volatile-ttl。默认是 noeviction,内存满了直接报错。volatile 系列只淘汰设了过期时间的 key。
10LRU 是真 LRU 吗?
不是,是近似 LRU。随机采样 maxmemory-samples 个 key(默认 5),淘汰其中最久未使用的。采样数越大越接近真 LRU。真 LRU 要维护双向链表,成本太高。
11LFU 和 LRU 怎么选?
LFU 按访问频率淘汰,能抗住"全表扫一遍"这种把冷数据刷成热数据的场景。纯缓存推荐 allkeys-lfu;如果访问分布比较均匀、想简单点,用 allkeys-lru。
12RDB 和 AOF 的区别?
RDB 是定时全量快照,fork 子进程写盘,文件小、恢复快,但会丢两次快照之间的数据。AOF 是写命令日志,appendfsync 有 no/everysec/always 三档,最多丢 1 秒,但文件大、恢复慢。
13混合持久化是什么?
4.0 之后支持,AOF 文件的前半段是 RDB 格式的快照,后半段是增量命令。恢复时先加载 RDB 部分,再重放增量,兼顾恢复速度和数据安全。7.0 默认开启。
14bgsave 的时候内存为什么会涨?
fork 之后父子进程共享物理内存,靠 copy-on-write。父进程写入时会复制被修改的页,所以写入量越大,额外占用的内存越多。写入很猛的实例做快照,峰值内存可能接近翻倍。
15AOF 重写是怎么做的?
不是压缩旧文件,而是 fork 一个子进程,根据当前内存里的数据直接生成最少的命令。重写期间的新命令同时写进旧 AOF 和重写缓冲区,重写完成后拼接。
16主从复制的全量和增量怎么区分?
从库发 PSYNC 带上 replid 和 offset。replid 匹配且 offset 还在 repl backlog 范围内就走增量;否则走全量,主库 bgsave 生成 RDB 传过去,同时把期间的新命令写进 buffer,传完补发。
17repl-backlog 太小会怎样?
从库断线时间稍长,offset 就落到了 backlog 之外,只能退回去做全量同步。表现为"主从频繁全量同步",第一件事就是调大 repl-backlog-size。
18主从延迟怎么解决?
没法彻底解决,复制是异步的。工程上的办法是:强一致的读走主库、写完延时再读、或者用 WAIT 命令等待至少一个从库确认(但要接受延迟增加)。
19哨兵是怎么判断主库挂了的?
单个哨兵发现无响应是主观下线;达到 quorum 个哨兵都这么认为就是客观下线;然后哨兵之间用 Raft 选一个 leader 执行故障转移。
20为什么 Redis Cluster 是 16384 个槽?
心跳包要携带槽位图,16384 位正好 2KB,65536 位就是 8KB,gossip 心跳太占带宽。而集群规模设计上不超过约 1000 个节点,16384 个槽已经够分。
21MOVED 和 ASK 有什么区别?
MOVED 表示这个槽永久归属另一个节点,客户端要更新本地槽位表;ASK 表示槽正在迁移,这次请求临时去目标节点问,但不要更新本地表。
22Cluster 下 MGET 多个 key 报错怎么办?
报 CROSSSLOT,因为多个 key 不在同一个槽。解决办法是 hash tag,用 {} 把要保证同槽的部分括起来,比如 {user:1001}:name 和 {user:1001}:age。
23缓存穿透是什么?怎么解决?
查一个数据库里也不存在的 key,每次都穿透到库。解法是缓存空值(带短 TTL)或者用布隆过滤器,在访问 Redis 之前就挡掉。
24缓存击穿和穿透有什么区别?
击穿是数据存在,只是热点 key 恰好过期,瞬间大量请求打到库上;穿透是数据根本不存在。击穿用互斥锁重建或者逻辑过期,穿透用布隆过滤器或空值缓存。
25缓存雪崩怎么防?
三层:过期时间加随机抖动,避免同时失效;多级缓存,本地缓存加 Redis,防 Redis 整体不可用;限流降级兜底,保证数据库不被压垮。
26缓存和数据库怎么保持一致?
Cache Aside,先更新数据库再删除缓存。是删不是更新,因为更新会有并发覆盖问题,而且可能算了很多没人用的值。只能做到最终一致,用延迟双删或者订阅 binlog 缩窗,TTL 兜底。
27为什么不能先删缓存再更库?
删完到更库之间有空窗期,这期间的读请求会把旧值重新加载进缓存,而且之后不会再有人删它,脏数据会留到 TTL 到期。
28延迟双删的延迟该设多久?
要大于一次"读请求查库 + 写缓存"的耗时。这个值没法精确算,所以它是经验做法。更可靠的是订阅 binlog 异步删缓存。
29分布式锁怎么加?
SET key value NX PX 30000,一条命令完成。value 必须是每个客户端唯一的标识,比如 UUID 加线程 ID。NX 保证互斥,PX 保证客户端崩溃后锁能自动释放。
30分布式锁释放要注意什么?
不能直接 DEL,要先 GET 比对 value 再删,而且这两步要用 Lua 保证原子。否则可能出现"我的锁过期了,别人拿到了锁,我执行完把它删了"。
31锁的过期时间不够怎么办?
用看门狗续期。Redisson 默认锁 30 秒,后台每 10 秒检查一次,业务还在跑就续到 30 秒。客户端真的挂了就续不了,锁自然过期,这是对的。
32Redlock 为什么不推荐?
它依赖各节点时钟不漂移这个假设,而分布式系统里时钟不可信;而且持锁期间如果客户端发生 GC 停顿,锁可能已经过期而客户端不知道。需要严格正确性应该用 ZooKeeper、etcd 或者 fencing token。
33Redis 事务支持回滚吗?
不支持。MULTI/EXEC 保证命令连续执行不被打断,但运行期错误只影响出错的那一条,其他命令照常执行。设计上认为运行期错误是程序 bug,应该在开发阶段发现。
34WATCH 是干什么的?
乐观锁。WATCH 某个 key 之后,如果它在 EXEC 之前被别人改了,整个事务不执行,EXEC 返回 nil。适合读-改-写场景,但并发高时重试率会很高。
35Lua 和事务有什么区别?
Lua 是真正原子的,还能做条件判断和循环,执行期间 Redis 不处理其他命令。事务不支持条件判断,你没法在事务里先读再决定写什么。但 Lua 必须短小,否则会阻塞整个实例。
36Pipeline 是事务吗?
不是。它只是把多条命令打包一次发出、一次收结果,减少网络往返,不保证原子性,中间可能插入其他客户端的命令。但吞吐提升非常明显。
37大 key 有什么危害?
删除时会阻塞主线程几百毫秒甚至几秒;网络传输可能打满带宽;客户端反序列化占大量内存;集群下会造成数据倾斜。所以大 key 要用 UNLINK 异步删,读用 HSCAN 分批。
38热 key 怎么发现和处理?
用 --hotkeys(需要 LFU)或监控代理统计。处理是三层:本地缓存挡住大部分请求、读写分离分散到从库、把 key 复制多份打散到不同节点。
39为什么不能用 List 当可靠队列?
BRPOP 取走消息后,消费者处理时崩溃,这条消息就永久丢失了,没有 ACK 和重投机制。要可靠投递用 Stream,它支持消费者组、ACK 和 XPENDING + XCLAIM 重新认领。
40Redis 适合放什么、不适合放什么?
适合高频、低延迟、能接受最终一致的数据:缓存、排行榜、计数器、Session、限流。不适合需要复杂查询和事务的数据、需要强一致的金额、以及单个几百 KB 的大对象。