1. 项目概述为什么所有分布式系统迟早会撞上抢锁这个坎先聊个实际场景。某电商平台做秒杀同一件商品库存只有50件并发请求来了十万次结果库存扣成负数超卖事故直接把运营主管送进会议室。你第一反应是给库存操作加锁但项目早拆成几十个微服务了每个服务部署了多个实例传统的synchronized或者 JVM 内部的ReentrantLock只能锁住当前进程——A 实例锁住了B 实例照样往里冲跟没锁一样。这就是分布式锁存在的根本原因在多个进程、多台机器之间协调对共享资源的互斥访问。点评项目---分布式锁这个题目看起来简单真做起来水很深。我在实际项目里见过不少团队把分布式锁当成Redis 里 set 一个 key就完事了结果锁失效、锁误删、死锁、性能退化各种事故轮着来。所以这篇博文不打算只讲理论我会把分布式锁的核心原理、三种主流实现方案、以及我在生产环境踩过的坑和排查套路全部梳理一遍按项目点评的视角告诉你这套方案到底解决什么问题、代价是什么、什么时候该选哪个。适合谁看正在做微服务拆分却不知道怎么保证并发安全的后端开发被主从切换丢锁折磨过的架构师以及面试前想把这套经典技术讲清楚的同学——这篇文章的目标是让你看完之后既能动手实现也能跟别人把为什么讲明白。2. 主流方案拆解Redis、ZooKeeper、数据库锁各自赢在哪、输在哪2.1 Redis 分布式锁性能之王也是事故高发区业界提到分布式锁第一个想到的几乎都是 Redis。核心就一句话利用 Redis 单线程模型和SET key value NX EX seconds这条原子命令多个客户端同时执行只有一个能成功谁成功谁就持有锁。这个方案的优势极其明显——快。Redis 是纯内存操作加锁解锁的耗时通常在毫秒级加上EX设置过期时间天然规避了客户端崩溃后锁永不释放的问题。对于秒杀、优惠券领取这种高并发短临界区的场景Redis 锁是唯一能扛住压力的方案。但问题也恰恰出在这。Redis 的锁强依赖过期时间如果业务执行时间超过了预设的过期时间锁就被 Redis 自动释放了另一个客户端趁虚而入拿到锁——两个客户端同时在临界区里干活这还叫互斥吗另外Redis 主从架构下存在一个经典的漏洞客户端 A 在主节点上加了锁主节点还没来得及把这条写入同步到从节点就宕机了从节点被提升为新的主节点但锁数据丢了客户端 B 在新主节点上成功加锁A 和 B 同时持有锁。Redis 官方为此搞出了 Redlock 算法但业内对这个算法的争议从来没停过后面我会专门讲。2.2 ZooKeeper 分布式锁强一致性的老实人ZooKeeper 实现分布式锁的逻辑跟 Redis 完全不同。它利用的是 ZooKeeper 的顺序临时节点特性客户端在锁节点下创建一个临时顺序节点然后获取锁节点下的所有子节点如果自己创建的节点序号最小就获得锁否则监听序号比自己小的前一个节点等它释放后唤醒自己。这个方案最核心的价值是强一致性。ZooKeeper 的 Zab 协议保证了多节点之间的数据一致性不会出现 Redis 主从切换丢锁那种问题。而且临时节点自带会话心跳机制——客户端崩溃后会话断开临时节点自动被删除锁随之释放不需要像 Redis 那样拍脑袋设置过期时间。加上 watch 监听机制客户端之间是推模式通知避免了 Redis 锁释放后其他客户端疯狂轮询的尴尬。缺点也很实在——慢。ZooKeeper 是磁盘持久化加 Zab 协议同步一次锁操作几十毫秒是常态高并发场景下吞吐量完全没法跟 Redis 比。还有个容易踩的坑ZooKeeper 的会话超时机制在某些极端情况下会出现客户端自己也不知道自己锁已经过期的问题如果业务执行时间超过会话超时时间锁也会被 ZooKeeper 自动释放跟 Redis 的过期时间如出一辙不过临门一脚的区别是 ZooKeeper 的会话超时通常可以设得比较长只要你不乱调参数。2.3 数据库分布式锁最简单也最容易翻车的方案用数据库实现分布式锁是最朴素的做法核心思路是利用数据库的唯一约束。比如建一张锁表里面有一个唯一索引的字段谁想加锁就往表里插入一条记录插入成功就是拿到锁释放锁就是删除这条记录。我见过不少中小团队在项目初期就这么干因为零额外组件、零运维成本只要复用业务数据库就行。这个方案应付低并发场景绰绰有余比如后台管理系统的定时任务一天跑一两次的那种。但数据库锁的局限性在稍微上点规模之后就暴露无遗。第一性能瓶颈每次加锁解锁都是一次数据库写操作高并发下连接的争抢会让你先死在数据库连接池上。第二锁释放的可靠性很差如果持有锁的进程崩溃插入的记录永远不会被删除锁就永久锁死了——需要额外建一个定时任务去打扫这些僵尸记录。第三数据库主从切换同样存在同步延迟导致锁丢失的问题跟 Redis 主从如出一辙。这些还都是能用层面的问题更不用说数据库锁压根没有续期机制业务跑太久锁根本撑不住。2.4 三大方案横评不该用谁更好来提问用谁更适合先把三种方案的核心指标拉个表这样选型的时候一目了然对比维度Redis 分布式锁ZooKeeper 分布式锁数据库分布式锁性能极高毫秒级中等几十毫秒级低受限于磁盘IO和连接池一致性弱主从切换可能丢锁强Zab协议保证弱主从同步延迟丢锁锁释放依赖过期时间可能提前释放临时节点会话超时相对可靠无自动释放崩溃即死锁实现复杂度低一条命令搞定中需要理解临时节点和watch机制低一张表唯一索引维护成本低高ZooKeeper集群本身需要运维零复用现有数据库应用场景高并发短临界区如秒杀、幂等控制对一致性要求极高的场景如分布式协调低并发低频操作如定时任务防重选型的核心逻辑其实就一句话你想要极致的速度就得接受 Redis 的一致性瑕疵你想要强一致性就得接受 ZooKeeper 的性能妥协你什么额外组件都不想引入就得接受数据库锁的脆弱性。没有什么方案是完美的关键是搞清楚你的业务最不能接受的是什么。3. 核心细节解析一个分布式锁从加锁到释放的完整生命周期3.1 加锁为什么必须是一条原子命令很多人写分布式锁习惯先SETNX再单独发一条EXPIRE命令这样做的隐患你一眼就能看出来——如果SETNX成功之后客户端在发EXPIRE之前崩溃了这条锁就永远没有过期时间变成一个死锁其他所有客户端永远拿不到锁。解决思路是让设置值和设置过期时间这两步操作变成一个原子操作。Redis 从 2.6.12 版本开始支持SET key value NX EX seconds这一个命令完成所有操作这是目前最标准的加锁写法。类似的如果你用 ZooKeeper创建临时节点这个动作本身就自带生命周期不存在创建了但没设过期时间的中间状态。再往深挖一步value这个字段也不是随便填的。我见过有人用set lock 1这种写法解锁的时候直接DEL这个 key——问题是如果 A 客户端持有的锁已经过期被 B 客户端拿走了A 客户端此时执行DEL删掉的是 B 客户端的锁B 的临界区就裸奔了。这就是经典的锁误删问题。正确做法是在 value 里存放一个客户端独有的标识比如 UUID 或者业务 ID解锁前先比对 value 是不是自己的是才删。3.2 解锁Lua 脚本保证的检查再删除有了上面提到的锁误删问题解锁操作就不能是简简单单的DEL而是要先检查 value 是否匹配再删除这两个操作也需要原子性。Redis 官方推荐用一段 Lua 脚本来实现if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段脚本的意思是取锁 key 的值如果跟当前客户端的标识相等说明锁确实是自己的删除否则说明锁已经被别人拿走了不删。整个判断和删除在 Redis 端完成天然原子不会出现判断完了但还没删的时候锁被抢走的时间窗口。用 ZooKeeper 的时候解锁就是删除自己创建的那个临时节点因为节点是唯一的不存在误删问题——你只能删自己的节点。这算是 ZooKeeper 方案的一个隐性优势省掉了很多防御性代码。3.3 过期时间你要续期而不是赌业务一定跑完Redis 锁最让人头疼的问题就是过期时间怎么设。设短了大促期间业务链路一慢锁提前释放别的请求冲进来同时处理同一笔订单设长了客户端崩溃后锁要很久才能被其它客户端拿到系统整体可用性下降。这里最靠谱的答案是动态续期。Redisson 等客户端框架实现了看门狗机制给锁设置了默认 30 秒的过期时间但后台有个定时任务每 10 秒检查一次——如果锁仍然被持有就把过期时间重置回 30 秒。这样就做到了业务没执行完锁就一直活着。我在实际项目中见过一个更轻量但实用的做法起一个守护线程定期去执行 Redis 的EXPIRE命令续期业务代码结束后先中断守护线程再释放锁。这里有个隐藏的坑——续期线程和业务线程要处理好顺序不然会出现业务还没结束续期线程已经被中断锁在最后一段业务代码上失效的情况。我自己的习惯是续期逻辑做成装饰器或者 AOP 切面强制跟业务生命周期绑定而不是让开发者手动控制。3.4 可重入性同一个线程重复加锁会不会死锁还有一个经常被忽略的细节——可重入。比如一个方法加了锁方法内部又调用了另一个也加了同一个锁的方法如果分布式锁不支持重入第二次加锁就会失败甚至自己把自己卡死。Redis 锁实现可重入的方式是用 value 存储线程标识 重入次数这样的复合信息。加锁时如果发现 key 存在且 value 里的线程标识跟当前线程一致就把重入次数加一不做真正的新增解锁时每解一次重入次数减一减到零才真正删除这个 key。ZooKeeper 的可重入处理大同小异核心都是持有者判断。这个细节如果没处理在实际业务里会以莫名其妙死锁的形式暴露出来而且特别难排查——你加锁的代码看起来是对的但不知道为什么下面的代码没执行。所以做分布式锁设计时要不要支持可重入必须提前定清楚别等出了问题再补救。4. 实操过程与核心环节实现模拟项目 X 的支付防重逻辑4.1 业务背景与锁选型思路我拿先前指导过的一个模拟项目来拆解项目代号就叫模拟项目 X 好了。业务场景是用户提交订单后调用支付接口支付回调会触发状态更新、库存扣减、积分发放三条链路但支付平台的回调机制是不保证只通知一次的同一个订单可能同时收到两三个回调。如果这三条链路并发执行积分会被重复发放库存会被重复扣减。需求拆解一下其实就是同一订单的支付回调处理必须串行化这是典型的分布式锁应用场景。回调并发量不算特别大每秒顶多几百笔但对一致性要求很高——钱的事容不得重复。锁的选型我最终选了 Redis。原因很简单并发量虽然不是极端高但回调处理链路里有多处 Redis 缓存读取引入 ZooKeeper 意味着要额外维护一套高可用集群对于这类中等规模系统来说运维成本没必要。4.2 项目落地的锁核心实现第一版的时候我用的是 Spring Boot 集成 RedisTemplate自己写了一个分布式锁工具类。核心加锁逻辑长这样public boolean tryLock(String lockKey, String requestId, long expireTime) { // requestId 用 UUID确保锁持有者有唯一标识 String script if redis.call(exists, KEYS[1]) 0 then redis.call(set, KEYS[1], ARGV[1]); redis.call(expire, KEYS[1], ARGV[2]); return 1; else return 0; end; // 这里用 eval 执行脚本保证判断、加锁、设置过期时间三者原子 }这里必须注意一点我第一版犯过一个低级错误用了SETNX加锁然后又单独调用expire设置过期时间后来在压测的时候发现锁会随机性死锁——因为SETNX成功之后、expire还没执行的时候线程被运维的发布系统强制杀掉了。改成上面这段 Lua 脚本之后这个窗口就消失了。解锁逻辑则是前面提过的检查再删除脚本public boolean unlock(String lockKey, String requestId) { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]); else return 0; end; }这里有个关键的手工验证步骤加锁和解锁的脚本要自己在 Redis 命令行里先跑一遍。我遇到过同事把参数顺序传反了导致线上解锁永远失败的情况——Lua 脚本在 Redis 里执行时KEYS数组和ARGV数组是分开的跟普通 Java 方法的参数顺序完全是两回事不实跑一遍很容易犯错。4.3 项目 X 实战中的关键指标与压测结果锁做完之后我对整个回调链路压了一轮。业务上我给锁设置的默认过期时间是 30 秒看门狗续期频率是每 10 秒一次。这样设计基于一个简单的计算逻辑支付回调处理链路最长链路是验签 查单 扣库存 更新订单 发积分耗时大概 2 到 8 秒30 秒的锁过期时间理论上覆盖了最慢情况再叠加续期兜底几乎不会出现锁提前释放。压测结果在 200 并发下QPS 达到 800 左右单次加锁平均耗时 1.2msp99 是 3.8ms整条链路加上锁的耗时比不加锁只增加了约 5%。这个开销对支付场景完全可接受。但压测也暴露了一个真实存在的问题——当并发量到 500 的时候Redis 的 CPU 使用率飙升到 70% 以上。排查下来发现是锁竞争失败的那部分请求没有做退避重试而是直接死循环重试加锁瞬间把 Redis 打爆了。后来加了一个带随机退避的加锁重试策略失败后先随机 sleep 10 到 50 毫秒再重试压力立刻降下来了。这个优化虽然很简单但真实线上环境里能等你随机 sleep 再重试的请求其实很少所以后来我对重试次数也做了限制——最多重试三次三次失败直接返回处理中稍后查询把压力挡在业务入口而不是全部怼到 Redis 上。4.4 锁续期的落地细节与经验续期逻辑的落地我建议直接集成成熟的客户端比如 Redisson 的tryLock配合leaseTime参数它内部的看门狗自动续期逻辑是经过大量生产环境验证的。但如果你像我遇到的一个项目一样因为技术栈限制不得不自己实现续期我给你一套可复用的思路public void renewLockPeriodically(String lockKey, String requestId, long lockExpireTime, long renewInterval) { ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() - { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(expire, KEYS[1], ARGV[2]); else return 0; end; // 续期脚本执行的返回值如果是 0说明锁已经被别人拿走了 // 此时业务线程应该得到通知立即中止业务逻辑而不是继续跑 }, renewInterval, renewInterval, TimeUnit.SECONDS); }这里有个非常容易被忽略的细节续期线程发现锁丢了之后不能让业务线程蒙在鼓里继续执行。我的做法是设置一个volatile boolean lockAlive标志位续期线程每次成功续期就把它置为 true发现锁丢了就置为 false业务代码在几个关键步骤之间检查这个标志位为 false 就立刻抛出异常回滚当前事务。这个机制挽救过我们一次线上事故——那次是因为一个外部接口超时导致业务线程卡了超过锁过期时间如果没有这个检查后续的扣库存和积分发放就在无锁状态下执行了。4.5 锁释放顺序为什么先删锁再提交事务是个坑这是我在模拟项目 X 里踩过最深的一个坑值得单独拿出来说。我做支付回调时有这样一段伪代码try { // 加锁 updateOrderStatus(); // 更新订单状态 deductStock(); // 扣库存 addPoints(); // 发积分 // 解锁 } finally { unlock(); }看起来逻辑没问题但实际线上偶发出现订单状态已更新、积分双倍发放的问题。排查了很久最后发现原因在事务边界和锁释放顺序上updateOrderStatus、deductStock、addPoints 这些操作在同一个事务里但unlock()在事务提交之前执行。解锁之后、事务提交之前的这个小窗口里另一个线程拿到锁进来查数据库读到的还是未提交的旧数据于是又执行了一遍完整的回调处理。修复方案是把锁的释放移动到事务提交之后。具体做法是利用 Spring 的TransactionSynchronizationManager注册一个afterCommit()回调在事务真正提交后再去释放锁。这个坑非常隐蔽因为它不是每次都会触发只有当解锁和事务提交之间存在恰好重叠的并发窗口时才会暴露。如果读者朋友的项目也有锁内写数据库 锁释放 事务提交的组合强烈建议检查一下锁释放的时机。5. 常见问题与排查技巧实录分布式锁的疑难杂症5.1 锁都设置过期时间了为什么业务还是重复执行这是提问率最高的问题。排查思路分两层先看是不是锁的持有者判断标准不对再看是不是过期时间设得太短恰好覆盖不了业务执行耗时。我排查过一个案例某定时任务每五分钟跑一次处理大量数据平均耗时三分钟当时锁过期时间设了 120 秒——几乎每次都会出现两个任务实例并行处理同一批数据。原因不是分布式锁失效而是锁过期时间太短。这个案例里把过期时间提升到 10 分钟后问题就消失了。但如果你连的是 Redis 主从架构就得再往下查一层看是不是主从切换导致的锁丢失。5.2 主从切换丢锁Redis 分布式锁的阿喀琉斯之踵前面提过这个痛点这里给出实际的排查和应对思路。模拟项目 X 上线两个月后遇到过一次主从切换主节点上写的锁没有复制到从节点从节点被提升为主节点后两个客户端同时拿到锁造成了几十单重复积分。排查方式是用 Redis 的info replication命令比对主从的复制偏移量确认在切换瞬间有部分写操作没有同步。这个问题的根治方案业界并没有统一答案。如果你的业务对一致性要求极其严苛别指望 Redis 锁直接上 ZooKeeper——这是我最诚实的建议。如果一定要用 Redis可以考虑 Redlock 算法也就是向多个独立的 Redis 节点同时加锁超过半数成功才算加锁成功。但这个算法本身有争议分布式存储领域的若干专家都公开抨击过它我自己也在生产环境测过它能提高可用性但无法做到绝对安全所以还是要回到业务层面兜底——比如在数据库层面加唯一约束做二次校验。5.3 客户端 GC 导致锁过期最隐蔽的幽灵锁这个坑我在实际项目中只遇到过两三次每次排查都极其痛苦。现象是客户端 A 持有 Redis 锁一切正常突然 Full GC 发生应用线程暂停了几十秒锁在 Redis 端因为过期被自动删除。GC 结束后A 线程继续执行业务代码但此时锁已经是 B 客户端持有的了两个线程同时在临界区运行。这个问题的可怕之处在于它完全不受你控制——你无法预测 GC 什么时候发生无法保证业务线程能在一个锁过期时间内运行完。所以你能做的只有两件事第一在业务侧的写操作中加乐观锁或者幂等控制把并发重复执行的后果降到最低第二ZooKeeper 的会话超时机制在这里反而更有优势——但同样无法完全避免 GC 停顿导致的锁释放问题。我见过一个做得不错的方案用数据库唯一索引做最终防线。比如积分发放表里用订单号 用户 ID作为唯一键即使是分布式锁失效导致两个线程同时执行数据库也只会插入一条记录另一个线程插入时因为唯一约束冲突而失败。这种分布式锁防并发 数据库约束防重复的双保险是我个人最推荐的生产级做法。5.4 快速排查速查表把上面所有问题整理成一张速查表方便实际遇到时对照排查故障表现可能原因排查方法解决方向业务重复执行锁过期时间短于业务执行时长查看业务耗时和锁 TTL动态续期或调大过期时间死锁所有客户端都拿不到锁加锁后没设过期时间 / 客户端崩溃前未释放检查 Redis 中残留 key看 TTL用原子命令加锁锁误删导致并发失控解锁前未校验持有者标识检查解锁脚本是否比对 valueLua 脚本先检查后删除两个线程同时进入临界区Redis 主从切换丢锁查看复制偏移量和切换日志换 ZooKeeper 或加数据库兜底偶发重复且伴随 GC 停顿Full GC 期间锁过期查看应用 GC 日志加幂等控制或双保险机制5.5 从一次故障复盘看设计取舍最后讲一个复盘案例。某业务团队起初用了 ZooKeeper 分布式锁但发现大促期间锁等待的耗时太长部分请求超时于是换成了 Redis 锁。换完之后性能问题解决了但引入了偶发的重复执行问题最后只能靠消息队列的消费幂等来兜底。这个案例很有代表性每一个方案都是用一致性换性能或者用性能换一致性的权衡。团队在做选型时往往只看性能指标忽略了容错机制的建设。我个人的观点是分布式锁只是第一道防线不是全部。无论选哪个方案业务侧的幂等设计都必须存在——锁可以在极端情况下失效但幂等不能让错误扩散。这是我参与多个项目之后最深的体会。6. 选型心法从项目实际出发的最终建议6.1 三个决策问题帮你快速锁定方案如果让我给一个选型决策框架核心是回答三个问题第一一致性要求有多高如果锁失效会导致资金损失、数据错乱这类不可接受的后果直接排除 Redis 主从方案选 ZooKeeper 或者加上完整的数据库幂等兜底。第二并发量到底有多大每秒几十次的低并发操作没必要上分布式锁组件数据库唯一索引配合定时清理就能解决每秒上千次的热点操作ZooKeeper 的性能会成为瓶颈Redis 是更现实的选项。第三团队对中间件的运维能力如何这个经常被忽略。ZooKeeper 集群的高可用运维不是零成本的如果你所在团队没有专门的中间件工程师出问题时往往叫天天不应。Redis 因为大部分团队已经很熟了反而是更稳妥的选择。6.2 每个方案的标配补充组件不管选哪种锁我都建议在周边做好这几个配套建设它们是分布式锁能够安全落地的必要条件锁监控告警对加锁失败次数、锁等待时长、锁自动过期次数做监控任何一个指标出现异常波动都说明锁的配置或使用方式有问题。我见过最快的故障发现就是靠告警而不是靠用户投诉。快速人工解锁平台线上出问题需要人工介入时能在管理后台一键强制释放某个锁。这个听起来很土但真到事故处理的时候特别有用。锁使用规范文档定清楚哪些场景需要加锁、锁 key 的命名规范、过期时间设置范围、禁止在锁内执行远程调用。虚拟项目里我见过一次因为有人在持锁期间调了外部 HTTP 接口导致锁超时最后造成连锁反应的案例这类问题靠规范基本能避免。6.3 最后的一点经验回到点评项目---分布式锁这个题目本身。分布式锁不是一个可以拿来即用的工具而是一套需要你理解原理、权衡取舍、并用工程手段兜底的设计。它最迷人的地方在于三个看似简单的字——互斥性——在不同的一致性模型和性能约束下延伸出了完全不同的技术方案。我写这篇文章不是想告诉你哪个方案最好而是想让你在看到任何一个分布式锁方案时能清楚地问出那几个关键问题锁会过期吗过期了怎么办持有者会误删吗主从切换会丢吗进程崩溃锁能自己释放吗在实际操作中这套提问模型比任何现成代码都更值钱。另外一个真实体会是分布式锁方案设计得再完善也不如尽量避免共享资源的并发写来得彻底。如果业务允许优先通过数据分片、请求路由等方式把并发请求分散到不同资源上很多场景根本不必要上锁。这是我在多个项目里反复验证过的经验——最好的锁是你不需要用的那把锁。
阅读完成 · 觉得有帮助?