前几天帮团队做面试复盘,十个候选人里七个能背出 Redisson 的 watchdog 续期机制,可往下追问就露馅了:watchdog 在什么条件下启动?为什么是每 10 秒续一次?传了 leaseTime 它还会续吗?能答到位的不到两个。
这篇文章不聊虚的,直接从源码和 Lua 脚本层面把 Redis 分布式锁的续期机制拆干净。我会把 Redisson 的续期定时任务、锁在 Redis 里的数据结构、面试官最爱追的几个隐藏坑,以及我在生产环境踩过的三个真实案例全部整理出来。不管你是准备面试,还是正在排查线上锁提前失效、双写、死等问题,这篇都值得存下来反复翻。
1. 先把分布式锁的“为什么”说透:续期问题到底从哪来
1.1 一条 SET 指令搞定的锁,到底缺了啥
Redis 分布式锁最基础的实现就是一条命令:
SET product:123:lock "uuid-20240501" NX EX 30NX:key 不存在才写入,保证互斥EX 30:30 秒自动过期,防止持有者崩溃后锁变成“死锁”
很多人的理解到这里就停了,以为“加了过期时间就万事大吉”。但这里有个思维盲区:过期时间既是兜底保险,也是驱逐令。锁在持有者异常崩溃后能自动释放,靠的就是过期;可一旦业务正常执行的时间超过了这个过期时间,锁会在业务还没跑完时就被 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.java(3.x 简化版) private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) { if (leaseTime != -1) { // 指定了租约时间:直接用你给的过期时间拿锁,不启动 watchdog return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG); } // 没指定租约时间:用 lockWatchdogTimeout(默认 30000ms)拿锁 RFuture<Long> 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 { RFuture<Boolean> future = renewExpirationAsync(threadId); future.onComplete((res, e) -> { if (e != null) { log.error("Can't 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 秒;不是就返回 0,watchdog 停止续期。整个操作一个原子脚本完成,从根上杜绝了“检查时锁还在、续期时锁已经换了主人”的竞争窗口。
释放锁的脚本同样值得看,许多人只背了“释放前要校验持有者”,却不知道校验和删除是同一个原子脚本完成的:
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():没传 leaseTime,watchdog 正常工作tryLock(waitTime, TimeUnit):没传 leaseTime,watchdog 正常工作lock(leaseTime, TimeUnit):传了 leaseTime,锁到期就到期,不续tryLock(waitTime, leaseTime, TimeUnit):传了 leaseTime,锁到期就到期,不续
原因就在 2.1 的源码分支:只要leaseTime != -1,Redisson 就用你给的固定租约去拿锁,后续根本不会调用scheduleExpirationRenewal。
生产环境里我见过太多这种写法:
boolean ok = lock.tryLock(5, 30, TimeUnit.SECONDS); // 业务逻辑跑了 2 分钟 // 锁在第 30 秒就已经自动过期,另一台机器堂而皇之进来了这个 bug 极其隐蔽,因为 tryLock 本身执行顺利,业务跑完还能正常 unlock(unlock 脚本发现持有者标记还在就正常删库了),日志里没有任何异常,只有并发测试才能真正暴露。所以面试被问到“watchdog 什么时候不生效”,第一时间答出“显式传 leaseTime 就不会续”,你已经超过一大半候选人了。
3.2 续期失败会怎样?线上要盯什么日志
watchdog 续期是异步的,网络抖动、Redis 超时、连接池打满都会让续期请求失败。看源码里的onComplete分支,失败大致有两种命运:
- 续期脚本返回 0:锁已经不归自己,停止续期,什么都不做,等锁自然过期
- 异步回调收到异常(
e != null):只记一条错误日志,随后不再安排下一次续期,锁会在剩余租期内自然过期
第二种情况很危险。日志如果没接入监控,你可能完全察觉不到锁已经进入“等待过期”状态,业务线程还蒙在鼓里继续写数据。Redisson 的经典错误日志是:
ERROR Can't 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 秒,拿不到直接失败,避免无限阻塞拖垮调用方 // 注意这里没传 leaseTime,watchdog 正常工作,默认租约 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 拿锁等 A,watchdog 再怎么续也续不出死锁
我的经验是:锁先按业务语义拆成订单锁、库存锁、优惠券锁,能缩小到单条记录就缩小到单条记录,长事务里避免嵌套拿锁。锁续期只是保命手段,架构上真正要思考的是怎么减少“需要续这么久”本身。比如批量报表任务,与其持锁 5 分钟,不如拆批、分片、把单次执行时间压到 30 秒以内——就算 watchdog 临时出故障,业务也大概率在原始租期内跑完,不会裸奔太久。
5. 常见问题排查与避坑实录
5.1 高频问题速查表
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 业务没跑完,两个线程同时写数据 | tryLock 显式传了 leaseTime,watchdog 没启动 | 不传 leaseTime,或把 leaseTime 调到业务最坏耗时以上 |
| 日志出现 Can't update lock expire time | 续期命令超时或 Redis 侧异常 | 加监控告警,检查 Redis 网络、连接池、慢查询 |
| 锁到点不释放,等待方全部卡死 | lockWatchdogTimeout 配得过大,持有者假死不再续期 | 恢复默认 30s,或按故障恢复 SLA 调小 |
| unlock 抛 IllegalMonitorStateException | finally 里无脑 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 不启动;续期失败会记Can't 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这三个方法从头到尾自己过一遍,比背十篇博客都有用。看懂了,这道面试题就再也不是背诵题了。