
1. 为什么这个问题能吵这么多年分布式锁到底选 Redis 还是 Zookeeper这个问题我在面试候选人的时候问过不下几十次在技术方案评审会上也被同事拿来来回回争过。说句大实话很多人纠结的其实不是锁本身而是对这两种中间件底层机制的理解和信任程度不一样。有人觉得 Redis 快、轻、项目里本来就有凭什么不用有人觉得 ZK 天然就是干协调这活的Redis 那套 TTL 过期方案听着就像在走钢丝。先明确一点分布式锁解决的根本问题只有一个在多个进程、多台机器之间保证某个共享资源同一时刻只能被一个客户端操作。单机场景下我们用 synchronized、ReentrantLock 就能搞定因为 JVM 内存是同一个锁状态大家都能看见。但服务拆成多个实例之后每个 JVM 各管各的内存不互通这时候就必须有一个第三方组件来存储“谁拿了锁”这个状态。这个第三方组件最常见的两个选择就是 Redis 和 Zookeeper。这篇文章我会把两种方案从实现原理、代码写法、极端情况下的表现、运维成本这几个维度完整拆一遍最后给一个可以直接套用的选型思路。不管你是在做微服务改造、写秒杀系统还是单纯准备面试这篇内容都能让你少走不少弯路至少下次遇到“到底用哪个”的讨论你心里会有一个基于场景而不是基于偏好的答案。2. 基于 Redis 实现分布式锁从 SETNX 到 Redisson2.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 上写入了锁但这条数据还没同步到 slavemaster 恰好挂了哨兵把 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 的单机十万级 QPSZK 的加锁性能通常在几千到一万级别在高频加解锁场景里劣势很明显。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 status2, versionversion1 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 停顿看看你们的业务到底会出现什么现象。很多坑光靠脑子想是想不到的亲手复现一次比看十篇选型博客都管用。