Redis 的八股题,我在面试别人和被人面试的时候都见过太多遍了。这个系列写到第三期,前两期把 Redis 的基础数据类型、常用命令和内存淘汰策略聊得差不多,这次把火力集中在真正能拉开差距的方向:高可用架构、持久化机制、缓存三大经典问题、分布式锁。这些都是生产环境天天会碰、面试官又最爱往深里追问的内容。
这篇适合三类人看:准备面试想系统过一遍 Redis 核心原理的;已经用 Redis 但只停留在"会用命令"阶段、想补上架构和原理短板的;以及部署过 Redis 但踩过坑、想验证自己理解对不对的。文章按面试追问的节奏来写,每个点都拆到原理层面,同时把生产环境的配置和坑一起带上,背完能直接用,查问题能对上号。
1. 高可用架构拆解:哨兵模式与集群模式的核心差异
1.1 哨兵模式:主从复制之上的自动故障转移
先理清一个容易混淆的前提:主从复制本身解决的是读压力,不是高可用。主节点负责写,从节点负责读,数据通过 RDB 全量快照加命令流增量同步往从节点推,但主节点一旦挂了,从节点不会自己上位,写能力直接断掉。哨兵模式就是在这个基础上补上了"故障感知"和"自动切换"两块能力。
哨兵本质上是独立运行的进程,干三件事:监控、通知、自动故障转移。监控的逻辑是哨兵每隔 10 秒(默认)向主从节点发一次 PING,如果主节点在 down-after-milliseconds 指定的时间内没响应,哨兵先把它标记为"主观下线",英文叫 sdown。注意这只是单个哨兵自己的判断,网络抖动就可能误判,所以还要走集体决策:一个哨兵认定主节点挂了以后,会向其他哨兵发起投票,超过 quorum 数量的哨兵都认为主节点不可用,状态才升级成"客观下线",也就是 odown。
到了客观下线这一步,哨兵集群内部会选出一个 leader,由它来执行故障转移:从健康的从节点里挑一个当新主节点,把这个新主节点的角色广播给所有从节点,让它们切换复制源,同时通知客户端新主节点的地址。整个过程不需要人工介入,这也是"高可用"的含义。
这里有个高频追问点:为什么生产环境建议至少部署 3 个哨兵?答案其实就一句话,少数服从多数的判定机制需要多数票。如果只有 2 个哨兵,其中一个挂了,剩下的那个哨兵永远凑不齐 quorum,故障转移根本无法触发,高可用反而成了不可用。3 个哨兵允许挂 1 个,5 个哨兵允许挂 2 个,以此类推,奇数部署是性价比最高的选择。
实际配置里,哨兵的核心参数长这样:
sentinel monitor mymaster 127.0.0.1 6379 2 sentinel down-after-milliseconds mymaster 5000 sentinel failover-timeout mymaster 15000第一行的 2 就是 quorum,表示至少 2 个哨兵同意主节点下线才判定客观下线。第二行是判断下线的超时时间,第三行是故障转移的超时上限。这几个参数在压测和演练时值得反复调,尤其 down-after-milliseconds 设得太短容易因为 GC 停顿或网络抖动触发误切换,太长又会让故障恢复时间变长,我一般从 5000 起步,结合监控指标再微调。
1.2 集群模式:16384 个槽位如何流转数据
集群模式和哨兵模式解决的不是同一个问题。哨兵解决的是高可用,集群解决的容量和吞吐的横向扩展。Redis Cluster 把整个 keyspace 切成 16384 个哈希槽,每个主节点负责其中一段连续的槽区间。写入一个 key 的时候,Redis 对 key 做 CRC16 运算,再对 16384 取模,得到的就是这个 key 落定的槽位,槽位归属哪个节点是明确的。
但客户端怎么知道槽位到节点的映射?这里有两个层面。集群内部,节点之间通过 gossip 协议互相通信,把槽位分布信息同步给所有节点;客户端侧,通常会缓存一份槽位映射表,发请求时直接算好目标节点。如果客户端算错了,比如集群发生了槽位迁移、节点扩容缩容导致映射变化,节点会返回一个 MOVED 错误,错误信息里带着正确的节点地址,客户端拿到后更新本地缓存,再重新发送请求。日常开发中偶尔会在日志里看到 MOVED 重试,这其实是正常机制,不是故障。
和阿斯克的区别是面试里经常被单独拎出来的点。MOVED 表示这个槽已经彻底迁移到别的节点了,后续所有请求都得到新节点处理;ASK 表示槽正处于迁移过程中,数据可能还在旧节点上,客户端需要跟随临时重定向到目标节点再问一次。一个代表永久转移,一个代表临时指路,本质都是重定向协议,但语义和客户端的处理方式完全不一样。
集群模式下的每个主节点同样可以挂从节点,从节点承担主节点的故障转移职责。这意味着集群自己有 failover 机制,不需要再叠一层哨兵。所以严格来说,哨兵和集群是两套独立的方案,生产上不会混用。很多人一上来就想搞集群,结果槽位迁移、客户端改造、数据倾斜排查全堆上来,运维成本比想象的高得多,小业务反而被拖累。
1.3 怎么选:按瓶颈决定架构方向
拿一张表把两个方案的差异摆清楚,后面选型就省事得多:
| 对比维度 | 哨兵模式 | 集群模式 |
|---|---|---|
| 核心目标 | 高可用 | 高可用 + 横向扩展 |
| 数据分布 | 每个节点保留全量数据 | 数据按 16384 槽分片 |
| 扩容方式 | 增加从节点,读扩展有限 | 增加主节点,迁移槽位 |
| 写入能力 | 单主写入,存在明显瓶颈 | 多主写入,随节点数扩展 |
| 运维复杂度 | 相对简单 | 客户端、槽位、数据倾斜都要管 |
| 适用规模 | 几十 GB 以内、读多写少 | 单机放不下、写入量持续增长 |
如果数据量还没到单机极限,主要压力在读取,用哨兵模式挂几个从节点分担读流量就够了,架构简单,排查问题也容易。如果数据量已经大到单机内存扛不住,或者写入 QPS 已经逼近单主上限,再考虑集群。选择架构的依据是瓶颈在哪里,而不是"越复杂越显得专业"。
2. 持久化机制怎么选:RDB、AOF 与混合持久化的权衡
2.1 RDB 快照:写时复制背后的全量备份
Redis 是内存数据库,断电就丢数据,所以持久化是绕不开的考题。RDB 就是给内存里的数据在某个时间点拍一张快照,默认写到 dump.rdb 文件。它的优点很突出:恢复时直接把快照加载进内存,速度比 AOF 快得多,文件体积也更紧凑。
RDB 的生成方式有 save 和 bgsave 两种。save 是同步的,直接阻塞主线程,生产环境基本没人用。bgsave 是异步的:主进程 fork 出一个子进程,子进程负责把数据写进临时 RDB 文件,写完后原子替换旧文件。这里最值得展开讲的是 COW(Copy On Write,写时复制)原理——fork 出来的子进程和主进程先共享同一份物理内存页,子进程只读不写,主进程在修改某个内存页前会把该页复制一份再改,这样子进程看到的始终是 fork 那一刻的数据快照,主进程的正常写入完全不受影响。
但 COW 有个隐藏成本:写多场景下主进程会复制大量内存页,内存峰值可能接近常驻内存的两倍,容器云上部署尤其容易触发 OOM。这个我后面在踩坑实录里会再说,现在先记住一个结论:RDB 适合做备份和快速恢复,但它有天然缺陷——它是打点快照,两次快照之间写入的数据一旦宕机就全丢。对数据敏感性高的业务,单靠 RDB 绝对不够。
2.2 AOF 日志:刷盘策略与重写机制
AOF 的思路和 RDB 完全相反。它把每次写操作(set、del、incr 这类)追加到日志文件末尾,恢复时重放一遍日志命令就能还原数据。AOF 有 fsync 三档策略,这是面试必考的基础点:
- always:每次写命令都 fsync 到磁盘,最安全但性能最差,写一次要等一次磁盘刷盘。
- everysec:每秒刷一次盘,最多丢 1 秒数据,性能和安全性比较平衡,生产环境默认用它。
- no:完全交给操作系统决定何时刷盘,性能最好,但丢数据的上限不可控,几乎没人生产用。
选 everysec 的原因要能说出"为什么":它把数据丢失窗口压缩到 1 秒内,同时把 fsync 带来的主线程阻塞降到最低。这里有个容易忽略的细节,fsync 频率过高时,磁盘 IO 会成为瓶颈,甚至引发主线程阻塞,所以 always 策略在写密集型业务下反而会导致吞吐暴跌。
AOF 还有一个绕不开的概念:rewrite(重写)。日志会无限增长,里面可能堆满了对同一个 key 的反复修改,重写就是根据当前内存里的数据生成一份最小化的命令集合,把历史冗余日志清掉。Redis 4.0 之后的重写逻辑用到了 fork 子进程加"重写缓冲区"的机制:子进程按 fork 时刻的数据生成新 AOF 文件,同时主进程把重写期间新增的写命令写入缓冲区,等子进程完成后把缓冲区内容追加到新文件末尾,保证重写期间一条数据都不丢。
2.3 混合持久化:取长补短的默认选择
Redis 4.0 之后引入了混合持久化,配置项是 aof-use-rdb-preamble。它做的事情很聪明:AOF 文件的头部先放一段 RDB 数据作为全量快照,后面再追加重写之后的增量 AOF 命令。加载时先飞快加载 RDB 部分恢复主体,再执行后面的 AOF 命令补齐增量,既保速度又保完整性。
从 5.0 开始这个配置默认开启,也就是说现在新部署的 Redis 基本不用纠结"选 RDB 还是 AOF"的二选一问题。我个人的部署习惯是这样的:
appendonly yes appendfilename "appendonly.aof" appendfsync everysec aof-use-rdb-preamble yes save 900 1 save 300 10 save 60 10000appendonly 打开 AOF,fsync 用 everysec,混合持久化保持默认开启,save 触发的 RDB 也保留,两条线并行。这里提醒一个容易踩的坑:混用之后,恢复数据时 AOF 和 RDB 是协作关系,不是替代关系。用 Docker 部署并且把数据目录挂到宿主机 volume 上的时候,千万别为了省空间只留 RDB 删掉 AOF,那样加载出来的数据大概率是残缺的。备份脚本也得同时覆盖两个文件。
3. 缓存穿透、击穿、雪崩:三兄弟的应对手册
3.1 缓存穿透:查不到的数据也要防
缓存穿透说的是请求查询的数据在缓存和数据库里都不存在。比如拿一个不存在的用户 ID 查用户信息,缓存没有,数据库也没有,于是每次请求都穿透缓存直接打到数据库。如果攻击者专门用一批不存在的 ID 高频请求,数据库压力瞬间被打满。
解法一:缓存空值。数据库查不到时,也往缓存里写一个空值,设置一个较短的过期时间,比如 60 秒,后续同样的查询命中空值缓存,不再打到数据库。好处是简单直接、立竿见影,坏处是每个不存在的 key 都要占一份缓存空间,而且如果业务上这个 key 之后真的有了数据,在过期时间内的用户会看不到最新值。所以空值过期时间要短,或者用带后缀的 key 做区分。
解法二:布隆过滤器。在缓存前面加一层过滤器,把所有可能存在的数据 key 提前放进去。查询时先问过滤器:这个 key 存在吗?过滤器说"不存在"就是绝对不存在,直接返回;说"存在"才继续查缓存和数据库。布隆过滤器的核心特点是:判断不存在零误判,判断存在有一定误判率,误判率取决于位数组长度和哈希函数个数。它省空间、查询快,适合 key 集合相对稳定、总量可控的场景,比如黑名单过滤、用户 ID 白名单校验。
面试时最好把两个方案都答出来,再补一句适用边界的判断:空值缓存适合临时防护、数据量小的场景,布隆过滤器适合前置拦截、数据集合稳定的场景。能说出方案的取舍逻辑,比单纯背名词强得多。
3.2 缓存击穿:热点 key 过期的瞬间压力
缓存击穿和穿透只差一个字,问题却完全不同。击穿指的是某个热点 key 在缓存里过期的那个瞬间,大量并发请求同时打到数据库。打个比方:某个明星的热搜词条在缓存里突然失效,紧接着十万用户同时搜索这个词条,缓存全部 miss,十万个请求直冲数据库,数据库瞬间被压垮。
解法一:互斥锁。缓存 miss 时不让每个请求都去查库,而是先尝试获取一把分布式锁。拿到锁的请求才负责查数据库、回填缓存,其他请求要么短暂等待后重试,要么直接返回降级数据。这样同一时刻只有一个请求会打到数据库。用 Redis 实现的核心就是一条带过期时间的原子命令:SET lock:hotkey uuid NX PX 30000,NX 保证只有一个线程能设成功,PX 保证锁不会因为线程挂掉而死锁。
解法二:逻辑过期。不给热点 key 设置物理过期时间,而是在 value 里存一个逻辑过期时间字段。查询时发现逻辑过期了,当前请求直接返回旧数据保住可用性,同时把 key 交给后台异步线程去刷新。这个方案很适合对一致性要求不高、能接受短暂旧值的场景,代价是实现更复杂,要自己维护异步刷新的队列和线程池。
我在生产里的习惯是分层的:真正的极热点 key 用逻辑过期,普通热点 key 用互斥锁,再配合下面要说的过期时间随机化,基本能把击穿风险压到很低的水平。
3.3 缓存雪崩:大面积过期与节点宕机
缓存雪崩比击穿更可怕。击穿是一个 key 扛不住,雪崩是一大片 key 同时失效,或者 Redis 节点直接宕机,导致请求像洪水一样全冲到数据库。两者的应对思路也完全不同。
大面积 key 同时过期,最常见的原因是所有 key 设置了同一个过期时间。比如晚上 0 点有一批活动数据统一设置了 3600 秒的过期时间,到了 1 点整它们全部失效,数据库在 1 点整迎来一波峰值冲击。解法特别朴素:给过期时间加一个随机扰动,在 3600 秒这个基础值上加上 0 到 300 秒的随机数,让失效时间分散开,避免"整点雪崩"。
如果是 Redis 节点宕机导致的雪崩,思路就不止一层:集群化部署可以把请求分散到不同节点;应用本地加一层进程内缓存,和 Redis 组成两级缓存,Redis 挂了一级还有本地扛一下;配合限流、熔断和降级机制保护数据库;再靠主从切换和哨兵自动恢复服务。注意限流降级不是 Redis 层能解决的,而是应用框架层面要做的事,比如 Sentinel 熔断、线程池隔离、兜底接口降级,这些要在系统设计的时候一起考虑进去。
还有一个高频小追问:雪崩之后缓存重建怎么保证一致性?稳妥的套路是"过期时间随机化 + 数据库访问动态加锁 + 缓存重建异步化"三管齐下,既保可用性,又把对数据库的冲击降到最低。一套组合拳下来,三兄弟基本就都被按住了。
4. 分布式锁的实现:从 SETNX 到 Redisson 的进化史
4.1 最朴素的 SETNX 实现和它绕不开的三个坑
分布式锁是 Redis 八股里出镜率最高的题,但别一上来就背 Redisson,从最原始的实现开始理解才有意思。最早大家用 SETNX(SET if Not eXists)抢锁:谁能成功 set 一个锁 key,谁就拿到锁。但 SETNX 和 EXPIRE 是两条独立命令,中间进程宕机的话锁永远不释放,所有线程都会死等。所以后来演进成一条原子命令:SET lock:key unique_value NX PX 30000,加锁和设置过期时间一步完成,第一个坑算是堵上了。
第二个坑是锁误删。线程 A 拿到锁之后业务卡了很久,锁超时自动释放了;线程 B 抢到锁开始执行;这时 A 终于跑完了,直接 DEL lock:key,把 B 的锁给删了。解决办法是 value 里存一个线程或请求唯一的标识,删除前先比对 value 一致才删。但"比对加删除"两步之间必须保证原子性,不能 GET 完再 DEL,中间被其他线程插进来就白搭。正确写法是用 Lua 脚本封装:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 endRedis 的 Lua 脚本是原子执行的,GET 和 DEL 在脚本里不会被其他命令插入,这才是严谨的解锁方式。
第三个坑是锁过期导致互斥失效。业务执行时间超过锁的 TTL,锁提前释放,别的线程就能进来。你可以把 TTL 设得非常大,但业务耗时不可控,而且 TTL 越大,持有锁的线程万一真的挂了,锁占用的时间越长。这个矛盾就是"看门狗"机制要解决的问题。
4.2 Redisson 看门狗:自动续期与公平锁
Redisson 是 Java 生态里最常用的 Redis 分布式锁客户端,前面的坑它都封装好了。它有两个核心机制值得背透:加锁和解锁的 Lua 脚本保证原子性,以及看门狗(WatchDog)自动续期。
看门狗的逻辑很巧妙:默认锁的 leaseTime 是 30 秒,但拿到锁的线程并不会真的只持有 30 秒。Redisson 会启动一个后台定时任务,每 10 秒(leaseTime 的三分之一)检查一次:只要锁还被当前线程持有,就把 key 的过期时间重置回 30 秒。这样业务跑超时了锁不会被提前释放,而线程如果挂了,后台定时任务也一起停了,锁最迟 30 秒后自动过期,不会死锁。等等,这正好解决了"TTL 设多少才合适"的千古难题。
Redisson 还提供了 fair lock(公平锁)和 read/write lock(读写锁)等变体。公平锁内部用 Redis 的 zset 记录等待队列,保证先来先服务;读写锁支持读读共享、写写互斥、读写互斥的完整语义。面试时能说出 Redisson 有这几个锁类型,比只说一句 getLock 深了一个层次,也能让面试官觉得你不是只会背 API。
4.3 主从切换的锁丢失与 RedLock 争议
最刁钻的追问来了:如果线程 A 在主节点上加锁成功,但主节点还没来得及把锁 key 同步到从节点就宕机了,哨兵把从节点提升为新主节点,这时候新主节点上根本没有这把锁,线程 B 过来加锁直接成功,互斥被彻底打破。这就是主从切换导致的锁丢失问题。
Redis 官方针对这个问题提出了 RedLock 算法:在多个完全独立的 Redis 节点上依次加锁,只有半数以上节点加锁成功才算真正拿到锁。听上去无懈可击,但业界对它的争议非常大。分布式系统专家 Martin Kleppmann 专门撰文批驳过它的理论缺陷,比如节点时钟漂移、客户端 GC 暂停导致锁被其他客户端接管等场景,都有论证 RedLock 并非绝对安全。所以 RedLock 更多是面试考点,生产环境真按它搭一套的非常少。
我的个人经验是:分布式锁要解决的核心问题,其实是业务侧能不能接受极端情况下的锁失效。大部分业务场景下,单节点 Redis 加锁加看门狗续期加删除前校验,配合业务侧的幂等设计和重试机制,已经足够可靠。真到了对锁的强一致有硬性要求的场景,与其在 Redis 上反复打磨,不如直接用数据库唯一约束或者 ZooKeeper、etcd 这类带强一致性的组件,从根上绕开这个问题。面试时把这个思辨过程讲清楚,比复述 RedLock 算法得分要高。
5. 面试追问清单与生产环境踩坑实录
5.1 这些高频追问别被问懵
上面几个大方向讲完后,面试官往往会追加几个小问题检验你是背的还是真懂,我整理几个高频的,每个都能展开成一个小专题:
- Redis 为什么单线程还快?核心在三点:数据全在内存里、IO 多路复用处理海量连接、单线程避免了上下文切换和锁竞争。注意 6.0 之后引入了 IO 多线程,但那只是把网络读写的活儿分摊出去,命令执行依旧是单线程,这个细节别答错。
- 大 key 有什么危害?删除和读取大 key 会阻塞主线程,比如删除一个几 MB 的 hash,del 命令可能卡住主线程几十毫秒,集群模式下还会导致槽位数据倾斜。应对是拆分大 key,或者用 unlink 命令异步删除,再配合 big key 巡检定期扫描。
- 集群模式下 mget 这类批量操作为什么不支持?因为 key 分散在不同的槽位,跨槽位的批量操作无法保证原子性。解决办法是用 hash tag 把相关 key 强行放进同一个槽位,代价是可能造成数据倾斜,或者自己在应用层拆分成多次请求。
- 缓存和数据库的一致性怎么保证?生产用最多的还是 Cache-Aside 模式:先更新数据库,再删除缓存,配合延迟双删来规避并发窗口内的脏数据。一致性要求更高的场景可以订阅数据库 binlog 做异步缓存失效。
这几个问题算是 Redis 八股的"必考拓展题",建议每个都自己动手写一遍 demo 再上考场,光看是记不牢的。
5.2 生产环境踩过的几个真实坑
最后分享几个我真实踩过的坑,比干背八股有用得多。
第一个坑和 Windows 相关。很多同学习惯在 Windows 上拿 Redis 做压测或者写 demo,用了非官方移植版,发现吞吐量只有几百 QPS,心里直打鼓。后来换了 WSL 环境又测了一遍,数据完全不一样。Windows 下的网络栈、文件系统和 Linux 不同,压测结果不能用来评估生产容量。看数据用可视化工具没问题,比如 Redis Desktop Manager 或者开源替代品 Another Redis Desktop Manager,浏览 key、看过期时间都很方便,但别在生产库上直接执行 KEYS * 这种全量扫描命令,会阻塞主线程。生产环境老老实实用 Linux 或者 Docker 部署,测试也只是图方便。
第二个坑是淘汰策略误杀核心 key。我给 Redis 配了 maxmemory 和 allkeys-lru,结果某些核心业务 key 在内存紧张时被淘汰了,缓存命中率骤降,数据库压力陡增。后面我把核心 key 调整成不设过期时间、靠主动更新维护,同时把淘汰策略改成 volatile-lru,只淘汰设置了过期时间的 key,核心 key 因为没设过期时间就不会被误清。别看这只是一个配置项的区别,线上效果天差地别。
第三个坑是 AOF 自动 rewrite 导致 OOM。AOF 文件涨到触发自动重写时,fork 子进程加 COW 机制会让内存瞬间猛涨,在内存吃紧的容器里直接 OOM。根源就像前面讲的那样:写多场景下 COW 会复制大量内存页,临时内存峰值可能接近物理内存的两倍。解法是给 Redis 预留足够的内存余量,或者定期在业务低峰期手动触发 bgsave 和 rewrite,避开高峰期自动触发。
第四个坑和主从复制有关。从节点网络闪断后重新连上主节点,主节点为了补数据会重新生成 RDB 文件做全量同步。数据量大时,主节点 bgsave 加网络传输的双重压力可能把带宽打满,影响线上读写。除了检查网络稳定性,还可以适当调大 repl-backlog-size,让增量同步缓冲区足够长,尽量用增量补数据,减少全量同步的概率。
Redis 的八股题背起来不难,难的是把原理串起来、在出问题的时候快速定位。这个系列我打算继续写下去,下一篇准备把内存淘汰策略、LRU 近似算法和 big key 治理展开聊透。各位如果也有什么好玩的踩坑经历,或者对哪块原理有疑问,欢迎在评论区聊一聊,互相补补课。