分布式锁从原理到选型:Redis、ZooKeeper、数据库方案全解析
做后端开发这两年分布式锁几乎是人人都绕不开的话题。尤其是去大厂面试Redis 的 SETNX、ZooKeeper 的临时顺序节点、数据库的唯一索引张口就能说出一两种方案的人不少但能讲清楚“为什么这么设计”“生产环境踩过哪些坑”的候选人其实并不多。这道“分布式锁一般都怎么实现”的题目表面考的是 API 使用实际上考的是你对分布式环境下并发控制的底层理解也顺带检验你到底亲手写过多少并发代码。这篇拆解我尽量按面试官想要的深度来讲从三种主流实现方案一直聊到选型依据和高频追问。不管你是在准备面试还是正在为项目选型建议都耐心看完尤其是最后的避坑部分都是我实际生产环境里真实遇到过的问题。1. 分布式锁到底要解决什么问题1.1 为什么本地锁在分布式环境下失效了很多同学一开始想不明白一个简单的 synchronized 就能搞定并发为什么要搞分布式锁在单机应用里多个线程共享同一块内存synchronized、ReentrantLock 这类 JVM 锁靠的是内存地址上的互斥标记线程之间天然可见所以能保证同一时刻只有一个线程访问临界资源。但一旦系统拆成多个实例每个实例是独立的 JVM 进程各自的内存互不相通。A 机器上的线程拿到了锁B 机器上的线程完全不知道两个进程同时去改数据库里的同一条记录大问题就来了。我印象最深的一次线上事故一个电商活动页面的库存扣减接口部署在两个实例上代码里用了本地锁。压测一开始库存就超卖了查了一会儿才发现两个实例各自放行了一个请求同一个订单号被创建了两次。从那以后我就彻底理解了分布式环境下的互斥需要一把“所有机器都看得见、都能访问的锁”这把锁不能放在某台机器自己的内存里必须放在大家都能访问到的公共组件上。1.2 一把合格的分布式锁要满足哪些条件这是面试官最爱问的后续问题也是选型时的核心标准。互斥性任意时刻只能有一个客户端持有锁这是最基本的底线。安全性加锁和解锁必须是同一个持有者不能因为超时、异常把别人持有的锁给释放了。可用性如果持有锁的节点宕机了锁要能自动释放不能死锁也不能让系统永久阻塞。高性能加锁解锁的开销要足够低不能因为引入一把锁把系统的吞吐量拖垮。可重入性同一个线程在持有锁的情况下能否再次获取同一把锁这在递归和嵌套调用场景里很重要。这五个条件几乎就是分布式锁的完整验收清单。后面聊到各个方案的时候我会逐一对照着讲你会发现在这几个条件下每种方案都有自己的短板。2. 基于数据库的实现方案2.1 基于唯一索引实现互斥数据库方案最常见的是利用主键或唯一索引的唯一性。思路很直接专门建一张锁表锁某个资源就插入一条记录插入成功说明拿到锁删除记录就是释放锁。这里用一个简化例子说明。锁表结构大致如下CREATE TABLE IF NOT EXISTS distributed_lock ( id INT NOT NULL AUTO_INCREMENT, lock_key VARCHAR(64) NOT NULL COMMENT 锁的资源标识, owner VARCHAR(64) NOT NULL COMMENT 持有者标识, expire_time DATETIME NOT NULL COMMENT 过期时间, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_lock_key (lock_key) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;加锁就是一条 INSERT 语句INSERT INTO distributed_lock (lock_key, owner, expire_time) VALUES (order:123, instance-001, DATE_ADD(NOW(), INTERVAL 30 SECOND));lock_key 上有唯一索引同一时刻只有一个事务能插入成功插入失败就说明锁被别人持有。释放锁更简单DELETE FROM distributed_lock WHERE lock_key order:123 AND owner instance-001;这里删除条件必锁带上 owner 字段否则可能出现 A 线程释放了 B 线程锁的问题。这个方案唯一的亮点就是简单不引入额外中间件业务团队如果连 Redis、ZooKeeper 都没有部署可以用它应急。但它的短板也很明显数据库是单点数据库挂了整个锁体系就挂了。锁没有自动过期机制只有插入时写入的 expire_time实际不会被数据库主动清理。如果持有者宕机这条记录就会一直存在形成死锁。每次加锁解锁都是一次数据库读写高并发下很容易把数据库打爆。2.2 用乐观锁优化并发控制另一种数据库方案是乐观锁比如在订单表加一个 version 字段更新时带上版本号条件UPDATE stock SET stock stock - 1, version version 1 WHERE id 123 AND version 5 AND stock 0;如果影响行数为 0说明版本号不匹配其他线程已经修改过数据需要重试。这本质上不是典型的分布式锁而是一种并发控制策略适合读多写少、冲突不激烈的场景。但我仍然建议面试时提一下因为它说明你对数据库并发机制有更全面的认知。2.3 什么情况下才建议用数据库锁我的观点很明确除非你的业务并发量极低、锁的粒度非常大或者项目里实在没有中间件资源否则不推荐数据库锁。它不是用来抗高并发的它只是“有锁能用”。面试时主动说出这些缺点反而比硬吹方案更好因为你展示了真实的生产判断力。3. 基于 Redis 的实现方案3.1 一条命令完成加锁Redis 方案是现在应用最广泛的分布式锁实现。核心在于 SET 命令的扩展参数SET lock_key unique_value NX PX 30000NX 表示只有当 lock_key 不存在时才设置成功这就是互斥性的来源。PX 30000 给锁设了 30 秒的自动过期时间避免了持有者宕机导致的死锁。unique_value 是客户端生成的随机值用来标识锁的持有者。注意这里我必须强调很多旧资料还在推荐 SETNX 加 EXPIRE 两条命令这在高并发下是有致命缺陷的。两条命令不是原子的万一 SETNX 刚执行完进程就崩溃了EXPIRE 没执行锁就永远不会过期。所以务必用一条 SET 命令完成加锁和设置过期时间这是原子性的基础。获取锁之后业务代码直接执行临界区逻辑就行。释放锁是另一门学问不能简单地 del 了事。3.2 释放锁必须写成 Lua 原子操作新手最容易写错的地方就在释放锁这一步。很多人图省事直接DEL lock_key但这里有个典型的误删场景线程 A 获得锁因为业务执行太久锁已经自动过期了然后线程 B 重新获得了锁。这时候线程 A 终于执行完了一个 DEL 把线程 B 的锁给释放了线程 C 又进来了互斥性彻底失效。正确做法是释放锁之前先判断锁的 value 是不是自己的 unique_value是才删除。而且判断和删除这两个动作必须放在 Lua 脚本里保证原子性-- unlock.lua if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end调用方式EVAL if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end 1 lock_key unique_value这个脚本是面试必问的考点建议直接背下来并且能解释清楚Lua 脚本在 Redis 中是原子执行的整个脚本执行期间不会被其他命令插入所以不存在“刚判断完就被别人抢锁”的竞态窗口。3.3 锁过期了但业务没执行完怎么办这是 Redis 分布式锁最让人头疼的问题。业务磨磨蹭蹭执行了 40 秒锁 30 秒就过期了锁被自动释放另一个线程抢到锁进来两个线程同时跑业务锁形同虚设。业界通用的解法是看门狗机制这也是 Redisson 内部的核心设计。加锁时Redisson 不直接给锁设置一个固定过期时间而是设置一个默认 30 秒的 leaseTime然后启动一个定时任务在锁还剩 1/3 时间也就是 10 秒左右时自动续期把锁的过期时间重新拉回 30 秒。只有业务正常执行完调用解锁方法这个续期后台任务才会被取消。这里要解释一个细节Redisson 的看门狗续期逻辑是“只要锁还存在就持续续期”所以哪怕业务真的跑了十分钟只要持有者进程还活着锁就不会断。但这也暴露了一个隐患——如果客户端进程发生长时间的 Full GC暂停了 30 秒以上看门狗线程没法执行锁还是会过期。这也引出了后面 RedLock 的讨论场景。另外如果你用的是 Redisson 的 lock 方法指定了 leaseTime也就是手动传入锁的持有时间看门狗机制就不会启动。这点要记清楚别在代码里混用。3.4 RedLock在主从故障下找回安全感Redis 的经典问题在于主从复制是异步的。客户端在主节点上成功加了锁此时主节点还没来得及把这条写命令同步给从节点就宕机了哨兵把从节点提升为新的主节点但新主节点上压根没有那把锁。客户端 B 再次加锁直接就成功了互斥性被彻底打破。这个场景 Redis 官方文档也无法完美解决于是提出了 RedLock 算法思路是不再只依赖单个 Redis 实例而是向多个独立的 Redis 节点比如 5 个同时发起加锁请求。RedLock 的具体步骤大致如下获取当前时间。按顺序向 N 个节点尝试加锁每个节点都使用相同的 lock_key 和 unique_value并设置一个比预期业务时间更短的过期时间。计算整个加锁过程的总耗时如果成功获取锁的节点数大于等于 N/2 1且总耗时小于锁的有效期则认为加锁成功。如果加锁失败或总耗时超时就向所有节点发起释放锁请求清除可能已获取的部分锁。RedLock 确实能解决单点故障下的锁丢失问题但它的争议也很大。整个算法假设各个节点互相独立、时钟同步实际上分布式系统中的时钟漂移、网络分区都很难保证。很多专家认为 RedLock 依然无法在极端情况下提供绝对的安全保证反而增加了复杂度和成本。面试时你可以这样回答RedLock 是 Redis 官方在 CAP 权衡下给出的一种折中方案它提升了可用性但不能做到绝对的线性一致性。大多数互联网业务场景单节点 Redis 配合主从加哨兵、加上看门狗续期已经能扛住绝大部分线上故障不一定非要上 RedLock。4. 基于 ZooKeeper 和 etcd 的实现方案4.1 ZooKeeper 的临时顺序节点设计ZooKeeper 实现分布式锁依赖两个核心特性临时节点和顺序节点。先说临时节点。客户端与 ZooKeeper 建立会话时创建的临时节点会在会话结束比如客户端宕机、网络断开时被 ZooKeeper 自动删除。这个特性天然解决了“持有者宕机导致死锁”的问题不需要设置超时时间也比 Redis 的过期机制更可靠。再说顺序节点。在多客户端同时竞争锁时没有哪个客户端能直接抢到锁而是各自在锁根节点下创建一个临时顺序节点然后比较自己节点的序号。序号最小的那个节点持有锁其他客户端只需要监听自己前一个节点的删除事件前一个节点消失了说明轮到自己拿锁了。整个流程整理一下客户端 A 在 /locks 节点下创建临时顺序节点 /locks/lock_0000000001。客户端 B 同时创建 /locks/lock_0000000002。客户端 A 查看 /locks 下的所有子节点发现自己的序号最小获取锁成功。客户端 B 同样查看子节点发现自己不是最小于是监听 /locks/lock_0000000001 的删除事件。A 执行完业务删除自己的节点B 收到通知后再次判断自己是否是最小节点然后获取锁。这个方案有一个很大的优点公平锁。所有客户端按照到达顺序排队获取锁不存在 Redis 那样的“先来者靠抢后来者反复自旋”的问题。而且客户端不需要持续轮询通过监听机制就能实现高效的等待。它的缺点也很直接ZooKeeper 的性能不如 Redis创建节点、监听事件都有额外开销不适合超高频的加锁解锁场景。另外ZooKeeper 集群的选举期间会短暂不可用这也需要考虑。4.2 etcd 的 Lease 实现思路etcd 的分布式锁实现思路和 ZooKeeper 类似但更现代化。它使用 Lease租约、Revision版本号和 Watch监听机制来实现。加锁时客户端创建一个 Lease 并设置过期时间然后在一个固定的锁前缀下写入一个 key并绑定这个 Lease。etcd 会为每个写操作生成一个全局单调递增的 RevisionRevision 最小的那个 key 就是锁的持有者。其他客户端监听这个前缀下的 key 变化或者监听自己的 Revision 排名等前面的 key 被删除后再尝试获取锁。Lease 还有一个自动续期的机制客户端需要周期性地向 etcd 发送 KeepAlive 请求来续租如果客户端宕机Lease 到期对应的 key 自动删除锁自然释放。从这里你能看出来ZooKeeper 和 etcd 的设计哲学是一致的依赖一个强一致性的协调组件通过“会话/租约 顺序节点/版本号”来公平地分配锁。这些方案的正确性和可靠性高于 Redis 的 SETNX 方案但代价是架构更重、吞吐量更低。4.3 用 ZooKeeper 时的一个隐蔽问题用 ZooKeeper 实现锁时有个容易被忽略的坑就是客户端在监听前一个节点删除事件之前那个节点可能已经完成了业务并释放了锁。这里有个时间窗口客户端 B 创建节点后、还没开始监听前一个节点的时候如果前一个节点恰好在这时被删除B 就会错过这个删除事件永远等不到通知导致锁等待超时。解决办法也不复杂不能只依赖监听每次节点删除事件触发后还要主动查询当前所有子节点重新确认自己的序号是不是最小。也就是说监听事件只是唤醒信号真正判断能否拿到锁要靠读取最新节点列表来判断。这个细节最能体现你是否真的写过 ZooKeeper 锁的客户端面试时提出来会非常加分。5. 三种主流方案怎么选5.1 关键维度对比对比维度数据库实现Redis 实现ZooKeeper / etcd 实现性能低每次加解锁读写数据库高单命令毫秒级中等节点创建与监听有额外开销可靠性差数据库单点且无自动释放中依赖过期时间主从切换可能丢锁高临时节点/租约自动清理公平性不支持默认不公平靠自旋争抢天然支持公平锁自动释放手动清理持有者宕机会死锁过期时间自动释放会话/租约到期自动释放实现复杂度简单需处理看门狗、Lua 脚本等细节中等适用场景低并发、无中间件依赖高并发、追求性能强一致、金融级可靠性要求这张表基本可以覆盖面试官的第 99% 的问题。实际上选型的逻辑也很清晰如果并发不高用数据库锁简单够用如果想用高性能换取可靠性的一定损失Redis 是最常见的选择如果业务对数据一致性要求极其严苛比如订单支付、账户余额变更这类场景ZooKeeper 或 etcd 更稳妥。5.2 有没有可能直接不用锁我从实际项目经验出发多说一句分布式锁不是银弹它只是保证互斥的手段之一。有些场景完全可以通过替代方案来规避锁例如数据库更新可以用乐观锁代替悲观锁。生成唯一订单号可以用数据库唯一索引兜底而不是把全部并发都压到一把锁上。秒杀场景的扣减库存可以改为 Redis 的原子自减命令避免锁竞争。对于某些离线任务用消息队列串行化处理也能天然避免并发问题。很多高级面试官最后一定会问一句“你有没有想过不用分布式锁怎么解决问题”能提出替代方案往往比执着于锁本身更让人眼前一亮。6. 高频追问与避坑指南6.1 面试官最容易追问的几个点追问一Redis 加锁命令为什么是原子的因为 SET 命令带 NX 和 PX 参数本身就是一个原子操作。而旧版的 SETNX 加 EXPIRE 是两条命令非原子中间崩溃会导致死锁。追问二为什么释放锁要用 Lua 脚本因为“判断 value 是否一致”和“删除 key”是两个动作分开执行存在竞态窗口。Lua 脚本能保证这两个动作的原子性。追问三看门狗机制在 Redisson 里是怎么实现的加锁时创建一个定时任务每 10 秒过期时间的三分之一执行一次续期把锁的过期时间重置。业务结束或锁被删除时取消定时任务。追问四ZooKeeper 方案和 Redis 方案你更推荐哪个我的回答是看场景。并发量高且能接受小概率主从故障的场景用 Redis一致性要求极高且并发量可控的场景用 ZooKeeper。不要只背结论要能给出理由。6.2 生产环境真实踩过的坑我结合自己线上出过的问题把最值得注意的几点列出来分布式锁的 key 一定要设计得足够细。锁 range 太粗比如把整个用户表一个锁覆盖高并发下所有请求都阻塞在一个锁上吞吐量直接崩掉。建议把 key 拆细到业务对象级别比如 order:创建订单:用户ID。Redisson 的 WatchDog 续期是把双刃剑。如果业务线程被阻塞太久比如调用外部接口超时看门狗会让锁一直续期后面所有请求都等着容易拖垮整个系统。需要给业务操作设置合理的超时时间同时给锁加上最大持有时间兜底。Redis 主从切换导致锁丢失的问题除了 RedLock还有一个易实施的兜底手段对共享资源的最终一致性做校验。比如库存扣减用 Redis 锁保护但数据库更新结果再用版本号做二次校验锁即使短暂失效也不会引发超卖。这些坑如果只靠背八股是踩不出来的真正干活的人看到这些细节大概率能会心一笑。6.3 一段可以直接用的 Redisson 加锁示例很多同学面试时喜欢描述 API但一写代码就露馅。这里给出一段 Redisson 的典型用法RLock lock redissonClient.getLock(order:create: orderId); try { // 第一个参数是获取锁的等待时间第二个参数是锁的自动释放时间 // 如果指定了释放时间则不会启用看门狗推荐用下面的方式 boolean isLocked lock.tryLock(3, TimeUnit.SECONDS); if (isLocked) { // 业务逻辑 } else { throw new RuntimeException(系统繁忙请稍后重试); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(获取锁被中断); } finally { // 只有在持有锁时才释放 if (lock.isHeldByCurrentThread()) { lock.unlock(); } }这里有个小经验tryLock 的等待时间不要设置太长否则大量线程会在等待锁时堆积请求前端体验会很差。一般 3 秒到 5 秒比较合适。我个人在实际项目里的倾向是互联网业务默认优先上 Redisson 的分布式锁因为它把加锁、续期、原子释放这些细节都封装好了踩坑成本最低。只有在明确需要强一致、公平锁的场景才会考虑 ZooKeeper 或 etcd。而在回答这道面试题的时候我的建议也很直接不要只背结论把三种方案的原理、优缺点和适用场景串起来讲再配合一两个你真实遇到过的案例这就是最好的答案。最后再分享一个小技巧面试官让你写 Redis 分布式锁伪代码时建议把 SET、Lua 释放脚本、看门狗提一下然后主动画一条时间线解释“锁持有者执行时间大于锁超时时间”会怎样。能讲清楚这个时间线这道题基本就拿下了。