
大家好我是程序员小策。凌晨两点线上告警炸了。你打开监控一看库存被扣成了负数。日志里两条订单几乎同时通过了扣库存逻辑——明明加了分布式锁怎么还是锁不住原因很简单——锁过期了。你的业务执行了 15 秒但锁的 TTL 只设了 10 秒。锁提前释放第二个请求趁虚而入两个请求同时扣了库存。这就是分布式锁最经典的翻车场景业务没跑完锁先跑了。问题定义锁过期 ≠ 业务完成分布式锁的基本套路你肯定知道SETNX 抢锁设个过期时间用完释放。看起来天衣无缝。但问题在于你设的过期时间凭什么刚好等于业务执行时间设短了——业务没跑完锁就释放了别的线程趁虚而入锁形同虚设。设长了——业务挂了锁不释放别人等到天荒地老。那设个差不多的值呢比如 30 秒也不行。平时 30 秒够用一旦遇到慢 SQL、GC 停顿、网络抖动业务执行时间可能飙到 40 秒。你永远无法预知业务到底要跑多久。这就是核心矛盾锁的过期时间是静态的但业务的执行时间是动态的。核心概念看门狗看门狗Watchdog是 Redisson 提供的自动续期机制——在锁的持有期间后台线程每隔一段时间自动延长锁的过期时间确保业务没跑完锁不会过期。打个比方你玩联机游戏服务器要求客户端每隔 5 秒发一次心跳。收到了心跳服务器就知道这个玩家还在线继续保留他的房间。一旦心跳断了服务器判定玩家掉线把房间回收。看门狗就是这个心跳机制。Redisson 持有锁的线程每隔 10 秒lockWatchdogTimeout / 3向 Redis 发一次续期请求相当于告诉 Redis我还活着锁别过期。如果线程挂了或者卡死了心跳自然就断了锁到期自动释放。翻译回技术语言游戏心跳看门狗客户端发心跳后台线程发续期命令5 秒一次10 秒一次lockWatchdogTimeout / 3服务器保留房间Redis 延长锁 TTL心跳断开 → 回收房间续期停止 → 锁自动过期实现看门狗到底怎么跑的先看一段最基本的使用方式importorg.redisson.api.RLock;importorg.redisson.api.RedissonClient;importorg.springframework.stereotype.Service;ServicepublicclassOrderService{privatefinalRedissonClientredisson;publicOrderService(RedissonClientredisson){this.redissonredisson;}publicvoiddeductStock(StringproductId){RLocklockredisson.getLock(lock:stock:productId);try{// 不传 leaseTime看门狗自动生效lock.tryLock(0,-1,java.util.concurrent.TimeUnit.SECONDS);// 业务逻辑扣减库存doDeduct(productId);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}finally{if(lock.isHeldByCurrentThread()){lock.unlock();}}}privatevoiddoDeduct(StringproductId){// 扣库存逻辑...}}关键点lock.tryLock(0, -1, TimeUnit.SECONDS)第二个参数传了 -1。这个 -1 就是不指定 leaseTimeRedisson 检测到 leaseTime -1 时就会启动看门狗。如果你传了一个具体的值比如 30 秒看门狗就不会启动——Redisson 认为你自己管过期时间。看门狗的续期逻辑在 Redisson 源码的RedissonLock.RenewalScheduler中核心流程如下// Redisson 源码简化版privatevoidscheduleRenewal(longthreadId){RenewalTasktasknewRenewalTask(threadId);// 延迟 lockWatchdogTimeout / 3 后执行续期// 默认 lockWatchdogTimeout 30 秒所以每 10 秒续期一次timeouttask.schedule(lockWatchdogTimeout/3,TimeUnit.MILLISECONDS);}classRenewalTaskimplementsRunnable{Overridepublicvoidrun(){// 1. 异步执行 PEXPIRE 命令重置锁的过期时间booleansuccessrenewExpiration(threadId);if(success){// 2. 续期成功递归调度下一次续期scheduleRenewal(threadId);}// 3. 续期失败锁已经不属于当前线程停止续期}}整个续期过程是递归调度的续期成功 → 调度下一次 → 续期成功 → 再调度……直到unlock()被调用或者续期失败。续期命令用的是 Lua 脚本保证了原子性-- Redisson 续期 Lua 脚本简化版ifredis.call(hexists,KEYS[1],ARGV[2])1then-- 锁还属于当前线程续期redis.call(pexpire,KEYS[1],ARGV[1])return1endreturn0先检查锁的持有者是不是自己是才续期。这防止了一个线程给另一个线程的锁续期。边界与陷阱看起来很完美了对吧但有几个坑你得知道。陷阱一unlock 必须放在 finally 里且要判断 isHeldByCurrentThread。后果如果业务抛异常unlock 没执行看门狗会一直续期锁永远不释放——这比锁过期更可怕。解法unlock 前判断lock.isHeldByCurrentThread()防止释放别人的锁。陷阱二传了 leaseTime 就没有看门狗。后果lock.lock(10, TimeUnit.SECONDS)这样写看门狗不会启动。10 秒后锁强制过期不管业务跑没跑完。解法需要看门狗就别传 leaseTime或者传 -1。陷阱三lockWatchdogTimeout 是全局配置改了影响所有锁。后果把lockWatchdogTimeout从 30 秒改成 10 秒续期间隔变成 3.3 秒。如果某个业务偶尔 GC 停顿 5 秒锁就过期了。解法默认 30 秒别乱改。如果某个锁确实需要更短的过期时间单独传 leaseTime。高级考量多节点与主从切换看门狗在单节点 Redis 下工作得很好。但生产环境通常是主从架构。问题主节点加锁成功数据还没同步到从节点主节点挂了。从节点升为主节点后锁信息丢失。另一个线程在新主上也能加锁成功——两个线程同时持有锁。看门狗解决不了这个问题。它只负责续期不负责主从一致性。Redisson 提供了 RedLock 算法来应对向 N 个独立 Redis 节点加锁超过半数成功才算加锁成功。但 RedLock 本身争议很大——Martin Kleppmann 写了一篇著名的文章质疑它SalvatoreRedis 作者又写了一篇反驳。实践中更常见的做法是接受极低概率的锁丢失在业务层做幂等兜底。比如扣库存前先查一下是否已经扣过用唯一订单号做去重。这比 RedLock 简单得多也更可靠。对比表格方案核心思路优点缺点适用场景SETNX 固定 TTL加锁时设过期时间简单业务没跑完锁就过期执行时间确定的短任务SETNX 看门狗后台线程自动续期锁不过期安全线程挂了需要等 TTL 才释放执行时间不确定的长任务SETNX 手动续期业务代码自己续期灵活可控忘了续期就锁过期代码复杂极少数需要精确控制的场景RedLock多节点加锁半数成功主从切换安全性能差、争议大对一致性要求极高的场景一句话总结大部分场景用看门狗就够了。只有在锁丢失不可接受且业务无法做幂等的极端场景下才考虑 RedLock。面试追问追问 1看门狗的续期间隔为什么是 lockWatchdogTimeout / 3→ 回答方向留出足够的容错空间。如果续期请求因为网络抖动失败了还有 2/3 的时间20 秒可以重试。如果间隔设成 lockWatchdogTimeout 本身一次续期失败锁就过期了。追问 2如果持有锁的线程发生 Full GC看门狗还能续期吗→ 回答方向不能。Full GC 期间所有线程暂停Stop-The-World看门狗线程也被冻结无法发送续期请求。如果 GC 时间超过 lockWatchdogTimeout锁会过期。这就是为什么 lockWatchdogTimeout 默认 30 秒——给 GC 留出足够的缓冲。追问 3可重入锁的续期是怎么处理的→ 回答方向Redisson 的锁是可重入的用 Hash 结构存储field 是线程标识value 是重入次数。看门狗续期时只看 field 是否存在不管重入次数。只要锁还属于当前线程就续期。unlock 时重入次数减到 0 才真正释放锁并停止续期。追问 4看门狗续期失败会怎样→ 回答方向续期失败锁已被释放或属于别的线程看门狗立即停止递归调度不再续期。锁会在剩余 TTL 到期后自动释放。这是正确的行为——续期失败说明锁已经不是你的了不应该继续霸占。总结看门狗解决的核心问题是锁的过期时间是静态的但业务的执行时间是动态的。读完这篇你应该能解释为什么 SETNX 固定 TTL 不够用、说出看门狗的续期间隔和默认超时时间、在代码中正确使用 Redisson 的看门狗不传 leaseTime、在面试中区分看门狗和 RedLock 解决的是不同层面的问题。