Redis分布式锁续期机制全解析:Redisson Watchdog源码与避坑指南
前几天帮团队做面试复盘十个候选人里七个能背出 Redisson 的 watchdog 续期机制可往下追问就露馅了watchdog 在什么条件下启动为什么是每 10 秒续一次传了 leaseTime 它还会续吗能答到位的不到两个。这篇文章不聊虚的直接从源码和 Lua 脚本层面把 Redis 分布式锁的续期机制拆干净。我会把 Redisson 的续期定时任务、锁在 Redis 里的数据结构、面试官最爱追的几个隐藏坑以及我在生产环境踩过的三个真实案例全部整理出来。不管你是准备面试还是正在排查线上锁提前失效、双写、死等问题这篇都值得存下来反复翻。1. 先把分布式锁的“为什么”说透续期问题到底从哪来1.1 一条 SET 指令搞定的锁到底缺了啥Redis 分布式锁最基础的实现就是一条命令SET product:123:lock uuid-20240501 NX EX 30NXkey 不存在才写入保证互斥EX 3030 秒自动过期防止持有者崩溃后锁变成“死锁”很多人的理解到这里就停了以为“加了过期时间就万事大吉”。但这里有个思维盲区过期时间既是兜底保险也是驱逐令。锁在持有者异常崩溃后能自动释放靠的就是过期可一旦业务正常执行的时间超过了这个过期时间锁会在业务还没跑完时就被 Redis 删掉其他线程立刻就能拿锁进来。两个线程同时进入临界区分布式锁形同虚设。这个矛盾是后面所有续期机制的出发点。1.2 过期时间与业务超时之间的天然矛盾假设业务是库存扣减单次执行 100ms锁过期设 30 秒绰绰有余。但换成定时报表、批量对账、Excel 导出这种动不动跑几十秒甚至几分钟的任务呢过期设 3 秒业务没跑完锁就没了双写风险直接拉满过期设 5 分钟持有者真的崩溃时其他线程要空等 5 分钟才能抢到锁故障恢复速度太差过期设成“业务最坏耗时”逻辑上说得通但最坏耗时根本估不准而且业务迭代过程中可能越来越慢今天设 5 分钟够用下个月就超了续期的思路本质上就是把“固定过期”改成“按需续租”锁的初始租期不用太长持有者活着就不断续持有者死了崩溃、断网、进程被杀没人续锁自己就过期了。这样既避免了业务没跑完锁先没了的双写问题也保证异常场景下锁能快速自动释放。1.3 续期的核心逻辑先确认归属再延长租期续期不是无脑PEXPIRE一把梭。如果一把锁已经被释放、被别的线程抢走了你还用旧线程的身份去续期等于给别人的锁加命这可比不续期严重多了。所以 Redisson 的续期动作拆开其实是两步校验锁当前持有者还是不是自己通过HEXISTS检查持有者标记确认是自己才执行PEXPIRE延长租期不是返回失败并停止续期这两步必须作为一个原子操作完成不能先查再改否则中间隔了一个网络往返锁可能正好被释放掉你就把别人的锁续上了。所以实现必须放在 Lua 脚本里让 Redis 单线程串行执行。这个“为什么必须用 Lua”的点面试时主动说出来面试官基本就会眼睛一亮。2. Watchdog 续期机制拆解Redisson 怎么把 30 秒变成“跑不完”2.1 续期任务的启动条件只有不带 leaseTime 才触发Redisson 的入口是getLock加lock()表面看起来就是“拿锁、干活、释放”但源码里这一层藏着关键分支// RedissonLock.java3.x 简化版 private T RFutureLong tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) { if (leaseTime ! -1) { // 指定了租约时间直接用你给的过期时间拿锁不启动 watchdog return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG); } // 没指定租约时间用 lockWatchdogTimeout默认 30000ms拿锁 RFutureLong ttlRemainingFuture tryLockInnerAsync( waitTime, config.getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG); ttlRemainingFuture.onComplete((ttlRemaining, e) - { if (ttlRemaining null) { // 拿锁成功 scheduleExpirationRenewal(threadId); } }); return ttlRemainingFuture; }三个关键结论拿锁时用的默认过期时间是lockWatchdogTimeout默认30000ms只有拿锁成功才会启动续期任务没抢到锁自然不会启动一旦你显式传了leaseTime比如lock(10, TimeUnit.SECONDS)或tryLock(5, 30, TimeUnit.SECONDS)内部就走leaseTime ! -1的分支watchdog 压根不启动最后这条是经典陷阱很多人线上锁提前失效就是死在这我第 3 节专门展开。2.2 为什么是每 10 秒续一次watchdog 调度入口在renewExpiration()它用 Netty 的newTimeout排了一个定时任务private void renewExpiration() { Timeout task connectionManager.newTimeout(new TimerTask() { Override public void run(Timeout timeout) throws Exception { RFutureBoolean future renewExpirationAsync(threadId); future.onComplete((res, e) - { if (e ! null) { log.error(Cant update lock {} expire time, getEntryName(), e); return; } if (res) { renewExpiration(); // 续期成功接着排下一次 } }); } }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); }默认internalLockLeaseTime是 30000除以 3 等于 10000ms也就是每 10 秒续一次每次把锁的过期时间重新拉回 30 秒。这个“租约周期的 1/3 续一次”的设计是有讲究的10 秒的检查间隔远小于 30 秒的租约就算一次续期请求在网络里慢了几秒锁也不会在两次续期之间过期如果间隔设成 25 秒网络抖动 6 秒锁就直接没了1/3 是性能和安全性平衡得很好的系数续期任务是自循环的续期成功才排下一次失败就停止不会出现“客户端已经断开还在无限续期”的诡异现象另外这个定时任务挂在 Netty 的 HashedWheelTimer 上也就是说它跟业务线程不是同一个执行体。业务方法怎么慢都不会直接阻塞定时器这个解耦设计也是 Redisson 续期能稳定工作的重要原因。2.3 锁的数据结构与 Lua 脚本逐行拆解先纠正一个常见认知偏差Redisson 的锁 key 存的不是简单字符串而是一个 hash。product:123:lock └── field: 846c49f4-3d2e-4e7a-8f5b-9f1a1f1e5a2b:1 // uuid:线程id value: 1 // 重入计数 TTL 30000ms用 hash 加持有者字段才能支持同一个线程的重入锁加锁一次 value 1释放一次 -1减到 0 才删 key。普通字符串方案想实现重入得自己另存一个计数变量麻烦且不原子。加锁的 Lua 脚本长这样-- KEYS[1] 锁的 key -- ARGV[1] 锁的租约时长默认 30000 -- ARGV[2] uuid:threadId持有者标记 if (redis.call(exists, KEYS[1]) 0) then redis.call(hincrby, KEYS[1], ARGV[2], 1); redis.call(pexpire, KEYS[1], ARGV[1]); return nil; end; if (redis.call(hexists, KEYS[1], ARGV[2]) 1) then redis.call(hincrby, KEYS[1], ARGV[2], 1); redis.call(pexpire, KEYS[1], ARGV[1]); return nil; end; return redis.call(pttl, KEYS[1]);首次加锁就HINCRBY建 field重入就再 1同时刷新过期时间。拿不到锁就返回剩余 TTL上层根据这个 TTL 决定继续等还是放弃。续期脚本更短-- ARGV[1] internalLockLeaseTime续期后的新租约默认 30000 -- ARGV[2] uuid:threadId if (redis.call(hexists, KEYS[1], ARGV[2]) 1) then redis.call(pexpire, KEYS[1], ARGV[1]); return 1; end; return 0;执行逻辑一句话HEXISTS确认锁还是自己的是才PEXPIRE延长 30 秒不是就返回 0watchdog 停止续期。整个操作一个原子脚本完成从根上杜绝了“检查时锁还在、续期时锁已经换了主人”的竞争窗口。释放锁的脚本同样值得看许多人只背了“释放前要校验持有者”却不知道校验和删除是同一个原子脚本完成的if (redis.call(hexists, KEYS[1], ARGV[3]) 0) then return nil; end; local counter redis.call(hincrby, KEYS[1], ARGV[3], -1); if (counter 0) then redis.call(pexpire, KEYS[1], ARGV[2]); return 0; else redis.call(del, KEYS[1]); redis.call(publish, KEYS[2], ARGV[1]); return 1; end;释放返回 0 表示还有重入计数锁继续留着返回 1 表示真正释放顺便PUBLISH唤醒在同一个 channel 上等待抢锁的线程。3. 面试高频追问续期机制的边界与隐藏坑3.1 传了 leaseTime 为什么 watchdog 就“罢工”了这是面试频率最高、候选人翻车最多的一道“隐藏题”。直接给结论lock()没传 leaseTimewatchdog 正常工作tryLock(waitTime, TimeUnit)没传 leaseTimewatchdog 正常工作lock(leaseTime, TimeUnit)传了 leaseTime锁到期就到期不续tryLock(waitTime, leaseTime, TimeUnit)传了 leaseTime锁到期就到期不续原因就在 2.1 的源码分支只要leaseTime ! -1Redisson 就用你给的固定租约去拿锁后续根本不会调用scheduleExpirationRenewal。生产环境里我见过太多这种写法boolean ok lock.tryLock(5, 30, TimeUnit.SECONDS); // 业务逻辑跑了 2 分钟 // 锁在第 30 秒就已经自动过期另一台机器堂而皇之进来了这个 bug 极其隐蔽因为 tryLock 本身执行顺利业务跑完还能正常 unlockunlock 脚本发现持有者标记还在就正常删库了日志里没有任何异常只有并发测试才能真正暴露。所以面试被问到“watchdog 什么时候不生效”第一时间答出“显式传 leaseTime 就不会续”你已经超过一大半候选人了。3.2 续期失败会怎样线上要盯什么日志watchdog 续期是异步的网络抖动、Redis 超时、连接池打满都会让续期请求失败。看源码里的onComplete分支失败大致有两种命运续期脚本返回 0锁已经不归自己停止续期什么都不做等锁自然过期异步回调收到异常e ! null只记一条错误日志随后不再安排下一次续期锁会在剩余租期内自然过期第二种情况很危险。日志如果没接入监控你可能完全察觉不到锁已经进入“等待过期”状态业务线程还蒙在鼓里继续写数据。Redisson 的经典错误日志是ERROR Cant update lock xxx expire time线上务必把这个关键字挂进监控告警一旦出现就要立刻排查是网络问题还是 Redis 侧问题。还有一个隐藏点虽然续期定时任务和业务线程解耦但 Netty 的 HashedWheelTimer 毕竟跑在同一个 JVM 里。如果发生长时间 STW 的 Full GC定时器照样会被暂停续期照样可能延迟甚至错过。这个问题属于极端情况但面试时主动提出来会显得你想得比一般人深。3.3 主从切换、JVM 假死、时钟跳跃续期救不了的三类场景watchdog 只解决“持有者活着但业务没跑完”这一种情况。下面三类问题它无能为力这是 Redis 锁方案的先天边界第一类主从切换丢锁。客户端 A 在 master 上拿到锁master 还没来得及把写入同步给 slave 就宕机了slave 晋升为 master 后压根没有这把锁客户端 B 轻松拿到锁双写发生。Redisson 的单节点锁解决不了这个问题RedLock 算法就是为缓解这类问题设计的但它同样有争议做不到绝对安全。第二类持有者进程假死。线程卡死、JVM 假死、网络分区watchdog 停发续期锁过期被他人拿走但原来那个进程“缓过来”后并不知道锁已经丢了继续写共享资源。严格说这种场景需要 fence token 之类的令牌机制才能真正兜底纯靠过期时间判活本质上是脆弱的。第三类时钟跳跃。PEXPIRE依赖 Redis 服务器的时间一旦 Redis 所在机器 NTP 时间跳变TTL 计算就可能直接错乱。分布式系统里把“时间”当判活依据就得接受时钟带来的不确定性。把这些边界摆出来不是为了劝退不用 Redis 锁而是让你在设计和答辩时心里有数续期解决的是业务超时这类常规问题真正的脑裂级故障要靠架构层面的机制去应对不是一把锁能包圆的。4. 生产落地配置参数、代码模板与锁粒度经验4.1 lockWatchdogTimeout 怎么配才不出事Redisson 默认 30 秒绝大多数场景不用改。真正需要调的场景是业务平均耗时明显偏长比如批量任务经常跑 40 到 60 秒可以适当调大减少续期次数singleServerConfig: address: redis://127.0.0.1:6379 password: 123456 lockWatchdogTimeout: 60000调大之后有个连锁反应要清楚续期间隔也会变成 20 秒一次因为间隔是internalLockLeaseTime / 3代码里没有单独的“续期间隔”配置项它和租约时长是绑定的。理解了这层关系配置时才不会配错。相反我见过有人把lockWatchdogTimeout调到 30 分钟然后线上反馈“锁不释放”——持有者进程一崩所有等锁的线程要干等 30 分钟才能抢到锁。这个参数本质是在“续期频率”和“故障恢复速度”之间权衡千万别贪大。4.2 一套可以直接抄的 tryLock 代码模板放一段我在生产项目里沿用了很久的模板注释里都是踩过坑之后总结的Resource private RedissonClient redissonClient; public void processOrder(Long orderId) { String lockKey order:lock: orderId; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { // 等锁最多 3 秒拿不到直接失败避免无限阻塞拖垮调用方 // 注意这里没传 leaseTimewatchdog 正常工作默认租约 30 秒 locked lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { throw new BizException(系统繁忙请稍后重试); } // 业务逻辑就算跑 5 分钟锁也会被 watchdog 持续续期 doBiz(orderId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BizException(抢锁被中断, e); } finally { if (locked) { // 只有自己成功拿到锁才释放否则可能误删别人的锁 lock.unlock(); } } }这里有个 code review 时我反复强调的点finally 里必须先判断locked再 unlock。很多新人写成无脑 unlock一旦 tryLock 没抢到锁unlock 会走 Lua 脚本发现持有者不是自己虽然不会删错锁但会抛IllegalMonitorStateException把原本的业务异常都盖掉排查起来异常痛苦。4.3 锁粒度与业务拆分别把续期当设计目标续期机制解决的是“锁租期”的问题但很多线上事故其实是“锁粒度”问题换了个马甲锁 key 太粗比如所有订单共用一把锁全局串行化吞吐直接坍缩锁 key 太细且不均匀热 key 全集中在大客户上续期压力全压在一个 Redis 分片上业务里嵌套多个锁A 拿锁等 B、B 拿锁等 Awatchdog 再怎么续也续不出死锁我的经验是锁先按业务语义拆成订单锁、库存锁、优惠券锁能缩小到单条记录就缩小到单条记录长事务里避免嵌套拿锁。锁续期只是保命手段架构上真正要思考的是怎么减少“需要续这么久”本身。比如批量报表任务与其持锁 5 分钟不如拆批、分片、把单次执行时间压到 30 秒以内——就算 watchdog 临时出故障业务也大概率在原始租期内跑完不会裸奔太久。5. 常见问题排查与避坑实录5.1 高频问题速查表现象原因处理方式业务没跑完两个线程同时写数据tryLock 显式传了 leaseTimewatchdog 没启动不传 leaseTime或把 leaseTime 调到业务最坏耗时以上日志出现 Cant update lock expire time续期命令超时或 Redis 侧异常加监控告警检查 Redis 网络、连接池、慢查询锁到点不释放等待方全部卡死lockWatchdogTimeout 配得过大持有者假死不再续期恢复默认 30s或按故障恢复 SLA 调小unlock 抛 IllegalMonitorStateExceptionfinally 里无脑 unlock但根本没抢到锁用布尔 locked 变量标记先判断再解锁锁总是提前消失但没有任何报错业务线程被 kill、断网、JVM 假死watchdog 停止续期区分场景正常退出必须 unlock异常退出接受锁自动过期主从切换后丢锁导致双写master 未同步锁数据就宕机单节点锁无解评估 RedLock 或改用 ZooKeeper/etcd 方案5.2 我踩过的三个坑第一个坑就是“显式传 leaseTime”。我们有个批处理任务前任代码写了tryLock(5, 60, TimeUnit.SECONDS)业务最长能跑 3 分钟。上线一个月没事直到某天数据量大、执行时间超过 60 秒两台实例同时开跑同一个批任务产生了一批重复数据。排查两天才发现 watchdog 根本没工作。从那以后我定了个规矩code review 时凡是用 RLock 传了 leaseTime 的必须写注释说明理由否则一律改成不带 leaseTime 的重载。第二个坑是 Spring 代理失效导致锁根本没加上。我们把 RedissonClient 配置成 Bean 后某人写了个注解式分布式锁但加锁方法是被同类内部this调用的没走 Spring 代理注解完全不生效。排查顺序很重要先确认锁真的加上去了再怀疑 watchdog 的问题。锁都没加谈何续期。第三个坑是多个线程等同一把锁时的续期模型。Redisson 对同一个 key 的续期任务集中在一个 ExpirationEntry 里管理多线程等待同一个锁不会重复排多个定时任务续期行为是针对第一个持有者线程进行的。理解这个模型才能解释“为什么只有一条续期日志”的困惑也能在向队友解释时少费很多口舌。6. 面试应答节奏与一个加分细节6.1 四层递进的回答路径面试官问“Redis 锁续期机制”推荐按四层递进每层控制好时间第一层定义层面30 秒讲完。锁必须设过期时间防止死锁但过期时间小于业务执行时间就会导致锁提前失效、并发进入临界区所以需要一种机制持有者活着自动延长租期持有者挂掉自动停止续期。第二层实现层面1 分钟讲透。Redisson 用 watchdog 实现默认租约 30 秒每 10 秒执行一次续期脚本脚本先HEXISTS校验持有者标记uuid:threadId确认是自己才PEXPIRE延长 30 秒加锁、续期、释放全部用 Lua 保证原子性。第三层边界层面1 分钟讲全。显式传 leaseTime 时 watchdog 不启动续期失败会记Cant update lock expire time并停止续期主从切换丢锁、JVM 假死、时钟跳跃这些情况 watchdog 都救不了。第四层升华层面。如果面试官追问“让你自己设计一个支持续期的分布式锁怎么搞”给出方案Redis 里用 hash 存持有者标记和重入计数Lua 脚本完成加锁和续期客户端起一个定时线程每租约 1/3 时间续一次续期前必须校验身份。整套回答下来面试官会觉得你是真在线上写过而不是背过八股。6.2 主动聊 1/3 续期间隔的权衡很加分答完上面四层如果还有时间我建议主动聊一个细节为什么续期间隔是租约的 1/3而不是 1/10 或者 1/2。太短比如每 1 秒续一次Redis 请求量暴涨锁一多光续期流量就能把 Redis 打热太长比如每 28 秒续一次网络抖动或 GC 停顿很容易让锁在两次续期之间过期1/3 的余量是两边平衡的结果也符合“每执行三次续期业务时间才推进一个租约周期”的心智模型方便人肉推演最坏情况能把这个权衡讲清楚说明你不光会用 Redisson还理解它为什么这么设计。面试官就算再追问“换成你会怎么配”你也有根有据地聊。说完这些再分享几句我个人的体会。分布式锁的续期机制这几年被讲得太多了但每次复盘面试我都发现能真正讲透的候选人依然很少。问题不在于记不记得住 watchdog 这个词而在于有没有把它放进一条完整的因果链为什么需要过期时间过期时间与业务耗时的矛盾是什么续期怎么解决这个矛盾续期自身又有哪些边界。这条链想明白了面试官从哪个角度切入你都接得住。我现在做项目的基本原则是Redis 锁只解决并发互斥不解决业务时长。能用短事务解决的事绝不长持锁watchdog 续期是兜底而不是设计目标。真到了分钟级持锁、绝对安全优先的场景我会直接换 ZooKeeper 或 etcd 方案不在 Redis 续期上死磕。最后给个硬核建议打开 Redisson 源码里的RedissonLock.java把tryAcquireAsync、scheduleExpirationRenewal、renewExpirationAsync这三个方法从头到尾自己过一遍比背十篇博客都有用。看懂了这道面试题就再也不是背诵题了。