☰
Redis分布式锁续期机制全解析:Redisson Watchdog源码与避坑指南
2026/10/5 10:40:21 网站建设 项目流程

前几天帮团队做面试复盘,十个候选人里七个能背出 Redisson 的 watchdog 续期机制,可往下追问就露馅了:watchdog 在什么条件下启动?为什么是每 10 秒续一次?传了 leaseTime 它还会续吗?能答到位的不到两个。

这篇文章不聊虚的,直接从源码和 Lua 脚本层面把 Redis 分布式锁的续期机制拆干净。我会把 Redisson 的续期定时任务、锁在 Redis 里的数据结构、面试官最爱追的几个隐藏坑,以及我在生产环境踩过的三个真实案例全部整理出来。不管你是准备面试,还是正在排查线上锁提前失效、双写、死等问题,这篇都值得存下来反复翻。

1. 先把分布式锁的“为什么”说透:续期问题到底从哪来

1.1 一条 SET 指令搞定的锁,到底缺了啥

Redis 分布式锁最基础的实现就是一条命令:

SET product:123:lock "uuid-20240501" NX EX 30
  • NX:key 不存在才写入,保证互斥
  • EX 30:30 秒自动过期,防止持有者崩溃后锁变成“死锁”

很多人的理解到这里就停了,以为“加了过期时间就万事大吉”。但这里有个思维盲区:过期时间既是兜底保险,也是驱逐令。锁在持有者异常崩溃后能自动释放,靠的就是过期;可一旦业务正常执行的时间超过了这个过期时间,锁会在业务还没跑完时就被 Redis 删掉,其他线程立刻就能拿锁进来。两个线程同时进入临界区,分布式锁形同虚设。这个矛盾,是后面所有续期机制的出发点。

1.2 过期时间与业务超时之间的天然矛盾

假设业务是库存扣减,单次执行 100ms,锁过期设 30 秒绰绰有余。但换成定时报表、批量对账、Excel 导出这种动不动跑几十秒甚至几分钟的任务呢?

  • 过期设 3 秒:业务没跑完锁就没了,双写风险直接拉满
  • 过期设 5 分钟:持有者真的崩溃时,其他线程要空等 5 分钟才能抢到锁,故障恢复速度太差
  • 过期设成“业务最坏耗时”:逻辑上说得通,但最坏耗时根本估不准,而且业务迭代过程中可能越来越慢,今天设 5 分钟够用,下个月就超了

续期的思路本质上就是把“固定过期”改成“按需续租”:锁的初始租期不用太长,持有者活着就不断续;持有者死了(崩溃、断网、进程被杀),没人续,锁自己就过期了。这样既避免了业务没跑完锁先没了的双写问题,也保证异常场景下锁能快速自动释放。

1.3 续期的核心逻辑:先确认归属,再延长租期

续期不是无脑PEXPIRE一把梭。如果一把锁已经被释放、被别的线程抢走了,你还用旧线程的身份去续期,等于给别人的锁加命,这可比不续期严重多了。

所以 Redisson 的续期动作拆开其实是两步:

  1. 校验锁当前持有者还是不是自己(通过HEXISTS检查持有者标记)
  2. 确认是自己,才执行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 抛 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 不启动;续期失败会记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这三个方法从头到尾自己过一遍,比背十篇博客都有用。看懂了,这道面试题就再也不是背诵题了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询