Redis 高可用与持久化深入:RDB / AOF / 哨兵 / Cluster
Redis 常被当成"一个缓存",但真到生产上,你要回答四个要命的问题:① 它突然重启,数据还在吗?② 主节点挂了,谁顶上?③ 内存塞不下了,怎么扩?④ 缓存大面积失效时,数据库会不会被压死?这一章按"单机持久化 → 主从复制 → 哨兵 → Cluster 分片 → 缓存三坑 → 分布式锁"的顺序,把这四个问题一次讲透。读完你会明白一件事:Redis 的高可用不是"装个集群"就完了,它是一组此消彼长的取舍。
9.1 持久化:RDB、AOF 与混合持久化
论Redis 为什么不能像 MySQL 那样"每条都刷盘"
因为 Redis 的立身之本是快。它的数据在内存里,一次 SET 是纳秒级;如果每条写都同步刷一次磁盘(毫秒级),性能会掉一到两个数量级——那就没人用它当缓存了。
所以 Redis 的持久化是在"性能"和"丢多少数据能接受"之间取平衡:RDB 是隔一段时间拍一张全量快照(丢得多、恢复快);AOF 是记流水账(丢得少、文件大、恢复慢)。
| 维度 | RDB(快照) | AOF(追加日志) |
|---|---|---|
| 存的是什么 | 某一时刻的全量数据二进制快照(dump.rdb) | 所有写命令的追加日志(appendonly.aof) |
| 触发方式 | save(同步、阻塞)、bgsave(fork 子进程)、配置 save 900 1 这类规则、主从全量同步时自动触发 | appendfsync always(每条刷,最安全最慢)/ everysec(每秒刷,默认推荐)/ no(交给操作系统) |
| 可能丢多少 | 两次快照之间的所有写入(可能几十秒到几分钟) | everysec 模式最多丢 1 秒;always 基本不丢 |
| 恢复速度 | 快(直接读二进制载入内存) | 慢(要把命令重放一遍,文件越大越慢) |
| 文件体积 | 小(压缩过的二进制) | 大(命令文本),需要靠 BGREWRITEAOF 重写瘦身 |
| 对写入性能影响 | 平时几乎无影响,但 fork 那一刻有抖动 | everysec 影响很小;always 明显拖慢 |
生产推荐配置:混合持久化(RDB 打头 + AOF 记尾)
论混合持久化解决了什么
纯 AOF 恢复慢,因为它要把可能上百万条命令重放一遍;纯 RDB 丢得多。混合持久化(aof-use-rdb-preamble yes)在重写 AOF 时,把当前内存的全量数据写成一个 RDB 二进制放在文件开头,之后新来的写命令再按 AOF 追加在后面。恢复时先加载 RDB 部分(快),再重放尾部少量命令(准)——又快又少丢。
所以现在的标准答案不是"选 RDB 还是 AOF",而是:两个都开,让混合持久化接手。唯一的例外是纯缓存场景(数据能从数据库重建),此时甚至可以两个都关。
① fork 会短暂阻塞主线程。Linux 上 fork 要复制页表,内存越大越慢。一个 32GB 的实例,fork 可能阻塞几百毫秒——这对 P99 延迟是灾难。别把 save 规则配得太密。
② copy-on-write 会让内存峰值翻倍。fork 之后父进程继续写,被改动的页会被复制一份给子进程。如果这期间写入量很大,RSS 可能涨到接近 2 倍。开了持久化的 Redis 实例,maxmemory 别设到机器物理内存的 80% 以上,留出 COW 空间,否则会 OOM 或被 OOM Killer 干掉。
③ 大 key 是 fork 和重写的放大器。一个 500MB 的 Hash 会让重写、迁移、删除都变成"卡一下"的操作。大 key 要拆(比如按 hash tag 分成多个小 key),这是 Redis 运维的第一条纪律。
④ 别用 KEYS * 排查数据量。它会阻塞主线程,生产上等于自我 DDOS。用 SCAN 游标遍历。
9.2 主从复制:读扩展与故障转移的基础
主从复制是所有高可用方案的地基:一个主节点负责写,N 个从节点复制主节点的数据、对外提供读。它既解决了"读扩展",也是哨兵和 Cluster 能做故障转移的前提。
论全量复制和增量复制分别在什么时候发生
① 从节点第一次连上主节点(或 replid/offset 对不上)→ 全量复制(psync ? -1):主节点 bgsave 生成 RDB → 发送给从节点 → 从节点清空自己的数据载入 RDB → 主节点再把期间缓存在缓冲区里的写命令发过去。全量复制很重(磁盘、带宽、内存都吃)。
② 只是短暂断连(网络抖一下)→ 增量复制(psync replid offset):从节点上报自己的 offset,主节点从 repl-backlog 环形缓冲里把漏掉的那一段命令补发过去。这就是为什么 repl-backlog-size 要按写入量调大——缓冲区太小,断连超过缓冲容量就只能退回全量复制。
① 复制是异步的,主节点不会等从节点。主节点执行完 SET 就返回客户端成功,命令再异步发给从节点。所以主节点在命令还没同步过去时宕机,这段数据就永久丢了——哪怕你配了 3 个从节点。这也是 Redis 只能算"最终一致"的原因。
② 主从延迟会咬到你的业务。典型故障:用户下单成功(写主),页面立刻刷新去查(读从),从节点还没同步到,用户看到"订单不存在",然后重复下单。解决思路有三种:写后一段时间内强制读主(缓存标记法)、把强一致读改走主节点、或者干脆用 WAIT 让主节点等从节点确认(会牺牲性能)。
③ 从节点默认只读是对的,但也有人关掉它。关掉 replica-read-only 在从节点写入,两边数据会永久分叉,下一次全量同步时你的写入被清空——别给自己挖坑。
④ 别让"复制风暴"发生。主节点挂掉后,如果所有从节点同时向新主发起全量同步,会把新主压垮。生产上可以用树状级联(从节点的从节点)或分批重启来缓解。
9.3 Sentinel 哨兵:自动故障转移
论哨兵做的四件事
① 监控:每个哨兵进程定期 PING 主节点和所有从节点。
② 通知:节点状态变化时,通过 Pub/Sub 通知其他哨兵和客户端。
③ 自动故障转移:主节点确认不可用后,选一个从节点升为主,其余从节点改为复制新主,并通知客户端新地址。
④ 配置中心:客户端可以通过哨兵查询"当前主节点是谁",不用把 IP 写死。
关键点:哨兵本身就是集群(通常 3 个或 5 个),它们互相投票,避免单个哨兵误判。
论主观下线 → 客观下线 → 选举 Leader → 选新主
① 主观下线(SDOWN):某个哨兵自己 PING 不通主节点,先标记"我认为它挂了"。
② 客观下线(ODOWN):这个哨兵去问其他哨兵"你们也觉得它挂了吗",同意的人数达到 quorum(比如 2)才升级为客观下线——这一步防止单台机器网络抖动造成的误判。
③ 选举 Leader 哨兵:多个哨兵用 Raft 协议选出唯一一个"执行故障转移的 Leader"(要求票数过半)。避免多个哨兵同时抢着升主造成脑裂。
④ 选新主:Leader 按规则挑选——优先 replica-priority 小的,其次复制 offset 最大(数据最新)的,最后 id 最小的。然后对新主执行 REPLICAOF NO ONE,其余从节点复制新主,老主恢复后降级为从节点。
客户端要这样连哨兵(以 Java 生态为例)
① 哨兵必须奇数个,且至少 3 个。2 个哨兵在"1 票 vs 1 票"时无法选出 Leader;1 个哨兵自己就是单点。生产标准是 3 个哨兵 + 3 台机器,且哨兵不要和主节点部署在同一台机器上(否则机器一挂,哨兵一起灰飞烟灭)。
② quorum 要和哨兵数量匹配。3 个哨兵建议 quorum=2。quorum 只为确认客观下线,实际选举 Leader 还需要过半票数。设成 3 的话,只要有一个哨兵网络不稳,就永远凑不齐客观下线,故障转移形同虚设。
③ 哨兵解决的是"可用性",不是"不丢数据"。故障转移过程中,异步复制没来得及同步的写数据依然会丢。而且客户端切换期间会有一小段报错窗口(通常几百毫秒到几秒),业务侧要有重试逻辑。选主过程中若老主其实还活着(假死),会出现"双主"短暂写分叉——这也是 Cluster 用多数派机制想缓解的问题。
还有一条实用建议:别用哨兵 + 客户端"每次请求都去查主地址"的写法(每次多一次网络往返)。用连接池(JedisSentinelPool / Lettuce MasterReplica)让它自己缓存并监听切换事件。
9.4 Cluster 分片:内存装不下时的水平扩展
哨兵能让一个主节点"挂得慢一点",但它解决不了单机内存上限——一个实例最多几十 GB,再大 fork 和主从复制都会失控。Cluster 的思路是把数据切开放到多个主节点上,每片自己带从节点,同时获得水平扩容和故障转移。
论16384 个哈希槽:切分的最小单位
Cluster 把整个键空间切成了 16384 个槽(slot),每个主节点负责其中一段。某个 key 落到哪个槽,由 CRC16(key) mod 16384 决定。
客户端随便连到哪个节点都行:如果这个 key 不在该节点,节点会返回 MOVED 5798 192.168.1.12:6379,意思是"去那个节点找"。客户端 SDK 会缓存这份"槽 → 节点"的映射表,之后直接连对节点,不用每次都重定向(当然,映射变化时会有一次 ASK 临时重定向)。
MOVED 是永久性的(槽已经迁走了),ASK 是临时性的(迁移进行中,这次去新节点问,下次还回旧节点)。面试区分这两个词,基本就能看出你玩没玩过真集群。
| 形态 | 解决什么 | 不给什么 | 适合场景 |
|---|---|---|---|
| 主从 | 读扩展、数据备份 | 不自动切换(主挂了要人工升主) | 读多写少、能接受分钟级人工介入的内部系统 |
| 哨兵 | 自动故障转移 + 客户端发现主 | 不扩内存(还是单主)、切换期间会短暂不可用 | 数据量在单机内存内、追求简单稳定的多数业务 |
| Cluster | 水平扩容 + 自动故障转移 | 不支持多库、跨槽操作受限、客户端和运维更复杂 | 数据量超过单机内存、需要持续扩容的大型业务 |
① 跨槽操作会直接报错。MGET a b c、MSET、事务(MULTI/EXEC)、Lua 脚本里操作的 key 必须都落在同一个槽。解法是 Hash Tag({1001}),但代价是——同一 tag 的 key 全在一台机器上,热点风险集中。这就是 Cluster 最核心的设计取舍:要么跨槽失败,要么热点集中。
② 只有一个数据库。SELECT 1 在 Cluster 里不被支持(只有 db0)。依赖多库隔离的旧系统迁移会很难受。
③ Pub/Sub 是广播的。集群里发布一条消息,所有节点都会转发,节点越多开销越大。大规模广播场景应改用 Stream 或外部 MQ。
④ 槽迁移期间会阻塞。扩容时 reshard 迁移大 key 会阻塞迁移,也可能让客户端频繁收到 ASK 重定向。建议低峰期做,且提前把大 key 拆掉。
另外,Cluster 也不保证强一致:主节点接收到写之后如果来不及同步就宕机,从节点升主会丢这段数据。Redis 从来不是 CP 系统,别拿它当账本。
9.5 缓存三坑:穿透、击穿、雪崩
这三个词面试问烂了,但真正做到位的团队不多。它们名字像、成因完全不同,方案也不能互串——先把三者的区别刻在脑子里。
| 坑 | 成因 | 解决方案(按优先级) |
|---|---|---|
| 穿透 | 查一个数据库里也不存在的数据,缓存永远不命中,每次请求都打到数据库。典型场景是恶意用随机 id 刷接口 | ① 缓存空值(短 TTL,如 60 秒)最简单;② 布隆过滤器前置拦截存在的 key 集合,不存在的直接拒;③ 接口层参数校验 + 限流(恶意流量靠这个挡) |
| 击穿 | 某一个热点 key 过期的瞬间,成千上万并发同时发现 miss,一起冲去查数据库,把数据库打挂 | ① 互斥锁/单飞(singleflight):只让一个请求去构建缓存,其余等待复用;② 热点 key 逻辑过期:不设 TTL,value 里存过期时间,逻辑过期后由一个线程异步重建,其他请求先返回旧值 |
| 雪崩 | 大批 key 同时过期(比如凌晨批量预热都设了同样的 1 小时),或者 Redis 整个集群挂了,请求全砸到数据库 | ① TTL 加随机抖动(基础值 + 随机 0~300 秒),把过期时间打散;② Redis 高可用(哨兵/Cluster),减少"整体挂";③ 服务端熔断降级 + 限流,保数据库这条命;④ 多级缓存(本地缓存 Caffeine + Redis) |
互斥锁构建缓存(防击穿,注意"锁超时 + 二次检查")
论缓存一致性:先更库还是先删缓存
三坑之外,最常被问的是"怎么保证缓存和数据库一致"。标准答案是 Cache Aside(旁路缓存):读:先读缓存,未命中读库并回填;写:先更新数据库,再删除缓存(注意是删,不是更新——更新容易在并发下写出旧值)。
为什么是"先更库、后删缓存"?反过来的话,删完缓存、还没更库,这时来了个读请求会把旧值重新写进缓存,之后就更不掉了。即使按正确顺序,理论上仍有极小概率不一致(读请求恰好在"更库"和"删缓存"之间把旧值回填)。工程上的兜底是:
① 延迟双删:更库前删一次、更库后延迟几百毫秒再删一次。
② 给缓存设较短的 TTL:不一致窗口最多就是一个 TTL,这是最省事也最有效的兜底。
③ 订阅 binlog(如 Canal)异步删缓存:把"删缓存"和业务代码解耦,可靠性更高,是大型系统的常见做法。
① 给所有 key 设同一个 TTL。这是"自己制造雪崩"。批量预热的数据,TTL 一定要加随机量。
② 防穿透用"缓存空值"但 TTL 设得和正常数据一样长。缓存空值是权宜之计,TTL 要短(几十秒),否则新数据写入后长时间读不到。
③ 布隆过滤器当成"绝对准确"。布隆过滤器有误判率:说不存在的一定不存在(可以放心拦截),说存在的可能不存在(还得查一次)。而且标准布隆过滤器不支持删除——数据删了它还说存在,要删就得用 Counting Bloom Filter 或定期重建。
④ 只做缓存不做熔断。Redis 挂的那一刻,所有请求会瞬间压到数据库。没有熔断降级(返回兜底数据/默认值)和限流(reject 部分流量),数据库会跟着一起挂,这就是真实的线上雪崩事故。
9.6 Redlock 分布式锁:它能做什么、不能做什么
先说最实用的部分:单实例 Redis 锁已经能解决 90% 的问题,写法只有一条命令加一个 Lua。
论Redlock:为什么有人要"半数以上"才上锁
单实例的大隐患是:主节点加锁成功后立刻宕机,从节点还没同步到这条锁——等它升主,这把锁就不存在了,第二个客户端能拿到同一把锁,两个人同时干同一件事。这就是"锁丢失"问题。
Redlock 算法的做法是:搞 N 个(通常 5 个)互相独立的 Redis 主节点(不是主从,是各自独立、没有复制关系),客户端:
① 记录开始时间,依次向 N 个节点用 SET NX PX 加锁,每个节点都要带超时(避免在某个挂掉的节点上死等)。
② 如果有超过半数(5 个里的 3 个)加锁成功,且总耗时小于锁的有效期,才算真正拿到锁。
③ 锁的有效时间 = 初始有效期 − 加锁消耗的时间(别拿到一把"已经快过期"的锁)。
④ 失败就向所有节点发起解锁(包括那些没加成功的)。
分布式系统社区为 Redlock 吵了很多年(Martin Kleppmann 与 antirez 的著名论战),结论对工程实践很有价值:
① 锁超时 + GC 停顿 = 两个客户端同时持锁。客户端 A 拿到锁后发生长时间 GC(或虚拟机暂停、时钟跳变),锁到期自动释放;客户端 B 拿到锁开始干活,A 从 GC 中醒来以为锁还在、也继续干活——互斥性被打破,Redlock 救不了你。
② 真正的解法是"fencing token"(栅栏令牌)。每次加锁返回一个单调递增的版本号,被保护的资源(比如数据库)记住已见过的最大版本号,拒绝更小版本号的写请求。这样迟醒的 A 拿着旧 token 去写就会被拒。但要注意:这要求存储端配合,很多场景做不到。
③ 所以锁的正确用法是"减少并发冲突",而不是"保证数据正确"。比如"防止同一个订单被重复处理",背后一定要再加一道数据库唯一键或状态机作为最终防线。锁失效了会退化成"两个请求都跑了",但这道防线保证数据不会错。
④ 时钟依赖也是隐患。Redlock 依赖各节点的墙上时钟近似正确;时钟跳变会让"有效期判断"出错。
| 方案 | 安全性 | 说明与适用 |
|---|---|---|
| 单实例 Redis 锁 | 中(主宕机可能锁丢失) | 最简单、性能最好。配合幂等兜底,绝大多数业务够用。推荐 Redisson。 |
| Redlock(5 节点) | 中高(仍受 GC 停顿影响) | 成本高(要 5 个独立实例)、争议大。除非有明确要求,否则优先选下面的 etcd/ZooKeeper。 |
| etcd / ZooKeeper 锁 | 高(基于共识算法、lease + 续约) | 要强一致时首选。etcd 用 lease + 事务 + revision;ZK 用临时顺序节点天然排队。代价是吞吐低于 Redis、运维成本更高。 |
| 数据库唯一键 / 状态机 | 最高(兜底防线) | 不是"锁",但最可靠。任何锁方案背后都应该有它。 |
① 忘了设过期时间 → 进程崩了锁永不释放,业务彻底卡死。SET NX 必须配 PX。
② 解锁直接 DEL → 误删别人的锁。必须 Lua 校验 value 是不是自己的标识。
③ 锁过期了业务还没跑完 → 两个人同时干。要么延长有效期,要么用 Redisson 的 watchdog 自动续期,要么接受"锁只是降低冲突概率"的现实。
④ 用同一把锁保护所有订单 → 全局串行化,吞吐直接归零。锁的粒度要细到具体的业务 id。
⑤ 锁内做了慢操作(调第三方、大批量写)→ 锁持有时间远超预期。锁里只做必要的最小操作。
⑥ 在 Cluster 里给锁和"要保护的 key"用不同槽 → 一旦涉及多 key 操作就会报 CROSSSLOT。用 Hash Tag 让它们同槽,或干脆把锁放在独立的 Redis 实例上。
9.7 小结:一张决策表
| 你的处境 | 建议做法 |
|---|---|
| 纯缓存,数据丢了能从库里重建 | 可以关掉持久化;但一定要配高可用和熔断,别让 Redis 挂掉变成数据库事故。 |
| 缓存 + 少量不可再生数据(如计数器、会话) | 开混合持久化(appendonly yes + aof-use-rdb-preamble yes),appendfsync everysec,保留 20% 以上内存余量给 COW。 |
| 数据量在单机内存内,要自动故障转移 | 主从 + 3 个哨兵(quorum=2,分散在不同机器)。客户端用 Sentinel 感知的连接池。 |
| 数据量超过单机内存,或需要持续扩容 | Cluster(至少 3 主 3 从),用 Hash Tag 处理多 key 操作,避开跨槽命令,扩容前先拆大 key。 |
| 怕缓存挂了打垮数据库 | 随机 TTL + 互斥重建 + 空值缓存 + 本地缓存 + 熔断限流,五道防线都上。只做前三个是不够的。 |
| 需要分布式锁 | 优先单实例 Redis 锁(Redisson)+ 幂等兜底;对锁安全性要求极高时用 etcd / ZooKeeper。永远别忘了数据库层的那道最终防线。 |
① 持久化选"混合":RDB 打底恢复快,AOF 记尾丢得少,everysec 是性能与安全的平衡点。别忘了 fork 的阻塞和 COW 的内存峰值。
② 复制是异步的,主节点宕机一定可能丢最新写入;主从延迟会让"写后立刻读"翻车,要针对性处理。
③ 哨兵管可用性(主观下线 → 客观下线 → Raft 选 Leader → 选 offset 最大的从节点升主),Cluster 管扩容(16384 槽 + MOVED/ASK)。两者都不保证强一致。
④ 缓存三坑:穿透缓存空值/布隆过滤,击穿互斥重建/逻辑过期,雪崩随机 TTL/高可用/熔断降级。三者别混淆。
⑤ 分布式锁降低冲突,不保证正确。锁背后必须有幂等(唯一键/状态机)兜底,这才是数据不错乱的真正原因。