Redis 深度教程 · 05
复制 · 哨兵 · 集群 · 分布式锁
前四章都在单实例内。这一章把 Redis 拉到多节点:数据如何在节点间复制、主库宕机时谁来顶上、数据如何跨节点分片,以及一个把基石二(单线程命令原子性)用到极致、同时至今仍有争议的话题——分布式锁。
本章你将建立的 schema
- 复制是异步的——客户端收到
OK时写可能还未到达副本,故障切换后该写可能丢失 - 哨兵(Sentinel)通过主观下线→客观下线→仲裁(quorum)完成自动 failover
- Cluster 用 16384 个 hash 槽(slot)分片,MOVED 与 ASK 是两种不同语义的重定向
- 分布式锁从
SETNX+EXPIRE演进到SET NX PX+ Lua 释放,再到 Redlock,但 Kleppmann 的批评表明 Redlock 对「正确性」场景仍不安全
复制的全量同步传输的是 RDB 快照(第 03 章讲的 fork + COW);分布式锁的原子获取和 Lua 原子释放,根基是单线程串行执行(第 02 章的基石二)——多条命令在 Lua 脚本里整体提交,主线程一口气执行完,中间不被任何其他命令插入。这两个连接贯穿本章。
5.1主从复制
一主多副本:主库(master)处理写,副本(replica)异步接收写命令的字节流并重放;副本只读,主库宕机后副本的数据可能落后于主库最后一批已确认写。
单实例无法扩读,且一旦宕机数据全失。复制把数据冗余到多个节点:读流量分摊到副本,主库宕机后副本可接管服务。但「异步」两字埋下一个关键取舍——主库把写结果返回给客户端的那一刻,副本不一定已经收到这条写。这不是设计疏漏,而是在延迟与持久性之间选了延迟。
全量同步
副本第一次连上主库(或因断线太久无法走增量),走全量同步(full resync):
- 副本发送
PSYNC ? -1,表示没有已知的复制偏移量。 - 主库执行
BGSAVE(fork 一个子进程,利用 COW 生成 RDB 快照,见第 03 章),同时把这段时间到来的写命令写入复制积压缓冲区(replication backlog)。 - RDB 文件通过 socket 传输到副本,副本清空本地数据并载入 RDB。
- 载入完成后,主库把缓冲区里积累的写命令一次性发给副本,副本从此进入增量同步。
增量同步与复制积压缓冲区
主库为每条写命令分配一个复制偏移量(replication offset),并将命令字节流写入一个固定大小的环形缓冲区——复制积压缓冲区(默认 1MB,由 repl-backlog-size 控制)。副本断线重连后发送 PSYNC <runid> <offset>;若主库的 backlog 中还保有副本缺少的那段写,就直接补发这段差量(部分重同步,partial resync),避免重传整个 RDB。若断线期间写入量超过 backlog 容量,缺失部分已被覆盖,则退回全量同步。
主库向客户端回复 OK 时,该写可能还在主库的 TCP 缓冲区里,尚未到达任何副本。若此后主库立刻宕机,哨兵提升某个副本为新主,这条已确认写就永久丢失。WAIT numreplicas timeout 命令可要求主库阻塞到至少 N 个副本确认收到,但这会提高延迟,且在 timeout 到期时仍不能保证。Redis 的复制设计目标是最终一致性,而非强一致性。
5.2哨兵 Sentinel
哨兵(Sentinel)是一组独立进程,承担三件事:持续监控主/副本是否存活、在主库不可达时自动执行 failover、向客户端提供「当前主库地址」的服务发现接口。
主从复制本身不包含故障检测与自动切换——主库宕机后副本依然原地等待。哨兵把运维的「发现宕机→手动切换→通知客户端」这个流程自动化。客户端只需在启动时连哨兵问「主库在哪」,哨兵负责始终维护这个答案的正确性。
主观下线与客观下线
单个哨兵每隔 1 秒向主库发 PING,若超过 down-after-milliseconds(默认 30000ms)没有有效响应,该哨兵单方面判定主库主观下线(Subjectively Down,SDOWN)。SDOWN 只是一个节点的看法,可能因网络抖动导致误判。
随后该哨兵向其他哨兵发送 SENTINEL is-master-down-by-addr 询问,收到足够多哨兵同意(票数 ≥ quorum 配置,通常为哨兵总数的多数派)后,判定主库客观下线(Objectively Down,ODOWN),触发 failover 流程。
选主与仲裁
进入 failover 的哨兵先通过 Raft-like 投票在哨兵集群中选出一个 Leader 哨兵(需 ≥ ⌊哨兵数/2⌋+1 票),由 Leader 独自完成选主逻辑。选副本的优先级顺序:① replica-priority 配置值小的优先;② 复制偏移量最大的(数据最新)优先;③ run ID 字典序最小的作为平局决胜。
选定新主后,Leader 哨兵向该副本发 REPLICAOF NO ONE,使其脱离副本角色;再把其他副本和客户端重定向到新主。客户端通过哨兵的 SENTINEL get-master-addr-by-name 拿到最新地址,或由客户端库(如 Jedis SentinelPool)自动在 failover 完成后重新连接。
quorum 和 Leader 哨兵选举的多数派是两个独立门槛,经常混淆。quorum 仅决定「够多少哨兵同意才触发 ODOWN」,可以设得比哨兵总数的多数派小(比如 3 个哨兵设 quorum=2),这会更快触发 failover 但也更容易误判。Leader 哨兵选举必须拿到 ⌊N/2⌋+1 票,这个值固定,无法配置。两者解耦,目的是允许在「灵敏度」和「选举严格性」上分别调节。
5.3Cluster:16384 槽与 MOVED/ASK 重定向
Redis Cluster 把键空间划分为 16384 个 hash 槽(slot),每个节点负责其中一段连续槽范围;键通过 CRC16(key) % 16384 映射到槽,从而路由到对应节点。
单实例(含哨兵主从)的写吞吐量和内存都受限于一台机器。Cluster 把数据水平分片到多组主从上,每组主库只负责一部分键,使写能力和内存随节点数线性扩展。代价是「跨槽多 key 操作」受限,以及客户端需要理解重定向协议。
为什么是 16384 个槽
槽的总数是 214 = 16384,而非更常见的 216 = 65536。原因在心跳包的大小:Cluster 节点之间定期交换包含槽位图(slot bitmap)的 gossip 心跳。16384 个槽需要 16384 bit = 2KB 的位图;65536 个槽则需要 8KB。集群节点数上限约 1000,16384 个槽对 1000 个节点已绰绰有余(每节点约 16 个槽),没必要为了扩展性付出 4× 的心跳带宽。
键到槽的映射:hash tag
槽号 = CRC16(key) % 16384。若 key 中包含花括号 {},则只用花括号内的子串参与哈希——这叫 hash tag。例如 {user1}.name 和 {user1}.age 都只用 user1 计算槽号,因此落在同一个槽,允许对这两个 key 执行 MSET 或 Lua 操作而不触发 CROSSSLOT 错误。
MOVED 与 ASK:两种不同语义的重定向
客户端向某节点发送命令,该节点若判断 key 不属于自己,会回一个重定向错误。有两种:
| 错误类型 | 触发时机 | 客户端动作 | 是否更新本地槽表 |
|---|---|---|---|
MOVED <slot> <ip:port> |
槽已永久迁移到新节点 | 向新节点重发请求 | 是,更新本地槽映射表 |
ASK <slot> <ip:port> |
槽正在迁移中,key 已迁到目标节点但迁移未完成 | 先发 ASKING,再向目标节点重发请求,仅此一次 |
否,不更新槽表(迁移未完成) |
区别的核心:MOVED 表示槽归属已定,客户端应更新本地的槽→节点映射缓存,之后对该槽的请求直接发新节点;ASK 表示这是迁移过程中的临时状态,客户端只为这一次请求做一次性重定向,不改变槽表,等迁移完成后收到 MOVED 再更新。
在 Redis Cluster 上执行 MSET foo 1 bar 2 baz 3,其中 foo、bar、baz 分别落在三个不同的槽上,会发生什么?
展开思路与答案
返回 CROSSSLOT Keys in request don't hash to the same slot 错误。Redis Cluster 不支持对多个不同槽的 key 做单条多 key 命令(MSET/MGET/DEL 等)——因为这些 key 可能分布在不同节点,节点无法跨节点做原子操作。解法:用 hash tag 把相关 key 强制路由到同一槽,如 {basket1}.foo、{basket1}.bar,或在客户端拆成多次单 key 请求。
5.4分布式锁演进
分布式锁的核心诉求是:同一时刻最多一个客户端持有锁。Redis 的原子命令让它成为热门实现方案,但从 SETNX+EXPIRE 到 Redlock,每一步演进都在修补上一步留下的漏洞——且争议至今未定。
单线程串行执行(基石二)保证每条命令的原子性。正是凭借这一点,「判断锁不存在」和「设置锁」才能作为一个原子操作完成,不存在两个并发客户端同时通过「锁为空」检查的窗口。Lua 脚本整体提交后,主线程一口气执行完脚本里的所有命令,中间零插入——这是分布式锁在 Redis 上成立的物理基础。
① SETNX + EXPIRE:两条命令的非原子漏洞
最朴素的实现:先 SETNX lock:resource 1(Set if Not eXists),成功后再 EXPIRE lock:resource 30。问题在于这是两条独立命令:若进程在 SETNX 成功后、EXPIRE 执行前崩溃,锁永远不会过期——死锁。这个窗口虽窄,但线上真实发生过。
② SET NX PX:原子获取
Redis 2.6.12 起支持扩展语法,把获取锁和设置过期合并为一条原子命令:
SET lock:resource <unique-uuid> NX PX 30000
# NX : 仅当 key 不存在时设置
# PX 30000 : 过期时间 30000 毫秒
# 返回 OK 表示获取成功,nil 表示已被持有
value 必须是唯一标识符(UUID 或进程+线程组合)。原因见下一步。
③ Lua 原子释放:compare-and-delete
释放锁时,不能直接 DEL lock:resource——若当前锁已被别人获取(自己的锁已过期),这一 DEL 会删掉别人的锁。正确做法是「先比对 value 是否是自己的,再删除」,这两步必须原子完成,否则比对通过后到删除前,锁恰好过期被他人获取,又回到问题。Lua 脚本是解法:
-- KEYS[1] = lock key, ARGV[1] = 当前持有者的 unique value
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
GET 和 DEL 在 Lua 里整体提交,主线程一口气执行,中间不插入其他命令。value 比对确保只有持锁者能释放自己的锁。
④ Redlock:N 个独立 master 上的多数派锁
上述方案在单实例(含哨兵)上有一个根本漏洞:主库宕机、副本接管时,若刚才已确认的获锁写还没同步到副本,新主上不存在这把锁,另一个客户端可以再次获取——同一把锁被两个客户端同时持有。
Redlock 算法(antirez 提出)用 N 个相互独立的 Redis master(无主从关系,互不依赖)解这个问题:
- 记下当前时间戳 t₀。
- 依次向 N 个 master 发送
SET NX PX获取请求(超时时间远小于锁的 TTL,避免因单节点慢而阻塞太久)。 - 若在 ⌊N/2⌋+1 个以上节点获取成功,且耗费的总时间 Δt < 锁 TTL,则认为加锁成功;锁的有效剩余时间 = TTL − Δt。
- 若未达到多数派,或 Δt 超出 TTL,则向所有节点发送释放命令,本次加锁失败。
即使其中 ⌊N/2⌋ 个节点宕机,剩余节点仍可构成多数派,锁依然可用。antirez 建议 N = 5。
⑤ Kleppmann 的批评:GC 停顿与时钟漂移
Martin Kleppmann(《数据密集型应用系统设计》作者)在 2016 年发表了How to do distributed locking,指出 Redlock 对「正确性」场景(即绝对不能两个客户端同时操作受保护资源)不安全,核心论证如下:
GC 停顿(Stop-the-World,STW)场景:客户端 1 获取锁,随即进入 JVM GC 的 STW 停顿。STW 期间锁过期,客户端 2 获取到同一把锁,开始操作资源。STW 结束,客户端 1 从停顿前的代码点恢复,它并不知道锁已过期,继续操作资源——两个客户端并发操作同一资源,互斥失效。
时钟漂移:Redlock 依赖各节点的系统时钟判断锁是否过期。若某节点时钟向前跳跃(NTP 调整、虚拟化环境的时钟漂移),锁会提前过期,导致同样的并发持锁问题。
Kleppmann 提出,唯一安全的解法是在资源端引入fencing token(围栏令牌,单调递增令牌):锁服务每次授权时附带一个严格递增的令牌号,资源端记录已处理的最大令牌号,拒绝令牌号更小的请求——即使旧令牌持有者的请求后来才到达,也无法覆盖新令牌持有者的写入。Redis 无法生成这样的令牌,因此对「正确性」场景,他建议用 ZooKeeper(内建 zxid 单调递增)。
⑥ antirez 的回应
antirez 在Is Redlock safe?中回应:Redlock 面向效率(efficiency)而非绝对正确性(correctness),设计目标是「在多数节点正常时,减少并发进程同时操作的概率」,而非在所有故障模式下保证绝对互斥。他认为时钟漂移在实践中可以被工程手段约束到足够小;随机锁值(unique value)可以在资源端做一种弱形式的 fencing 校验。双方的分歧实质上是对「分布式锁应当提供效率保证还是正确性保证」的前提不同。
截至 2026 年,Redlock 的安全性争议尚无公认定论。工程上的经验性共识:效率场景(同一时刻避免多个进程重复执行某任务,偶发一次重复可接受)——单实例 Redis 锁(SET NX PX + Lua 释放)已足够,Redlock 提供额外的节点故障容忍;正确性场景(绝对不能两个进程同时持锁,如金融扣款)——Redis 锁不够,使用 ZooKeeper / etcd(基于 Raft,提供 fencing token 语义)。
⑦ Redisson watchdog:解决持锁者提前过期
SET NX PX 30000 带来另一个问题:若持锁业务执行时间超过 30 秒,锁过期后别人可以获取,但原持锁者还在运行中——锁失效了,但业务不知道。
Redisson(Java Redis 客户端)的watchdog(看门狗)机制:调用 lock() 时不传 leaseTime(或传 -1),Redisson 在后台启动一个定时任务,每隔约 10 秒(lockWatchdogTimeout 的 1/3,默认 30s / 3)向 Redis 发一次续租命令,将锁的 TTL 重置回 lockWatchdogTimeout(默认 30000ms)。只要持锁进程存活,锁就不会提前过期。进程崩溃后 watchdog 停止续租,锁在 30 秒内自然过期,不会产生死锁。
传了 leaseTime 参数(如 lock(30, TimeUnit.SECONDS))时,Redisson 不启动 watchdog——锁将在 30 秒后强制过期,不管业务是否完成。这用于「即便进程卡死,也必须在固定时间内释放锁」的场景。不传 leaseTime 时,watchdog 兜底,适合「业务时长不可预测、但进程健康时不应让锁提前过期」的场景。两种模式各有适用场景,不能无脑选一个。
客户端已收到主库返回的 OK,确认一条写命令成功。此后主库宕机,哨兵完成 failover,将某个副本提升为新主。这条写一定还在新主上吗?
展开思路与答案
不一定。复制是异步的:主库向客户端返回 OK 时,该写可能还在 TCP 缓冲区或 replication backlog 中,尚未到达任何副本。若主库在写传到副本前宕机,副本上不存在这条写;failover 后新主对外提供服务,这条写永久丢失。这是 Redis 异步复制的根本取舍——为换取低延迟,放弃了强持久性保证。WAIT 命令可以降低(但不能消除)这一风险。
·自测
- 主从复制的「全量同步」和「增量同步」各在什么情况下触发?复制积压缓冲区(repl backlog)在其中扮演什么角色?
- 哨兵的「主观下线」与「客观下线」分别是什么?为什么需要区分两者,单个哨兵判定宕机后直接 failover 有什么问题?
- Redis Cluster 使用
MOVED与ASK两种重定向,二者的核心区别是什么?客户端收到 ASK 后是否应更新本地槽表,为什么? - 设计判别:一个「绝对不能两个进程同时持锁」的场景(如金融系统扣减库存后写入数据库),应当用 Redis 分布式锁还是 ZooKeeper,判断依据是什么?如果用 Redis 锁,需要在哪里额外引入什么机制才能接近安全?
展开全部答案
1. 副本首次连接、或断线重连时 repl backlog 中已不存在副本缺失的那段写(缺失量超过 backlog 大小),触发全量同步:主库 fork 子进程生成 RDB 传输到副本,期间新写入缓冲在 backlog 中,传输完后补发。增量同步在副本断线重连、且 backlog 中仍保有缺失段时触发:主库只补发差量字节流。backlog 是一个固定大小(默认 1MB)的环形缓冲区,是避免因短暂断线而重走全量的关键——backlog 太小、写入量大,就会触发不必要的全量同步。
2. 主观下线(SDOWN):单个哨兵在 down-after-milliseconds 时间内未收到主库有效 PING 响应,单方面判定主库不可达。客观下线(ODOWN):多个哨兵协商,票数达到 quorum 配置后,集体判定主库不可达,触发 failover。区分的原因:单个哨兵与主库之间可能因网络抖动、交换机丢包等原因误判,而多数哨兵同时与主库失联的概率远低,可大幅降低误 failover 的概率(failover 本身会引起短暂服务不可用和数据风险)。
3. MOVED 表示槽已永久迁移到新节点,客户端应更新本地槽映射表,之后对该槽的请求直接发新节点。ASK 表示槽正在迁移中,key 已移到目标节点但迁移未完成,客户端仅对此次请求发 ASKING + 重发,不更新槽表——因为迁移尚未完成,槽归属关系还未正式确定,贸然更新槽表会在迁移完成前后造成路由混乱。
4. 「正确性场景」应优先选 ZooKeeper(或 etcd)。依据:ZooKeeper 基于 Raft/ZAB 共识协议,可提供严格单调递增的 zxid 作为 fencing token;资源端可拒绝令牌号落后的请求,即使出现 GC STW 停顿或时钟漂移,旧令牌的写入也不会被接受。Redis 锁的问题在于:① 异步复制导致 failover 时可能重复授权同一把锁;② 无法生成严格单调递增的 fencing token,无法让资源端区分「旧令牌持有者」和「新令牌持有者」的写入。若业务约束只能用 Redis,需在资源端自行维护一个单调递增的版本号(每次成功写入后递增),写入时比对传入的版本号是否 ≥ 当前已记录最大值,拒绝落后版本的写入——这等价于在应用层实现 fencing token,代价是资源端需要配合改造。
挑出「用 Redis 锁保护扣库存」方案的漏洞
某电商系统用如下方案保护库存扣减:① 获取锁:SET lock:sku:123 <uuid> NX PX 5000;② 读库存 → 判断是否足量 → 扣减 → 写回;③ 释放锁(Lua compare-and-delete)。分析在时钟漂移或 JVM GC STW 场景下,该方案在哪一步失效,以及若要实现真正的「不超卖」,资源端(数据库)需要如何配合。
提示(不给全解)
失效路径:步骤②耗时超过 5 秒(或时钟使锁提前过期)→ 锁自动过期 → 另一个进程获取锁、开始执行步骤② → 两个进程并发读到相同的库存数值、各自判断足量 → 各自扣减 → 超卖。此时步骤③的 Lua 只能防止「删他人的锁」,无法回滚已执行的库存写入。资源端配合方向:考虑在数据库层用乐观锁(版本号 CAS:UPDATE stock SET cnt=cnt-1, version=version+1 WHERE sku=123 AND version=<expected>,影响行数为 0 说明已被并发修改,回滚重试)或数据库行锁(SELECT FOR UPDATE)在持久化层面保证原子扣减,Redis 锁退化为「减少数据库并发压力」的效率手段而非正确性保障。