资讯动态

Redis分布式锁演进:从SETNX到Redisson看门狗

发布时间:2026/10/15 23:08:56 来源:尧图企业网站定制
1. 问题背景为什么分布式环境下还需要一把“锁”先说一个最常见的场景。某个团队维护了一套订单服务早期是单体架构并发不高的时候直接用本地锁synchronized 或者 ReentrantLock就能保证同一个订单不会被两个线程同时处理。后来服务拆成多实例部署一台变成三台问题立刻来了本地锁只锁得住当前进程请求被负载均衡分散到三台机器上同样的“判断用户余额并扣款”逻辑完全可能被 A 实例和 B 实例同时执行。于是大家开始寻找一个“所有实例都能看到的锁”Redis 因为本身已经是基础设施几乎成了第一选择。这个项目标题里提到的“Redis 分布式锁”本质上就是利用 Redis 的原子操作实现跨进程互斥。听起来很简单但实际落地时牵扯的问题非常多锁怎么加、锁怎么释放、锁超时了怎么办、业务执行时间比锁过期时间还长怎么办、主从切换会不会导致锁丢失……每一个问题背后都是一次生产事故的教训。我之所以写这篇复盘是因为最近又帮一个朋友排查了他们线上偶发的“重复支付”问题最后定位到竞态窗口就出在锁的过期时间设置上。这个过程中梳理了一遍从 SETNX 一路演进到 Redisson 看门狗的完整链路发现很多细节如果不抠清楚确实容易踩坑。不管你是刚接触分布式系统的新手还是已经在生产环境里用过 Redis 锁但没完全吃透原理的人这篇文章的目标都是让你看完之后能看清每个方案背后的权衡是什么为什么最终社区会收敛到 Redisson 这种“看门狗”思路以及你在自己的项目里到底应该怎么选。2. SETNX 时代一把最原始的锁2.1 最初的最初SETNX EXPIRERedis 从 2.6.12 版本开始支持在 SET 命令上直接附加 NX 和 EX 参数在这之前大家用的是 SETNX 命令。SETNX 的含义是“SET if Not eXists”也就是只有当 key 不存在的时候才能设置成功。这个语义天然适合做锁# 尝试获取锁 SET lock_order_1001 my_token NX EX 30这条命令的意思是只有当lock_order_1001这个 key 不存在时才设置它并且给它设置 30 秒的过期时间。如果返回 OK说明当前实例拿到了锁如果返回空说明锁被别人持有。为什么需要 EXPIRE因为分布式环境下持有锁的实例随时可能崩溃如果没有过期时间这把锁就永远没人释放其他实例只能干等——这是分布式系统中的“死锁”问题。我见过很多刚开始接触分布式锁的开发者第一版代码就是直接调 SETNX然后忘记设置过期时间。开发环境下没问题因为测试人员不会去吧 Redis 里的 key 删掉但生产环境一旦有节点 crash锁就永远卡在那里只能靠人工清 key 恢复。这种事故的修复流程非常被动你需要先监控到大量请求超时然后排查所有 Redis key最后找到那个没有 TTL 的锁 key 手动删除。整个过程耗时几十分钟而这几十分钟内业务是完全不可用的。2.2 误删锁最经典的坑SETNX EXPIRE 解决了“死锁”问题但紧接着就会遇到第二个坑业务执行时间超过锁过期时间锁自动释放了另一个线程 B 获取到锁开始执行。此时线程 A 执行完毕想要释放锁。如果释放逻辑简单粗暴地执行 DEL key就会把线程 B 持有的锁删掉。线程 C 紧接着又获取到锁……于是多个线程实际上同时进入了临界区。这个问题的本质是释放锁的操作必须验证“这把锁是不是我的”。解决方案是给锁的 value 设置一个唯一标识# 获取锁value 是当前请求的唯一标识 SET lock_order_1001 uuid_xxxx NX EX 30 # 释放锁需要先比较 value 再决定是否删除比较和删除两步操作放在客户端逻辑里做会有竞态窗口。为了保证原子性需要用 Lua 脚本来实现“判断 删除”if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段 Lua 脚本的意思是先把锁的 value 取出来如果等于当前请求的唯一标识ARGV[1]才执行删除否则什么都不做。因为 Redis 是单线程执行 Lua 脚本的所以这个“先判断再删除”的流程在 Redis 端是原子的不会被打断。踩过这个坑之后团队一般会给锁的 value 用 UUID 或者“设备 IP 线程 ID 自增序列”这种全局唯一的字符串确保任何两个请求的锁标识都不可能相同。2.3 SETNX 三件套的正确姿势把 GET DEL 换成 Lua 脚本把 SETNX EXPIRE 换成 SET NX EX 原子操作基础版分布式锁的完整链路就能闭环了加锁SET key uuid NX EX ttl业务逻辑执行临界区代码释放锁执行 Lua 脚本校验 UUID 后删除这套方案能覆盖 90% 以上的基础场景代码量少不依赖额外组件轻量服务完全可以用这个方案。我自己的经验是如果团队对 Redis 的使用已经比较熟练、业务对锁的需求只有“互斥 防死锁”这两点其实没必要一上来就套 Redisson徒增依赖和配置成本。但要是想得更远一点这套方案的隐患也很明显锁的有效期是固定的。业务执行时间不可控时锁可能提前释放导致并发安全问题。如果锁过期时间设置太长一旦持有锁的实例真挂了其他实例需要等待很久才能抢到锁影响可用性。不具备公平性。多个实例同时争抢谁先到达 Redis 谁就赢无法实现“先到先得”的排队效果。不可重入。同一个线程如果递归调用加锁逻辑第二次会锁住自己。其中第三个问题其实就是“锁标准之争”的起点——当业务对公平性有要求时SETNX 方案基本无能为力。3. Lua 脚本从朴素方案到原子性保障3.1 为什么必须靠 Lua 而不是客户端组合命令很多开发者会有疑问判断 value 和删除 key不就是先 GET 再 DEL 吗放客户端里写也没什么问题吧实际上问题不小。分布式场景下Redis 的每个命令都是单独执行的两个命令之间完全可以插入其他客户端的行为。比如线程 A 执行 GET 拿到了自己的 UUID然后线程 B 在 A 删除之前就把锁抢过去了——这个概率虽然不高但一旦发生就是线上事故。要做到“判断”和“删除”之间没有任何缝隙唯一可靠的方式是让这两个操作在 Redis 服务端原子完成。Redis 的 Lua 脚本天然满足这个需求因为它会在执行期间阻塞其他命令保证了脚本内部的逻辑不会被外部干预。同样的原理也适用于“加锁 过期时间”客户端里分别执行 SET NX 和 EXPIRE 也有竞态窗口SET NX 成功之后、EXPIRE 执行之前如果进程崩溃锁同样会永久存在。所以标准做法就是用 SET NX EX 一条命令完成设置和过期时间绑定。3.2 Lua 脚本的完整锁实现一段稳妥的加锁脚本长这样-- KEYS[1] 是锁的 key -- ARGV[1] 是当前请求的唯一标识 -- ARGV[2] 是锁的过期时间单位毫秒 if redis.call(set, KEYS[1], ARGV[1], NX, PX, ARGV[2]) then return 1 else return 0 end很多人习惯用 EX 指定秒级过期时间。我的建议是如果业务对时间敏感优先考虑 PX 毫秒续期因为有些框架的看门狗续期是基于毫秒计算的单位不一致容易导致边界判断失误。释放锁则用之前提到的那段脚本。把这两段脚本提前用SCRIPT LOAD加载到 Redis拿到 SHA 摘要后每次调用EVALSHA可以减少网络传输的开销。实测下来的效果是在高 QPS 场景下每次锁操作的耗时能从 Lua 脚本完整传输的约 0.2ms 降低到 0.1ms 以内对于追求极致性能的服务确实是个值得做的优化。3.3 Lua 方案还没解决的难题脚本化之后原子性问题是解决了但分布式锁最痛苦的“业务超时”问题依然没有绕过。过期时间设置的逻辑永远是矛盾的设置短了业务一慢锁就没了设置长了真崩溃时要等很久才能恢复。打个比方锁的过期时间就像餐厅排队时取的号。你预估自己 20 分钟能排到座位但前面的人突然聊起来了吃了一个小时。服务员按规矩把你的号作废下一批顾客进场。此时你正啃着鸡腿突然发现你的号被吊销了新顾客已经坐在你对面——两个顾客在同一张桌子上吃饭。分布式锁也一样锁过期不是锁的主人主动释放而是 Redis 按时间单方面判定。只要持有锁的线程还在运行中它的语义就应该是“锁还在有效期内”而不是“物理时间到了就作废”。Lua 方案解决不了这个问题因为它不能感知持有者的状态。正是这个痛点催生了“看门狗”这种自动续期机制。4. Redisson 看门狗给锁加上自动续期4.1 看门狗原理锁不再“过期作废”Redisson 的思路很直白既然锁过期时间不好设那就让获得锁的线程在运行期间定期“续约”——只要业务还在跑看门狗就持续给锁延期直到业务结束主动释放锁。Redisson 默认的锁超时时间是 30 秒看门狗每隔 10 秒会检查一次如果锁还在说明线程还没释放就把过期时间重新设置为 30 秒。这个“续期操作”同样是基于 Lua 脚本完成的原子操作思路和前面的统一释放脚本类似。具体流程可以简单描述为客户端请求加锁Redisson 底层执行 Lua 脚本在 Redis 中创建一个 hash 结构记录锁名称、持有者标识和加锁次数。加锁成功后开启一个后台定时任务调度线程池作为看门狗。看门狗每 10 秒执行一次续期操作把锁的过期时间重新刷新为 30 秒。业务逻辑执行完毕调用 unlock 方法Redisson 执行释放脚本。如果业务线程长时间不结束比如因为死循环或 IO 阻塞看门狗也会一直续期直到线程停止或被外部终止。这个机制带来的最大改变是锁的有效期从“固定时间”变成了“动态时间”——只要持有者还活着锁就永远不会过期。这就解决了前面 SETNX 方案里“业务还没执行完锁就没了”的核心痛点。4.2 源码视角看门狗是怎么实现的虽然不要求每个人都读一遍 Redisson 源码但理解几个关键类的职责对排查问题很有帮助。在 Redisson 中加锁的核心逻辑在RedissonLock类里。lock()方法最终会调用tryAcquire内部先尝试用 Lua 脚本加锁如果加锁成功且没有指定leaseTime锁的过期时间则启动一个scheduleExpirationRenewal任务。这个任务的核心逻辑是每 10 秒执行一次renewExpiration。renewExpiration内部执行一段 Lua 脚本如果锁还存在且持有者是自己就把 expiretime 重置为 30 秒。如果执行续期时发现锁已不存在或者持有者变了就取消调度任务。这里有一个非常关键的细节看门狗续期的时候一定会检查“持有者是不是自己”。如果锁已经被别人抢走比如网络分区导致锁超时丢失看门狗不会盲目续期覆盖别人的锁。Redisson 还提供了tryLock(long waitTime, long leaseTime, TimeUnit unit)这个方法。当leaseTime大于 0 时锁会使用固定过期时间看门狗机制不会启用当leaseTime设置为 -1 时才会启用看门狗自动续期。这个细节特别容易踩坑很多开发者以为传了 leaseTime 就能自动续期实际上恰恰相反——传了固定时间反而不续期。提示如果业务场景对锁的最长持有时间有硬性限制比如防止死循环情况下锁无限期被持有一定要设置 leaseTime 作为兜底。看门狗无限续期是一把双刃剑它解决了“业务没跑完锁没了”的问题但也可能让一个异常线程永远占用锁。4.3 看门狗机制的价值边界看门狗解决的是 SETNX 方案中“固定过期时间”带来的核心矛盾但它并不是银弹。它的价值边界在于适用于“业务执行时长不可控但必须保证互斥”的场景比如分布式任务调度、库存扣减等。适用于“持有锁的节点不会无缘无故地长时间暂停”的假设。如果持有锁的节点发生 STW比如 Full GC看门狗线程本身也可能停顿导致续期失败锁依然会被释放。这个问题在 Redisson 的实际使用中确实存在。当 JVM 发生长时间的 Full GC 时业务线程停止了但看门狗线程也可能被 GC 卡住没有成功续期锁就释放了。其他节点拿到锁进入临界区原节点的业务线程 GC 结束后也继续执行就产生了并发竞争。不过话说回来这个问题属于极端场景。生产环境里 Java 应用配置了 G1 回收器后Full GC 时间一般能控制在几十毫秒级别远小于 10 秒的看门狗续期窗口。真正危险的场景是异地多次 Full GC 累积超过数秒这种情况本身就需要优先解决 GC 问题而不是期望锁机制来兜底。5. 架构权衡三个方案如何选5.1 核心维度对比聊了这么多说到底最后还是要落地到一个问题上我的项目到底该用哪个方案。我整理了一张表把三个方案几个关键维度拉出来对比方便你根据项目情况快速判断。维度SETNX LuaRedisson看门狗开启实现成本低需自己写脚本低引入依赖即用可重入性不支持需额外实现计数器支持内部用 hash 记录重入次数自动续期不支持支持默认 30 秒窗口每 10 秒续期公平性不支持默认不支持可用 FairLock 实现公平防误删需自行实现 Lua 校验释放内置校验比较持有者标识配置复杂度低只需 Redis 连接中需引入完整客户端框架依赖体积轻量适合简单服务较重引入大量类库崩溃兜底依赖 TTL依赖 TTL 看门狗双重机制看完这张表你会发现如果只想要一个“最简单、能跑、没有额外依赖”的锁SETNX Lua 完全能满足。如果项目里已经用了 Redisson 作为 Redis 客户端或者需要重入、自动续期、公平队列这些高级特性直接用 Redisson 更省心。5.2 公平性诉求什么时候需要 FairLockRedisson 提供了一种特殊的FairLock它的内部实现是在 Redis 中维护一个有序集合记录每个等待获取锁的线程的请求顺序然后按照顺序依次授予锁。这个需求多出现在任务调度类场景。比如多个实例同时在跑定时任务业务要求“先发起请求的任务先执行”如果两个任务同时抢锁用非公平锁可能造成后面的先执行、前面的后执行产生不必要的冲突。但公平锁在实现上会增加复杂度和性能开销因为每次加锁和释放都要维护队列高并发下的争抢性能会打个折扣。我的经验是90% 的业务场景其实不需要公平锁只需要保证“同一时刻只有一个执行者”就够了。公平与否反而没那么重要。如果业务真的需要严格排队先和产品确认一下需求是否合理很多时候只是“觉得应该公平”但从系统整体来看无序执行并不会带来实质问题。5.3 主从架构锁丢失的隐忧还有一个分布式锁绕不过去的问题Redis 主从切换。所有客户端写主节点从节点异步同步数据。当主节点崩溃时从节点提升为主节点此时尚未同步到从节点的锁 key 就会丢失新的主节点上没有这把锁后续请求就能直接加锁成功。针对这种情况Redis 官方提出的 RedLock 算法红锁要求写入多个独立的 Redis 实例只有大多数实例加锁成功才算抢锁成功。复杂度很高而且社区对 RedLock 的有效性一直有争议很多高水平的架构师也公开表达过多实例红锁在极端情况下并不严格的看法。我的观点是如果你的系统可以容忍极少情况下锁的短暂失效比如秒杀场景多卖一件商品可以接受通过额外校验兜底那么单实例 Redis 看门狗方案足够用如果你的系统对一致性要求极其严苛比如金融支付类应该优先考虑基于持久化存储数据库行锁、ZooKeeper 临时顺序节点的方案而不是继续在 Redis 上折腾。6. 真正生产中的常见问题与排查实录6.1 误删锁你的锁被别人释放了吗这是最经典的线上问题。症状是某个接口偶尔报“并发冲突”排查发现多个请求同时进入了临界区但看代码逻辑里明明是加了锁的。排查步骤先确认加锁和释放锁是不是同一把 key。再确认释放时是否校验了持有者标识。最后看释放逻辑是不是用了 Lua 脚本保证原子性。这三个环节任何一个做错了都可能导致误删。我有一个同事就踩过这个坑他是用 JEDIS 客户端先 GET 再 DEL没有用 Lua 脚本结果在低峰期没出问题一到高峰期流量上来误删概率陡增。复现方式很简单开两个服务实例手动把第一个实例加锁后休眠一段时间然后模拟第二个实例尝试加锁并释放锁看看锁的状态。如果第二个实例的释放操作直接把 key 删了说明校验逻辑没生效不是误判。6.2 看门狗没续期锁为什么还是会丢排查了几次线上问题之后我发现一个共性看门狗续期不是 100% 生效的。它的续期由调度线程池执行如果应用线程池被打满或者 GC 停顿超过 10 秒续期操作就会延迟甚至失败。还有一个容易忽视的坑在应用中使用 Redisson 时如果配置了两个不同的 Redisson 客户端连接同一个 Redis但其中一个客户端配置了固定的lockWatchdogTimeout另一个没配那么两个客户端创建锁的续期行为可能不一致。我遇到过一个场景业务 A 用 Redisson 的tryLock(0, 10, TimeUnit.SECONDS)设置了固定过期时间业务 B 用默认看门狗 30 秒。两个业务共用同一个 Redis key 前缀结果 A 的锁到期后 B 立刻就能抢到导致 A 和 B 实际上同时执行。这个问题定位了很久才发现是两个服务的锁过期时间配置不一致导致的。排查思路查看 Redis 中锁 key 的 TTL 变化。如果 TTL 恒定为某个固定值不减说明没有启用看门狗因为看门狗会把 TTL 刷新回 30 秒。如果 TTL 持续减小到 0说明看门狗没工作需要检查是否设置了 leaseTime。6.3 全局锁引发的性能瓶颈分布式锁最容易被忽略的问题是它会把并发量直接压到 Redis 单节点上。假设你们每秒有 1 万请求都在抢同一把锁Redis 的单线程模型会瞬间成为瓶颈因为每次加锁都是串行执行。针对高并发下的锁竞争有几个优化方向锁粒度拆分。对同一个订单 ID 加锁天然分散但如果是全局库存锁可以把库存分成多段每段对应一把锁。缩短临界区。锁里面只放必须互斥的代码其他操作移到锁外面减少锁持有时间。引入预扣减。在 Redis 中先减库存异步扣减数据库锁只保证数据库扣减的一致性。我曾经参与过一个库存服务的优化原来整个“减库存 更新销量 写审计日志”都在锁里执行平均耗时 80ms。优化后把写审计日志移出锁外锁内操作降到 20ms同样的锁粒度下吞吐提升了接近 4 倍。6.4 实战排查工具与命令如果你需要快速确认某个 Redis key 是不是锁可以用以下命令直接观察TTL lock_key查看剩余过期时间如果持续在 20~30 秒之间波动说明看门狗正在续期。GET lock_key查看 value确认持有者标识能对应到哪台机器的哪个线程。TYPE lock_keyRedisson 的锁是 hash 类型而 SETNX 方案是 string 类型从类型能判断是用什么客户端加的锁。MONITOR在高危操作前开启 MONITOR 监听 Redis 命令流看有没有可疑的 DEL 操作抢在释放前执行。另外推荐在客户端代码里给锁的关键事件打上日志加锁成功、加锁失败、开始续期、续期失败、释放锁成功。尤其是续期失败的日志很多时候是分钟级偶发一段时间不记录日志根本查不出来。7. 工程实践构建一个稳健的分布式锁7.1 落到代码层什么时候用什么方案如果你用的是 Spring Boot Redis 的轻量项目我的建议是分情况选择单体多实例部署、锁需求简单、不想引入额外依赖直接用 SETNXLua锁的过期时间设置足够大比如业务平均耗时的 10 倍以上配合监控告警出问题能及时发现。已有 Redisson 依赖、需要分布式组件复用直接用 Redisson 的RLock并按需开启看门狗。重入场景较多、锁粒度较细优先用 Redisson它内置重入计数省去自己实现低层 hash 锁结构的麻烦。对锁的公平性有强诉求Redisson FairLock 可以满足但要接受性能损失。7.2 锁过期时间到底怎么设这是一个涉及业务判断的问题我给不出统一的公式但可以给出经验参考如果业务是同步调用且对方接口响应时间有保证比如缓存查询锁过期时间可以控制在 1~2 秒。如果业务包含外部接口调用、数据库交互、网络IO甚至可能触发重试锁过期时间建议 10~30 秒。长事务场景比如异步任务拆分、人工审批流程不建议用 Redis 锁建议用数据库乐观锁、状态机或者 ZooKeeper 锁。在实际项目中我倾向的做法是设置一个合理的默认值10 秒同时配合看门狗自动续期这样既不会因为默认值太短导致业务超时也不会因为太长导致故障恢复慢。7.3 加锁失败后的等待策略SETNX 方案的一个不足是国内很多代码写得像这样Boolean locked redisTemplate.opsForValue().setIfAbsent(key, value, timeout); if (locked) { // 执行 } else { return 系统繁忙; }拿到锁失败就直接返回“系统繁忙”这在瞬时高并发下会造成大量请求被拒体验很差。正常做法是加一个自旋重试失败后 Sleep 一小段时间比如 50ms再重试最多重试 N 次每次间隔随机化避免所有请求同时冲过来。Redisson 的tryLock(waitTime, leaseTime, TimeUnit)方法天然支持“等待锁”模式在 waitTime 内阻塞等待其他线程释放锁如果超时还没拿到就返回 false。这样能极大减少“直接拒绝”的体验问题。但要注意tryLock如果设置 leaseTime 为 -1 启用看门狗那 waitTime 以内的等待是阻塞的会占用线程资源。这个在设计线程池大小时要提前考虑免得等待锁的线程把线程池占满其他非锁请求反而进不来。7.4 锁的监控与告警线上分布式锁如果没有任何监控出了问题很难快速定位。我建议至少采集这几个指标加锁成功率一段时间内加锁成功次数 / 加锁总次数。等待锁平均耗时能反映锁竞争激烈程度。锁持有平均时长能反映临界区代码的执行效率。看门狗续期失败次数这是锁丢失的直接信号。Redis 命令耗时P99锁的延迟直接影响业务接口延迟。这些指标可以用 Micrometer 或者 Prometheus 客户端接入直接挂到现有监控体系里。锁的告警阈值需要根据业务实际调整比如“加锁成功率低于 90%”这类通用阈值不一定合适最好先跑一周看基线数据再确定异常判定线。8. 写在最后选型不是一道选择题而是一道权衡题从 SETNX 到 Lua 脚本到 Redisson 看门狗这个演进过程本质上是一连串“取舍”的叠加。SETNX 方案用最小的成本解决了“多实例互斥”这个基础需求但代价是过期时间不可控、不可重入、不够公平。Lua 脚本解决了原子性却解决不了“业务比锁活得更久”的尴尬。Redisson 看门狗用自动续期换来了更强的鲁棒性但同时也增加了复杂度还给异常线程留下了无限期占用锁的可能。我自己在实际项目里的体会是分布式锁是一个“平时用起来简单、出问题时极其棘手”的组件。很多团队一开始用 SETNX是因为“够用就行”的心态但一旦业务开始跨模块、跨服务、要求高可用这套简单的方案就会出现各种边缘问题。而在真正把 Redisson 引入生产之后也不要以为万事大吉看门狗本身也有失效场景。如果你想做一次系统的归档复盘我建议可以回去翻一下自己生产环境里加锁相关的事故记录把每个事故对应到这篇文章里的某个维度是误删是超时是主从切换还是看门狗没续期把这些案例总结成一份内部的“锁故障手册”比任何空洞的方案比较文档都有用得多。最后再分享一个小技巧任何分布式锁方案上线之前都建议写一个并发压测用例模拟两个线程同时执行临界区 10000 次验证确实没有出现同时进入的情况。这个测试看起来不起眼但它能提前把我文章里说的误删、超时两个问题在开发环境就暴露出来而不是等你上生产了再去紧急答辩。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑