分布式锁实现方案详解:Redis、Redlock、ZooKeeper 与 Redisson 看门狗
同样叫分布式锁,Redis、ZooKeeper、etcd 在失效语义、故障模型和运维成本上差别巨大,选错实现的代价往往在故障发生时才暴露。本文拆解三路实现的核心机制:Redisson 的可重入与看门狗、Redlock 的设计与围绕它的著名争论、ZK 临时顺序节点如何消除惊群、etcd 的租约与版本号,最后给出对比表和选型决策路径。
它解决什么问题
选分布式锁实现时,要比较的从来不只是”能不能锁住”,而是三个更具体的问题:持锁者崩溃后锁怎么回收、锁服务自身故障时互斥是否仍然成立、临界区执行超出预期时会发生什么。
每个候选实现都有自己的阿喀琉斯之踵。Redis 单节点逻辑简单,但生产环境必然主从部署,异步复制意味着故障转移可能丢掉刚写入的锁;ZooKeeper 靠会话绑定的临时节点回收锁,语义干净,可一次长时间 Full GC 就可能让会话过期、锁易主,而旧持有者还蒙在鼓里;etcd 与 ZK 类似但生态不同。所谓选型,就是把这些故障模型摆到台面上,对照业务的容错底线做匹配——锁失效只是造成一次重复计算,还是会造成库存错误、资金差错,答案不同,选择就不同。
核心原理
Redis 路线:单条 SET key value NX PX ttl 原子完成加锁,value 存随机 UUID 标识持有者,释放用 Lua 脚本校验身份后删除。Redisson 在此基础上补齐工程短板:hash 结构支持可重入、看门狗自动续期、订阅通知式阻塞等待。针对主从丢锁,社区给出 Redlock——向多个相互独立的 master 加锁、过半成功才算成功,但这场”多数派”设计引发了持续至今的争论。
ZooKeeper 路线:利用临时顺序节点加锁,会话消失节点自动删除,天然解决持锁者崩溃的回收问题;加锁失败的客户端只监听自己的前驱节点,避免全员惊醒。Curator 的 InterProcessMutex 把这套机制封装成可重入锁。
etcd 路线:基于 Raft 的强一致键值存储,用 lease(租约)绑定 key,租约到期 key 自动删除;每个 key 带全局单调递增的 revision,配合事务(Txn)做 compare-and-swap,revision 还能直接充当 Fencing Token。
深入机制
Redisson:可重入与看门狗
Redisson 的锁在 Redis 里是一个 hash:key 是锁名,field 是 客户端UUID:线程ID,value 是重入计数。同一线程再次加锁时只需把计数加一,解锁时递减,归零才真正删除 key——可重入由此而来。
看门狗解决 TTL 与业务耗时的矛盾:不显式指定锁时长时,默认租期 30 秒,后台定时任务每 10 秒(租期的三分之一)检查持锁线程是否存活,活着就把过期时间重置回 30 秒,续期同样走 Lua 脚本保证原子性。进程真死则看门狗同归于尽,锁按时过期,机制自洽。但要注意看门狗防的是”业务没跑完”,防不了”进程被冻结”:Full GC 或容器挂起让续约和业务线程一起停摆,锁过期易主后旧线程恢复执行,互斥已被打破。
Redlock 与那场著名争论
Redis 主从复制默认异步:客户端在主节点写入锁即返回成功,同步到从节点之前主宕机,Sentinel 把没收到锁数据的从节点提为新主,锁凭空消失。Redlock 的对策是绕开主从,客户端依次向 N 个相互独立的 Redis master(通常 5 个)用相同 key、value、TTL 加锁,满足两个条件才算成功:至少 N/2 + 1 个节点加锁成功;总耗时小于锁的 TTL。锁的实际有效时间约为 TTL − 加锁耗时 − 时钟漂移余量,失败时要向所有节点发起释放。
2016 年,《数据密集型应用系统设计》作者 Martin Kleppmann 发文公开批评 Redlock,核心论点是:Redlock 的安全性依赖有界网络延迟、有界进程暂停、有界时钟漂移三个假设,而真实系统中 GC 停顿、时钟跳变、延迟报文随时会打破这些假设。他构造的反例很典型:客户端 A 拿到锁后被 GC 冻结,锁过期,客户端 B 拿到锁并开始写,A 恢复后继续写,互斥失效——而这个场景下没有任何算法层面的违规,纯粹是租约模型的固有缺陷。他的结论是:正确性场景必须引入 Fencing Token,由资源端拒绝过期令牌。
Redis 作者 antirez 随即撰文反驳,认为工程实践中时钟问题可控、随机 value 已能防止大部分误删。这场争论对从业者的真正价值不是站队,而是学会一个判断框架:先区分效率型加锁(锁失效只是多算一次,可用 Redis)与正确性型加锁(锁失效就是资损,需要 ZK/etcd + Fencing Token),再谈实现。生产上 Redlock 很少被采用,理解争论比会用算法更重要。
ZooKeeper:临时顺序节点与惊群优化
ZK 加锁流程:所有客户端在 /locks 持久节点下创建临时顺序节点,形如 lock_0000000042;创建后检查自己的序号是否全场最小,是则持锁,否则只监听序号比自己小一位的前驱节点;前驱节点被删除(主动释放或会话过期)时收到通知,再次检查自己是否最小。释放即删除节点。
两个设计细节值得展开:
- 为什么用临时节点:临时节点的生命周期与会话绑定,客户端进程崩溃、网络分区导致会话超时后,ZK 服务端自动删除节点,锁被回收。这把 Redis 里”TTL 怎么设”的两难换成了”会话超时怎么设”,语义更贴近”持锁者活着”这个事实本身。
- 为什么用顺序节点且只监听前驱:若所有等待者都监听同一个节点,锁释放瞬间全员被唤醒、争抢、再睡下,这就是惊群效应,锁竞争激烈时 ZK 集群会被广播风暴拖垮。顺序节点让每次释放只唤醒一个后继者,同时序号天然定义了先来后到,得到的是一把公平锁。
可重入由客户端实现:Curator 的 InterProcessMutex 在本地维护 ConcurrentMap<Thread, LockData>,LockData 记录持锁线程、节点路径和重入计数,同一线程重入只增加计数,不触碰 ZK。注意其边界——可重入仅限同 JVM 同线程,跨进程的业务级重入要靠应用层身份与幂等设计。
ZK 并非高枕无忧:客户端长时间 GC 或网络抖动导致会话超时时,ZK 删节点、新客户端加锁,而旧客户端可能尚未感知会话失效,仍以为自己持锁。租约模型的窗口在这里同样存在,正确性场景依旧需要 Fencing Token。
etcd:lease 与 revision
etcd 的锁语义由两个原语拼出:
- Lease(租约):创建租约获得 TTL,把锁 key 挂到租约上,客户端周期性 keepalive 续约;客户端死亡、停止续约,租约到期后 key 自动删除。与 ZK 会话机制异曲同工。
- Revision(修订号):全局单调递增,每次写操作推进。抢锁用事务实现:
Txn().If(createRevision(key) == 0).Then(create key).Else(get key),判断 key 不存在则创建,存在则读取已有 revision 排队等待。fencing 更是开箱即用——把加锁时的 revision 带给下游资源,资源端只接受更大 revision 的写入。
云原生体系里 etcd 是标准件,Java 侧有 jetcd 客户端;传统 Java 微服务体系里 ZK + Curator 的生态更成熟。
Fencing Token 的落地形态
令牌本身只是一个单调递增的数,必须资源端配合校验才有意义:MySQL 可在业务表加 fencing_token 字段,写入用条件更新 UPDATE ... WHERE id = ? AND ? > fencing_token 拒绝旧令牌;对象存储用条件写或版本约束;etcd 直接用 revision;ZK 可用 zxid 或节点 stat 版本。如果下游资源不支持任何条件写,它就不适合承担正确性型锁的写入端。
工程实践
- Redis 锁 value 必须是随机 UUID,别用线程名这类可预测值;释放只走 Lua 校验删除。
- 明确接受主从切换窗口:关键写路径加唯一约束或 fencing 兜底,别指望锁本身绝对可靠。
- ZK 锁要监听会话事件,会话过期后主动终止临界区逻辑并报错,而不是继续写。
- 临界区能短则短:锁内不做 RPC、不跑长事务;监控锁等待时长与持有时长,P99 异常先怀疑临界区膨胀而非锁实现。
- 把”抢锁失败”的降级策略写进设计文档:快速失败、排队等待还是走幂等重试,别留给运行时随机发挥。
对比与权衡
| 维度 | Redis(Redisson) | ZooKeeper(Curator) | etcd |
|---|---|---|---|
| 一致性模型 | 主从异步复制,故障转移有丢锁窗口 | ZAB,多数派写入,CP | Raft,多数派写入,CP |
| 锁回收机制 | TTL + 看门狗续约 | 临时节点随会话删除 | lease 租约到期删除 |
| 进程暂停风险 | 存在(GC 冻结后续约失效) | 存在(会话超时误判) | 存在(keepalive 中断) |
| Fencing 支持 | 需自行生成令牌 | 可用 zxid/版本号 | revision 天然可用 |
| 吞吐与延迟 | 最高,单次操作亚毫秒 | 写走多数派,中高延迟 | 写走多数派,中高延迟 |
| 公平锁 | 支持(FairLock) | 顺序节点天然公平 | 按 revision 排队公平 |
| 运维成本 | Redis 通常已有 | 需维护 ZK 集群 | K8s 体系内通常已有 |
| 客户端生态 | Java 首选 Redisson | Curator 成熟 | jetcd 等 |
选型决策路径,按问题顺序回答:
- 锁失效的后果是”多算一次”还是”资损”?前者走 Redis,后者继续往下。
- 现有基础设施有什么?有 Redis 无 ZK/etcd 且场景可幂等兜底,就用 Redisson,别为一把锁引入新组件。
- 正确性要求高:已有 K8s/etcd 体系选 etcd,传统 Java 体系选 ZK + Curator,两者都务必叠加 Fencing Token。
- Redlock 仅在”要 Redis 性能、又要高于单节点的可靠性、且能接受其假设”时考虑,多数情况下它是面试题而不是生产答案。
面试视角
Redis 主从切换为什么会导致锁丢失? 主从复制是异步的,主节点写入锁后立即向客户端返回成功,数据尚未同步到从节点时主宕机,故障转移提升的新主缺少这条锁记录,其他客户端得以重复加锁,互斥被打破。这是单主 Redis 锁的固有窗口,Redlock 正是为缓解它而设计。
Martin Kleppmann 为什么批评 Redlock? 他认为 Redlock 的安全性建立在有界网络延迟、有界进程暂停、有界时钟漂移三个假设上,而 GC 停顿、时钟跳变、延迟报文在真实系统中随时打破假设:客户端持锁期间被冻结,锁过期易主后恢复执行,互斥即失效且算法本身无违规。他的主张是正确性场景必须靠 Fencing Token 让资源端兜底,而不是信任租约。
ZooKeeper 锁为什么要用临时顺序节点? 临时节点把锁生命周期绑定到会话,客户端崩溃后自动回收,天然防死锁;顺序节点让每个等待者只监听自己的前驱,锁释放时只唤醒一个后继,既消除惊群效应,又借序号实现先来后到的公平性。
ZK 锁和 Redis 锁的失效语义差在哪? Redis 锁活到 TTL 到期,与持锁者状态解耦,需要看门狗续约;ZK 锁活到会话结束,与持锁者连接状态绑定,崩溃即回收、无需续约,但 GC 或网络抖动可能造成会话误判,让锁提前易主。两者在进程暂停场景下都有窗口,正确性场景都要 fencing。
etcd 做锁相比 ZK 有什么特点? 机制上 lease 对应会话、revision 对应顺序节点,能力对等;差异在生态与接口:etcd 是 K8s 标准件、HTTP/gRPC 接口友好、revision 可直接充当 fencing token,而 ZK 在 Java 传统微服务体系中客户端更成熟。
小结
- 选型的本质是把各实现的故障模型与业务容错底线做匹配:效率型加锁用 Redis,正确性型加锁用 ZK/etcd 并叠加 Fencing Token。
- Redlock 用多数派独立节点换可靠性,但其安全性假设在进程暂停与时钟跳变下不成立,理解 Kleppmann 与 antirez 之争比背算法步骤重要。
- ZK 的临时顺序节点一招解决三件事:崩溃回收、消除惊群、公平排队,但会话超时误判是其固有软肋。
- etcd 以 lease + revision 提供对等能力,revision 天然可作 fencing,云原生体系优先。
- 任何基于租约的锁都存在”旧持有者复活”窗口,资源端的 fencing 校验是正确性的最后一道防线。

