1. 为什么这个问题能吵这么多年
分布式锁到底选 Redis 还是 Zookeeper,这个问题我在面试候选人的时候问过不下几十次,在技术方案评审会上也被同事拿来来回回争过。说句大实话,很多人纠结的其实不是锁本身,而是对这两种中间件底层机制的理解和信任程度不一样。有人觉得 Redis 快、轻、项目里本来就有,凭什么不用;有人觉得 ZK 天然就是干协调这活的,Redis 那套 TTL 过期方案听着就像在走钢丝。
先明确一点,分布式锁解决的根本问题只有一个:在多个进程、多台机器之间,保证某个共享资源同一时刻只能被一个客户端操作。单机场景下我们用 synchronized、ReentrantLock 就能搞定,因为 JVM 内存是同一个,锁状态大家都能看见。但服务拆成多个实例之后,每个 JVM 各管各的,内存不互通,这时候就必须有一个第三方组件来存储“谁拿了锁”这个状态。这个第三方组件,最常见的两个选择就是 Redis 和 Zookeeper。
这篇文章我会把两种方案从实现原理、代码写法、极端情况下的表现、运维成本这几个维度完整拆一遍,最后给一个可以直接套用的选型思路。不管你是在做微服务改造、写秒杀系统,还是单纯准备面试,这篇内容都能让你少走不少弯路,至少下次遇到“到底用哪个”的讨论,你心里会有一个基于场景而不是基于偏好的答案。
2. 基于 Redis 实现分布式锁:从 SETNX 到 Redisson
2.1 最基础的加锁逻辑是怎么写的
Redis 实现分布式锁的最核心命令是SETNX,全称是 SET if Not eXists,意思是“只有 key 不存在时才设置”。配合过期时间,早年大家是这么写的:
SETNX lock_order_1001 1 EXPIRE lock_order_1001 30这两条命令看起来没问题,但如果 SETNX 执行成功、EXPIRE 还没来得及执行,进程突然崩溃了,这个锁就会一直存在,其他线程永远进不来。这就是经典的“非原子操作导致的死锁隐患”。
正因为这个原因,Redis 2.6.12 之后官方把SET命令扩展了,支持NX、EX(或PX)选项,等于把两条命令合并成一条原子操作:
SET lock_order_1001 uuid_value NX PX 30000这条命令的含义是:只有这个 key 不存在时才设置它,同时带上 30 秒过期时间,并且把 value 设置为一个唯一的标识。为什么 value 要用 UUID?因为释放锁的时候要先校验“这个锁是不是我自己的”,不然可能出现一种很尴尬的情况:线程 A 的锁过了有效期自动释放,线程 B 拿到锁正在执行,然后 A 业务跑完了,执行DEL把 B 的锁也删了。这种情况往下深挖,就是我常说的“锁误删”,在高并发场景下一旦发生,等于锁形同虚设。
所以释放锁不能简单写一行DEL,而是要先把 key 的 value 取出来比较,如果和当初自己设置的一致,才允许删除。而且这个“比较+删除”也必须做成原子操作,于是 Lua 脚本就成了标准解法:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end把这段脚本通过 Redis 的 EVAL 命令执行,整个判断和删除过程就不存在中间环节被插队的可能。
2.2 过期时间怎么定:Redisson 看门狗机制
Redis 锁最麻烦的一个问题就是:过期时间设短了,业务还没执行完锁就自己释放了;设长了,万一持锁的进程真挂了,其他线程要干等很久。
这个矛盾在前几年几乎是 Redis 分布式锁被攻击最多的点。后来 Redisson 出现,用“看门狗”机制解决了大部分问题。Redisson 的lock方法默认会启动一个后台定时任务,每隔 10 秒(默认锁的 leaseTime 的三分之一)给锁续期一次。只要持锁的客户端还活着,锁就不会因为时间到了被自动释放;如果客户端真的死了,心跳停了,锁最多在租约到期后自动消失。
这个思路其实很像“租约”机制,Redis 里没有真正的会话概念,但通过持续的续期,模拟出了一个近似于 TTL 与客户端生命周期绑定的效果。实际使用时,建议优先选择 Redisson 而不是自己手动封装 Redis 分布式锁。自己写的锁,第一版往往只考虑“加锁/解锁”,第二版才想起来要续期,第三版才处理续期的线程安全,踩过的坑能写一页纸。
2.3 Redis 锁的三个真实隐患
第一个隐患是主从切换导致的锁丢失。Redis 的主从复制是异步的,线程 A 在 master 上写入了锁,但这条数据还没同步到 slave,master 恰好挂了,哨兵把 slave 提升为新的 master,此时新的 master 上没有这把锁,别的线程就能成功加锁。结果是两个线程同时持有同一把锁。
第二个隐患是客户端 GC 停顿。持锁线程发生长时间 Full GC,暂停了几十秒,锁在暂停期间过期了,线程 B 拿到锁进入临界区。线程 A GC 结束后并不知道自己的锁已经过期,继续操作共享资源。这个问题的本质是“锁的持有方无法感知锁状态已经失效”,和 ZK 的会话超时其实类似,我都遇到过线上真实案例。
第三个隐患是 RedLock 的争议。RedLock 的方案是同时向多个独立的 Redis 节点申请加锁,超过半数成功就算拿到锁。但这个方案发布后,分布式系统专家 Martin Kleppmann 专门写文章批评过,他认为 RedLock 依赖了不现实的时钟假设,Redis 作者 Antirez 也回应了一篇辩护文章。这个争论的结论我建议这么理解:不要让 Redis 承担超出它能力范围的强一致保证,如果业务真的无法接受锁丢失,那从一开始就不应该把宝押在 Redis 上。
3. 基于 Zookeeper 实现分布式锁:临时顺序节点的精妙之处
3.1 锁的自动释放机制
Zookeeper 实现分布式锁,核心依托是它的一种节点类型——临时顺序节点(EPHEMERAL_SEQUENTIAL)。临时节点的生命周期和会话绑定,客户端会话断开,节点自动被删除。这一点和 Redis 的 TTL 续期逻辑有本质区别:ZK 不依赖时间估算,而是由服务端主动感知客户端会话是否存活。
加锁的流程可以一句话概括:所有想要获取锁的客户端,在同一个锁目录下创建自己的临时顺序节点,谁创建的节点序号最小,谁就拥有锁。举个例子,三个客户端都在/lock/order_1001目录下创建节点:
/lock/order_1001/lock-0000000001 /lock/order_1001/lock-0000000002 /lock/order_1001/lock-0000000003序号最小的lock-0000000001对应的客户端获得锁,另外两个客户端处于等待状态。持有锁的客户端会话结束或者进程崩溃,临时节点被 ZK 自动删除,序号第二小的客户端立刻感知到这个删除事件,然后它就成为新的锁持有者。
这个机制让我印象最深的一点是:不需要设置超时时间,也就不存在“业务还没跑完锁被自动释放”的问题。锁的生命周期完全跟随会话,只要客户端和 ZK 的心跳正常,锁就能一直持有。这一点在逻辑上比 Redis 的 TTL 方案干净很多。
3.2 等待与监听:如何避免羊群效应
一开始大家实现 ZK 分布式锁时,习惯让所有等待的客户端都去监听自己前面那个最小节点的删除事件。但这里有一个性能陷阱,业界叫“羊群效应”:如果锁被释放,成千上万的客户端同时被唤醒,同时去 ZK 查节点列表,ZK 要扛住一波巨大的读请求冲击。
正确的做法是:每个客户端只监听排在它前面的那个节点。比如lock-0000000003只监听lock-0000000002的删除事件,lock-0000000002只监听lock-0000000001。前面一个节点释放,只唤醒下一个客户端,形成一种类似传递锁的链条。
这里有一个细节要注意:Watcher 在 ZK 中是一次性的,触发一次之后就不会再触发,所以客户端在事件回调中必须重新注册 Watcher,还要在唤醒后再次检查自己是否仍是最小节点。因为可能在唤醒的瞬间,有新的客户端插队创建了序号更小的节点。这个过程写成代码就是“注册监听 → 被唤醒 → 再次获取子节点列表 → 判断自己是否最小 → 不满足则继续注册监听”这样一个循环。
3.3 Curator 框架与 ZK 锁的边界情况
实际开发中很少会自己从头写这套逻辑,Apache Curator 提供了InterProcessMutex,直接封装好了临时顺序节点、监听和重入逻辑。用起来大概是这个样子:
CuratorFramework client = CuratorFrameworkFactory.newClient( "zk1:2181,zk2:2181,zk3:2181", new RetryNTimes(3, 1000)); client.start(); InterProcessMutex lock = new InterProcessMutex( client, "/lock/order_1001"); lock.acquire(); try { // 业务逻辑 } finally { lock.release(); }代码非常简洁,但这个方案也有自己的边界问题。最典型的是会话超时:如果客户端网络抖动,或者发生长时间 GC 导致和 ZK 的心跳中断,ZK 服务端会判定会话过期,自动删除临时节点。此时其他客户端就能拿到锁,但原客户端活过来之后并不知道自己已经失去了锁,继续执行临界区逻辑,依旧存在“两个客户端同时持锁”的窗口。
另一个问题是性能。ZK 的写入请求全部要经过 leader 节点,并且要完成 Zab 协议的两阶段提交,过半 follower 确认后才能返回成功。即使集群是 5 节点,可用性也不是无限高,极限情况是超过一半节点不可用时整个集群拒绝写入。对比 Redis 的单机十万级 QPS,ZK 的加锁性能通常在几千到一万级别,在高频加解锁场景里劣势很明显。
4. 正面 PK:性能、可靠性、运维成本的实打实对比
4.1 吞吐量和延迟差了多远
先看测试数据层面。Redis 的 SETNX 加锁就是一条内存写操作,单节点轻松跑到几万甚至十万 TPS,响应时间在亚毫秒到几毫秒之间。ZK 的加锁要创建临时顺序节点,这一步是写请求,需要走 leader 并完成持久化确认,响应时间通常在 5ms 到 20ms 之间,吞吐能力比 Redis 低一个数量级。
如果业务是秒杀场景,Redis 在性能上的优势是压倒性的。但这里要给一个忠告:分布式锁的瓶颈往往不在锁本身,而在你锁住的共享资源上。你锁的是数据库行记录,数据库的 QPS 就是天花板;锁的是库存服务,下游服务的处理能力才是关键。为了一把锁选择 Redis 让它在性能上遥遥领先,但下游一压就挂,意义其实不大。
4.2 可靠性:从 CAP 角度重新理解两个方案
从 CAP 的角度看,Redis 主从复制是典型的 AP 取向,在分区发生时更倾向于保证可用性,而 ZK 通过 Zab 协议实现线性一致性的写入,把 CP 属性放在首位。
落到锁场景里,这个差异具体表现为:Redis 在主从切换的瞬间可能丢失锁记录,导致两个客户端同时持锁;ZK 在写入锁节点时能保证一旦返回成功,数据已经存在于超过半数节点上,不会因为单点故障而丢失。
但这里一定要辩证看。ZK 的强一致性能保证的是“锁节点的写入顺序”是全局确定的,它并不能保证“客户端永远正确感知自己持锁状态”,因为会话超时和网络分区依然会造成锁被提前释放。所以严格说,世界上没有一种分布式锁能同时保证“互斥性”和“业务执行期间持锁者状态完全正确”,所有方案都是在互斥强度和工程代价之间取平衡。
4.3 运维成本和团队负担
这一项在实际选型中占比经常被低估。Redis 大多数公司已经有现成的集群或哨兵架构,加锁只是新增一个调用,运维层面几乎零负担。Zookeeper 虽然本身不算复杂,但它通常只服务于分布式协调场景,如果你的架构里没有 Kafka、Dubbo、HBase 这类依赖 ZK 的组件,为了锁单独维护一个 ZK 集群,等于给团队增加了一整套需要监控的中间件。
ZK 集群最少也要 3 台机器才能搭建,否则故障恢复能力无从谈起。而且 ZK 出问题时的排查门槛比 Redis 高,会话超时、节点数过多、JVM 堆外内存增长,这些都不是看一眼监控就能定位的。团队如果没有人对 ZK 内部机制有足够的经验,线上出问题的黄金时间很容易被浪费在百度焦虑上。
我把两个方案的核心差异整理成一张表,方便你直接对照需要关注的点:
| 对比维度 | Redis 方案 | Zookeeper 方案 |
|---|---|---|
| 锁状态存储本质 | key-value + TTL | 临时顺序节点 + 会话 |
| 自动释放机制 | 基于过期时间,需续期补偿 | 会话断开自动删除,无需设置超时 |
| 加锁性能 | 极高,单节点十万级 QPS | 一般,千到万级 QPS |
| 极端情况下互斥性 | 主从切换可能丢失锁,存在双持锁窗口 | 写入强一致,但会话过期仍有双持锁窗口 |
| 可重入支持 | Redisson 提供,需引入客户端库 | Curator 提供,标准封装 |
| 等待锁的方式 | 客户端自旋重试或阻塞等待 | 通过 Watcher 事件通知,链表式唤醒 |
| 运维成本 | 低,多数公司已有 Redis | 高,需单独维护集群 |
| 适用团队 | 已有 Redis 基础设施的绝大多数团队 | 已有 ZK 组件或对协调服务有强诉求的团队 |
5. 到底怎么选:一个可以直接套用的决策模板
5.1 按业务场景划分
我的建议不是“用哪个更好”,而是“你的场景更容不下哪一种失败”。
如果你的目标是防止重复提交、限流控制、秒杀库存防超卖这类场景,业务上偶尔出现一次“锁提前失效导致两个线程同时处理同一个请求”,后果通常是多扣了一次库存或者产生一条重复记录,可以通过接口幂等设计、事务补偿兜底。这种场景 Redis 完全够用,而且明显更轻盈,接入成本低到可以忽略。
如果你的目标是全局任务调度防重、金融转账互斥、主备切换决策、多机房写入强一致这类场景,锁一旦失效的代价极其昂贵,比如资金重复划拨、主备同时对外提供服务。这种场景请果断选 Zookeeper 或同等强一致能力的组件,不要拿 Redis 方案在核心资金链路上冒险。
还有一种情况值得单独说:如果你们的架构里已经用了 Zookeeper(比如用 Kafka 或者 Dubbo),那即使业务只是普通互斥需求,直接用现成的 ZK 也没什么不对。毕竟分布式系统里每多一个中间件,就多一分排查故障的心力,复用已有组件永远是最优解。
5.2 几个实战中的锁细节建议
锁的粒度一定是越小越好。不要锁一个全局的 key,比如“库存锁”这种,应该精确到stock:sku_1001这种级别。锁的粒度越粗,并发能力越差,而且锁等待链越长,越容易在流量尖峰时把线程全部阻塞住。
加锁和解锁务必成对出现在同一个 try/finally 中,且解锁代码放在 finally 的第一行,避免业务抛异常导致锁一直被占。无论用 Redis 还是 ZK,这个基本习惯出了问题,任何中间件都救不了你。
锁的 value 要用全局唯一的标识,不要用固定字符串。这样解锁时才能安全地“先比较再删除”,不会误删别人的锁。Redisson 内部已经默认处理了这一点,但如果你是自己封装 Redis 锁,这个点非常容易漏。
5.3 锁失效之后的补偿策略
不管选了哪个方案,我都建议在业务侧留一条后路。常见的做法是用数据库乐观锁在扣减库存或者更新状态时做最终的并发校验。比如更新订单表时带上版本号,UPDATE order SET status=2, version=version+1 WHERE id=? AND version=?,即使分布式锁因为极端情况失效,数据库这一层也能拦住一部分并发覆盖。
另一个策略是让业务操作本身具备幂等性。最典型的方式是给每次请求生成一个唯一的 requestId,处理方在执行业务逻辑前先检查这个 requestId 是否已经处理过。这样就算分布式锁形同虚设,重复请求也不会产生重复数据。这条思路算是我在线上踩过几次坑之后最深刻的体会:分布式锁是第一道防线,但永远不该是唯一一道防线。
6. 面试官为什么爱问这个问题:高频追问与回答主线
6.1 高频追问的破解思路
这个问题在面试里被问的频率太高了,本质上因为它是“考察候选人是否真正理解分布式系统取舍”的完美入口。面试官往往不会满足于一个二选一的结论,而是想看你能不能把底层原理讲清楚。
常见的追问包括:
- Redis 的
SETNX有什么坑,为什么后来改用 Lua 脚本? - Redisson 的看门狗机制是怎么实现的?
- ZK 的临时顺序节点是怎么保证锁的公平性的?
- 什么是羊群效应,怎么避免?
- 如果一个持锁线程发生 GC,两个方案分别会有什么表现?
- 你做过的最复杂的分布式锁排查案例是什么?
回答这些问题时,最大的忌讳是背八股文。比如“Redis 更快所以 Redis 好”,或者“ZK 更可靠所以 ZK 好”,这种单一维度结论不仅显得经验不足,还暴露了你没有从业务场景出发做过思考。比较好的打开方式是把问题拆成三层:先讲锁要满足什么条件,再对比两种方案的实现机制,最后落到场景权衡。
6.2 一条完整回答的参考主线
你可以这么组织你的回答:先说明分布式锁的三大基本要求——互斥性、防死锁、可重入,然后说 Redis 方案的核心是 SETNX 加过期时间,配合 Lua 脚本保证释放原子性,Redisson 用看门狗解决续期问题;再说 ZK 方案的核心是临时顺序节点加 Watcher 监听,客户端会话断开节点自动删除,Curator 封装了完整实现。最后落到选型:如果业务对可靠性要求极高或者团队已有 ZK 集群,选 ZK;如果追求性能和部署便捷,选 Redis,但要意识到主从切换可能带来锁丢失,需要业务侧兜底。
这样回答的好处是用“机制对比加场景结论”替代“谁好谁差”,既展示了原理深度,又展示了架构权衡能力。我自己面试的时候,只要候选人能把“为什么不用 MySQL 实现分布式锁”这个问题也顺带讲清楚,基本就会给他通过。因为你真的理解了锁的本质之后,你会发现中间件只是载体,问题始终是“如何在不可靠的网络和分布式状态下判断所有权”。
7. 回到开头那个问题
我个人的实际体会是:这个问题没有标准答案,但它有一个非常适合大多数业务的标准起步方案——先上 Redis 加 Redisson 把业务功能跑通,在代码层面做好幂等和数据库乐观锁兜底,等到出现了 Redis 锁确实无法接受的故障场景,再针对那一个场景评估是否迁移 ZK。不要为了技术上的“极致可靠”过早背上一个庞大的协调服务依赖,也不要在资金链路这种容不下风险的地方图省事。
最后再分享一个小技巧:不管最终选哪个,先在测试环境模拟一次真故障。把 Redis 的 master 进程 kill 掉、把 ZK 的某个 follower kill 掉、在持锁期间人为触发一次长时间的 GC 停顿,看看你们的业务到底会出现什么现象。很多坑光靠脑子想是想不到的,亲手复现一次,比看十篇选型博客都管用。