分布式锁实战:Redis、ZooKeeper与数据库方案的对比与选型
发布时间:2026/10/8 15:37:42 锦皓数字建站

我们先从一个非常典型的场景说起。你在做库存扣减微服务拆了三四个实例synchronized已经锁不住了于是你上网搜索分布式锁看到了 Redis、ZooKeeper、数据库三种方案。看起来都不难照着帖子一写就能跑。但线上真出了事故你才会发现同样的锁换一个业务场景正确性完全不是一个量级。分布式锁本质上是一门如何在大规模节点之间建立互斥共识的学问Redis、ZooKeeper、数据库只是三种不同的达成路径它们的差异不在于是不是能锁住而在于失败时你损失什么。这篇文章我会把三种方案从原理到工程实践拆开讲重点放在它们各自最容易被忽视的代价上最后给出一套我实际用在生产环境的选型路径。不管你是在做微服务改造、写分布式任务调度还是在准备分布式锁的面试题这篇文章都值得你花十分钟看完。1. 先弄清楚分布式锁到底在防什么1.1 一个典型的丢了锁事故我早年间维护过一套秒杀系统当时用的是最简单的 RedisSETNX。上线后第一周很稳定直到某天大促主库 CPU 飙到 90%Redis 主节点因为内存碎片触发了一次快速重启Sentinel 把请求切到了从节点。结果几十个线程同时发现锁可以重新获取瞬间涌入扣减逻辑库存变成了负数。事后复盘问题不是出在锁的代码而是出在我们默认了主从切换期间锁不会丢。这就是分布式锁最难的地方它不是能不能生成唯一凭证而是当基础设施出现故障时锁是否还能保持互斥。大多数方案的差异几乎全部集中在这个故障窗口里。1.2 分布式锁的三个最基本的语义在对比方案之前我先定义清楚我们要讨论的边界。一把合格的分布式锁至少要满足三个条件互斥性任意时刻只能有一个客户端持有锁。可重入性工程上通常需要同一个客户端可以重复进入同一把锁。防死锁持有锁的客户端崩溃或网络中断后锁能够在有限时间内被自动释放。前两条对应锁的语义第三条对应锁的生命周期自治。不同方案在第三条上的处理方式直接决定了可靠性上限。1.3 记住一个结论没有银弹刚接触分布式锁时我也以为能锁住就行。后来我的判断标准变成了如果我要锁的那段逻辑执行到一半持有者真的挂了我能不能接受另一个节点在同一时刻也在执行这段逻辑如果你的答案是绝不能那么你就不能选一个可能失效的方案如果你的答案是偶尔重入一次也能接受只要最终一致那你完全可以选性能最好、成本最低的那个。带着这个判断标准我们再来看三种方案各自的底牌。2. Redis 锁为什么它是默认选项却藏着性能陷阱2.1 从 SETNX 到 SET key value NX EX 的演进很多人第一次写 Redis 锁用的是SETNX key value然后单独再调一次EXPIRE key seconds。这个写法有个致命问题SETNX和EXPIRE不是原子的。如果SETNX成功之后进程突然崩溃锁永远不释放系统直接死锁。正确姿势是 Redis 2.6.12 之后引入的原子命令SET lock:order:1001 unique_token NX EX 30NX表示只有 key 不存在时才写入EX 30表示 30 秒后自动过期。这两件事在服务端是原子完成的。在 Java 里用 Spring Data Redis写法大概是Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, uniqueToken, Duration.ofSeconds(30));注意这里我把 value 设置成了uniqueToken不是固定字符串——这一点接下来详细讲。2.2 唯一身份标志与原子解锁很多人会忽略解锁时必须校验 value 是不是自己写入的。如果不校验会出现一个非常经典的错乱A 线程加锁后处理超时锁自动过期B 线程加锁成功此时 A 线程处理完直接DEL key把 B 的锁删掉了。B 的临界区瞬间失去保护其他线程涌入。解法是解锁时比对 value并且比对、删除两步必须原子。用 Lua 脚本是标准做法if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这个脚本执行前把KEYS[1]传成锁 key把ARGV[1]传成当前线程的uniqueToken。只有 token 匹配才删除token 不匹配说明锁早就不是你的了绝不能动。这个细节也是分布式锁面试题里最高频的考点——面试官真正想确认的是你踩没踩过这个坑。2.3 主从复制丢锁与 Redlock 争议Redis 方案最大的隐患就是我在开篇提到的场景锁写入主节点主节点还未把数据复制到从节点就宕机了锁直接消失。从节点被提升为新主节点后其他线程就能再次加锁成功。互斥性在故障窗口内被打破。为此 Redis 作者 antirez 提出了 Redlock 算法在 N 个完全独立的 Redis 节点上通常 5 个依次加锁只要超过半数节点成功就认为加锁成功。这能显著提高锁的存活概率。但分布式领域对 Redlock 一直存在争议最有名的是 Martin Kleppmann 的观点即使有 Redlock网络停顿GC pause依然会造成两个节点在同一时刻都认为自己持锁。我在生产环境里的态度比较实际如果你的系统已经在使用 Redis Cluster并且你对互斥性的要求是99.9% 时间内严格互斥那么 Redlock 并不能给你 100% 的承诺它只是把故障概率降低了一个数量级。很多架构师看到这里会直接放弃 Redis转投 ZooKeeper这个决策对不对看完下一节再判断。2.4 看门狗续期机制的使用边界Redis 锁普遍存在锁过期时间怎么定的纠结设短了业务没跑完锁就没了设长了万一持有者真挂了其他线程要干等那么久才能拿到锁。Redisson 的看门狗Watch Dog机制解决了这个问题默认 30 秒锁持有者每 10 秒自动续期到 30 秒客户端一旦崩溃锁会在最多 30 秒内自动释放。听起来很美但续期本身也参与分布式系统的拍脑袋假设。如果你的业务逻辑是不可中断的强一致性操作比如跨行转账扣款Redis 任何形式的锁释放机制都无法保证绝对不释放该锁。这里必须做一个抉择要么把锁内逻辑拆短到可在租约期内完成要么接受超时后可能出现的重复执行再用幂等消费兜底。我见过太多团队把规避重复执行的希望全压在 Redis 锁上结果最后靠数据库唯一索引救场。3. ZooKeeper 锁公平、可靠但别当成万能协调器3.1 临时顺序节点的工作原理ZooKeeper 实现分布式锁的核心是临时顺序节点EPHEMERAL_SEQUENTIAL。每个客户端在锁目录下创建一个临时节点ZooKeeper 会自动为它追加一个全局递增的序号。客户端只需要判断自己是不是当前最小的序号是则持有锁否则监听前一个节点的删除事件。当前一个节点没了再次判断自己是不是最小。这套机制有两个关键特性临时节点与会话绑定客户端崩溃、会话超时节点自动消失锁自动释放不会像 Redis 那样永远锁死。FIFO 排队所有等待者按序号排列先到先得天然公平不像 Redis 那样有抢锁风暴。用 Apache Curator 的InterProcessMutex代码复杂度几乎为零InterProcessMutex lock new InterProcessMutex(client, /locks/order_1001); if (lock.acquire(5, TimeUnit.SECONDS)) { try { // 临界区 } finally { lock.release(); } }3.2 会话超时与假死问题ZooKeeper 也不是没坑最典型的坑叫会话超时客户端和 ZooKeeper 之间心跳断了ZooKeeper 判定会话失效并删除临时节点释放锁但其实客户端只是网络抖动进程还活着此时它回到临界区继续执行而另一个客户端已经拿到了锁——两个进程同时执行临界区。这和 Redis 的锁过期本质上是同一个问题分布式系统无法区分网络分区和进程崩溃。ZooKeeper 的优势在于锁的释放完全由服务端会话状态驱动不需要业务代码显式续期但缺点是这个超时窗口依然存在。处理办法是设置合理的会话超时时间同时让客户端在锁内逻辑里尽量少做长耗时操作缩短持有锁但可能不再拥有锁的时间。3.3 ZooKeeper 锁的代价ZooKeeper 方案的成本主要在运维和性能性能下限加一次锁至少要一两次网络 RTT高并发下还要承担 Watcher 通知风暴。虽然优化成了只监听前一个节点但极端场景下锁释放后所有节点几乎同时收到通知并竞争吞吐量容易出现毛刺。运维复杂度ZooKeeper 集群本身的部署、监控、扩缩容比 Redis 重不少。与 Hadoop 生态的重合很多团队本来就用 ZooKeeper 做 Hadoop、Kafka 的协调顺手把锁也放上去这没问题但如果你的系统本来没有任何 ZooKeeper 依赖只为加锁而引入一套集群成本就偏高了。我的经验是ZooKeeper 锁适合强互斥 中等吞吐 已有 ZooKeeper 基础设施的场景比如分布式任务调度、配置中心关联的 leader 选举。它不是一个性能选手而是一个正确性选手。4. 数据库锁被低估的正确性被高估的可用性4.1 悲观锁SELECT FOR UPDATE 的实现与死锁数据库实现分布式锁最直接的方式就是悲观锁。在事务里执行SELECT stock FROM inventory WHERE sku_id 1001 FOR UPDATE; -- 业务计算、扣减 UPDATE inventory SET stock stock - 1 WHERE sku_id 1001; COMMIT;FOR UPDATE会对命中的行加排他锁事务提交或回滚时释放。这个方案的优点非常突出它依赖的是数据库本身的 ACID 和事务隔离不需要额外组件也不用处理锁续期只要事务没提交行锁就一直有效。而且如果业务本身就在一个事务里读数据、算数据、写数据这把锁几乎是顺路获取的代码侵入很小。但问题也很明显。第一个是性能每条加锁操作都占用一个数据库连接事务期间连接不能释放高并发下连接池很快就耗尽。第二个是死锁两个事务以不同顺序锁同一批资源时数据库死锁检测会让其中一个事务回滚。比如订单服务和库存服务同时操作 sku1001 和 sku1002 的行锁顺序不一致就会触发死锁。这个在数据库锁场景里特别常见。我有一个排障经验死锁后的第一反应不是加锁超时时间而是看 SQL 执行计划和事务长度。绝大多数数据库锁死锁根源都是事务里混入了远程调用、批量查询这类长操作导致持锁时间太长。4.2 乐观锁版本号的适用边界如果悲观锁太重可以用乐观锁。乐观锁的假设是并发冲突很少只在提交时校验版本UPDATE stock SET stock stock - 1, version version 1 WHERE sku_id 1001 AND version 5;影响行数为 1说明更新成功影响行数为 0说明 version 已经变了需要重试或者放弃。它在概念上也是一种锁——但严格说它不提供阻塞等待的能力更适合短事务、低冲突、可重试的场景。我一般建议乐观锁不要用在写概率高的场景否则重试风暴会很难看它最适合的是读多写少、写冲突极少、单次写操作极快的内部管理系统场景比如后台修改配置、用户资料。4.3 数据库锁表方案什么时候还能用第三种数据库方案是显式维护一张锁表利用唯一索引保证互斥CREATE TABLE distributed_lock ( lock_key VARCHAR(128) PRIMARY KEY, owner VARCHAR(64), expire_at DATETIME );获取锁时INSERT IGNORE唯一键冲突说明锁已被占释放时DELETE。配合expire_at可以做一个简陋的租约机制。这套方案在真实生产环境里存活率不高因为一旦某个持有者崩溃锁只能等过期时间到了才能被重新获取而这个过期时间检查 删除旧锁的清理逻辑又容易写错比如误删别人的锁。我的看法是它只适合低频、内部、允许锁等待几秒钟的故障场景比如定时任务集群里防止同一任务被多个节点执行。如果你已经引入了 Redis 或 ZooKeeper没有必要为了少装一个组件而强行选它。5. 核心对比可靠性、性能、成本三个维度的量化参考5.1 一张表看清差异为了方便直观选型我整理了一张实际工作中最常用到的对比表维度Redis 锁ZooKeeper 锁数据库锁互斥性保证主从切换或锁过期时可能失效会话失效前保持强互斥事务未提交前强互斥自动释放机制过期时间 看门狗续期临时节点 会话超时事务回滚/提交锁公平性不公平抢锁随机公平FIFO 顺序取决于 SQL 排队机制加锁性能高单次 RTT中多次 RTT Watcher低事务 行锁依赖组件RedisZooKeeper已有数据库运维成本低中高无额外成本典型失败模式主从切换丢锁、锁过期会话超时导致锁提前释放死锁、连接池耗尽5.2 参数量化与容量参考在实际压测里我见过的一组典型数据单机房、千兆网络、普通配置可以参考Redis 单节点加锁大约 0.1ms ~ 0.5ms吞吐可以轻松到每秒数万次。ZooKeeper 加锁大约 2ms ~ 10ms吞吐一般在每秒数千次级别节点多了会明显下滑。数据库FOR UPDATE加锁大约 1ms ~ 5ms但扛不住高并发连接池配置稍不对就阻塞在获取连接上。这里有个很容易被忽略的结论性能和可靠性在同一个方案内部往往是冲突的。比如 Redis 锁要更可靠就得引入 Redlock——但 Redlock 需要和多个节点通信性能立刻从 0.1ms 涨到接近 ZooKeeper 的水平复杂度还更高而 ZooKeeper 锁为了提升可用性加大会话超时时间互斥窗口就会变长。没有哪个方案可以同时做到又绝对可靠、又绝对快你必须先确定自己更吃哪一边。6. 选型路径别根据热门选根据风险选6.1 场景分级强互斥、可容忍短暂不互斥、低并发低频根据业务对重复执行的容忍度我会把使用场景分成三个等级第一级强互斥场景不可容忍重复执行典型例子是金融转账、资金冻结、订单状态流转。这类场景我优先选 ZooKeeper 锁或数据库行锁并配合唯一索引兜底。如果团队实在不想引入 ZooKeeper那至少要接受 Redlock 的复杂度同时把主从切换丢锁当作已知风险写进设计文档。第二级可容忍短暂重复的场景典型例子是秒杀扣库存、发优惠券、排行榜计算。高并发下允许极端情况出现一两次超卖可以用 Redis 锁配合消息队列或者定时补偿。这个等级是 Redis 方案最舒服的位置性能高、成本低偶尔丢一次锁的损失完全在可控范围。第三级低并发、低频、内部系统典型例子是后台定时任务执行、配置刷新、数据归档。这类系统一天只有几次加锁竞争用数据库乐观锁或锁表就够不需要为了一把低频锁引入中间件集群。6.2 我常用的决策路径与取舍逻辑下面是我在团队里反复使用的决策流程基本不靠感觉先问有没有数据库唯一索引或其他幂等机制兜底如果有那锁的正确性压力会小很多随便选一个性能好的方案。再问系统已经有哪些基础组件已有 Redis 优先考虑 Redis 锁已有 ZooKeeper 优先考虑 ZK 锁都有的情况下按互斥要求选。然后问锁内逻辑平均执行时间是多少如果普遍超过 1 秒Redis 锁要重点设计续期ZooKeeper 锁要注意会话超时和节点堆积问题。最后评估团队运维能力ZooKeeper 集群挂了一半怎么办Redis 的持久化策略和哨兵切换有没有人管运维复杂度才是分布式锁选型的隐性天花板。我也见过很多人因为Redis 锁面试题答得最流利就优先推荐 Redis这个逻辑是有问题的。技术选型应该从你业务的失败代价出发而不是从你熟悉的技术栈出发。7. 三个隐蔽的实战坑先记下来7.1 锁内做了耗时操作导致锁自动过期这个问题出现的频率极高。很多人把锁设成 10 秒过期但临界区里居然调了外部 HTTP 接口或者查了一个慢 SQL结果锁早就过期了另一个线程已经进入临界区。就算有看门狗续期你也要在代码里显式控制临界区只放必要操作外部调用一律挪到锁外或者在锁内做好超时熔断。否则你追求的不是分布式锁而是心理安慰。7.2 可重入问题有些锁方案默认不支持可重入。比如同一个线程在已经持锁的情况下再次加锁Redis 原生命令直接会失败ZooKeeper 则可能把自己的节点排在后面造成自己等自己的死锁。我的经验是如果业务里无法避免嵌套加锁尽量别用原生库而是选自带可重入语义的封装比如 Redisson 的RLock、Curator 的InterProcessMutex。自己写可重入逻辑很容易在 ThreadLocal 清理和异常恢复上翻车。7.3 锁的清理与恢复机制不管选哪种方案锁都要有主动释放和被动兜底双保险。主动释放是finally里的unlock/release/commit被动兜底是过期时间、临时节点或事务回滚。我踩过最深的坑是在一个try-with-resources结构里锁在 try 块开头获取在 close 时释放结果中间抛了Error级别异常没有走 finally导致锁残留指向了关闭的资源池。后来我严格遵循锁获取与释放包裹最小临界区且在 finally 中释放的规范再没出过类似问题。7.4 锁与本地事务的边界最后提醒一个很容易被忽略的点锁的作用域和本地事务的作用域如果重叠要非常小心提交顺序。比较稳妥的路径是先提交事务再释放锁反过来先释放锁再提交事务会让锁失去意义因为在事务提交前的间隙其他线程读到的还是旧数据。我自己处理的分布式锁故障里有相当一部分根本不是锁的问题而是锁和事务顺序搭配错了。这个细节面试题一般不会直接问但线上排查比啥都实用。结语写了这么多其实就想表达一件事分布式锁没有最好的实现只有对当前系统最合适的实现。Redis 快但有丢锁风险ZooKeeper 稳但有运维成本数据库朴素但靠得住。结合你自己的业务失败代价、团队运维能力、已有技术栈去选比盲目追求某个热门方案靠谱多了。如果这篇文章能让你在设计评审时少走一次弯路那我就没白写。最后再分享一个小习惯每次引入新的分布式锁方案前先在测试环境强行注入一次故障比如杀掉持有锁的节点、模拟主从切换、把会话超时调到极小看系统会不会出现两个节点同时进入临界区。能接受再上线不能接受换个方案继续测。分布式锁这种东西纸上跑得通不算本事故障下跑得稳才算。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。