title: 看门狗每 10 秒续一次期,业务却跑了 40 秒:Redisson 锁续期源码里藏着的 2 个默认陷阱
date: 2026-08-22
category: 分布式锁
tags: [Java, 后端, Redis, Redisson, 并发编程]
我们订单服务用 Redisson 做分布式锁,锁一段「扣减库存 + 生成订单」的逻辑。某天凌晨告警:库存出现了超卖,同一件商品被下了两单。查日志发现,两个线程都拿到了同一把锁,几乎同时进入临界区。翻到 Redisson 源码才明白,问题出在「看门狗」和我们写锁的方式上——这个坑我们踩了两次才彻底搞清。
事故现场:两把锁同时生效
那段代码最早是这样写的:
RLock lock = redissonClient.getLock("stock:" + productId); // 显式指定 30 秒过期 lock.lock(30, TimeUnit.SECONDS); try { // 扣库存 + 建订单,偶发需要 35~50 秒 orderService.create(productId); } finally { lock.unlock(); }看起来没毛病:30 秒过期,业务跑完释放。但orderService.create在大促时偶发要 40 秒,超过了 30 秒。按我们的理解,Redisson 有「看门狗」会自动续期,锁不会提前失效——可事实是,业务跑到 30 秒时锁真的过期了,另一个线程拿到了锁。
看门狗源码:它只在「不指定 leaseTime」时才工作
翻 Redisson 的RedissonLock#lock()和scheduleExpirationRenewal,关键在这里:
// RedissonLock 内部续期调度(简化) private void scheduleExpirationRenewal(long threadId) { // 只有 leaseTime == -1(即未显式指定)才进入看门狗逻辑 if (this.expirationRenewalMap.containsKey(threadId)) { return; } Timeout task = commandExecutor.getConnectionManager() .newTimeout(new TimerTask() { @Override public void run(Timeout timeout) { // 每 internalLockLeaseTime / 3 续一次期 RFuture<Boolean> future = renewExpirationAsync(threadId); future.onComplete((res, e) -> { if (res) { // 递归续期,默认 30s 租约,每 10s 续一次 scheduleExpirationRenewal(threadId); } }); } }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); }逐行讲三个关键点:
- 看门狗的触发前提是
leaseTime == -1。一旦你像我上面那样写了lock(30, SECONDS),Redisson 认为「你已经明确知道要锁多久」,就不再启动看门狗,锁就老老实实 30 秒后过期。 - 默认租约
internalLockLeaseTime是 30 秒,看门狗每30 / 3 = 10秒续一次期,靠 Lua 脚本把过期时间重新刷成 30 秒。 - 续期也是用 Lua 保证原子:
pexpire只对「还持有锁」的 key 生效,如果锁已被别人拿走,续期静默失败,不会把别人的锁续上。
所以正确用法是不指定 leaseTime,让它走看门狗:
RLock lock = redissonClient.getLock("stock:" + productId); // 不传 leaseTime,默认走看门狗,每 10s 自动续期 lock.lock(); try { orderService.create(productId); // 跑多久都行,锁不会提前掉 } finally { // 释放时 Redisson 会取消看门狗定时任务 lock.unlock(); }第二个陷阱:unlock 不在 finally 里,看门狗线程永远不取消
我们的第二版又踩了另一个坑:有同学把unlock()写在业务逻辑后面,没包在finally里。业务抛异常时unlock没执行,看门狗还在每 10 秒续期,这把锁成了永久锁,直到 Redis key 手动删掉。
Redisson 在unlock()内部会调用cancelExpirationRenewal取消续期任务:
// RedissonLock#unlock 内部(简化) public void unlock() { // 释放锁的 Lua:del key 并发布解锁消息 RFuture<Boolean> future = unlockAsync(); future.onComplete((opStatus, e) -> { // 关键:取消看门狗的定时续期 cancelExpirationRenewal(); }); }所以unlock必须在finally块里,确保无论业务成功还是抛异常都能执行,续期任务才能被正确取消。我们后来统一封装了一层模板方法:
public <T> T withLock(String key, Supplier<T> action) { RLock lock = redissonClient.getLock(key); lock.lock(); // 走看门狗 try { return action.get(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }isHeldByCurrentThread()这个判断很重要:如果锁已经因为超时(显式 leaseTime 场景)被 Redis 清掉,再unlock会抛IllegalMonitorState异常。加了判断,超时不释放的锁就不会在 finally 里再炸一次。
第三个容易被忽略的点:tryLock 的等待与续期
很多人用tryLock(waitTime, leaseTime, unit)来「抢不到就放弃」,这里面的坑是:只要传了 leaseTime,看门狗同样不工作。
// 想要「抢不到等 3 秒、抢到后自动续期」要分两步写 boolean locked = lock.tryLock(3, TimeUnit.SECONDS); // 第一参是等待时间,不传 leaseTime if (locked) { try { orderService.create(productId); // 看门狗生效,不怕业务慢 } finally { lock.unlock(); } }注意tryLock(3, SECONDS)这种单参写法和tryLock(3, 30, SECONDS)完全不同:前者只设等待时间、leaseTime 仍是 -1(看门狗生效),后者设了等待时间也设了 leaseTime=30(看门狗失效)。少写一个参数的差别,就是锁会不会提前掉。
看门狗续期失败的边界
看门狗不是万能的。它依赖「续期 Lua 能成功执行」,如果那 10 秒窗口里 Redis 主节点宕机、或网络抖动导致续期失败,锁还是会过期。我们遇到过一次 Redis 网络分区,续期连续两次失败,锁在 30 秒后过期,业务又出现了短暂的并发重入。
这也是为什么 Redisson 官方反复强调:分布式锁在极端网络异常下做不到绝对互斥,它只能把「误释放」的概率压到很低,不能完全消除。如果你要的是「绝对不能重复执行」,得在业务层再补一道幂等(比如用请求唯一 ID 去重表),而不是把所有信任都押在锁上。
锁的粒度,比锁的实现更影响吞吐
踩完超卖坑之后我们才意识到,锁的 key 设计比「用哪种锁」更影响性能。最初我们getLock("createOrder")锁整个下单入口,结果不同用户的下单请求被串行化,吞吐量掉了一半。改成getLock("stock:" + productId)只锁单个商品后,不同商品之间完全不互斥。进一步我们把一个大库存拆成多个子库存行(比如按仓库维度),热点商品的锁竞争又降了一截。锁的命名空间要精确到「真正会冲突的资源」,而不是笼统的业务方法名——这一条对 Redisson、Redis 手写锁、ZooKeeper 锁都成立。锁太粗,等于自己给自己做限流;锁太细,又可能出现「该互斥的没互斥」,这个度要靠压测数据来定,不是拍脑袋。我们曾把一个粗粒度锁(锁整个商家)拆成细粒度(锁单个商品),下单接口 P99 从 320ms 降到 140ms,这个数据比任何理论都直观——锁粒度优化带来的吞吐提升,经常比换锁实现更明显。
几个方案对比
| 写法 | 看门狗是否生效 | 风险 |
|---|---|---|
lock()无参 | 生效,每 10s 续期 | 业务永久卡住会一直续,需 finally 释放 |
lock(30, SECONDS) | 不生效 | 业务超 30s 锁提前掉,并发重入 |
tryLock(3, 30, SECONDS) | 不生效 | 同上,且抢不到直接返回 |
| 锁不在 finally 释放 | 续期任务不取消 | 成为永久锁,直到手动清 |
我的取舍很明确:绝大多数场景用无参lock()+finally释放,把续期交给看门狗;只有「业务执行时间有硬上限、超时就该放弃」的场景才显式指定 leaseTime,并且接受它不会自动续期、需要自己控制业务耗时。把锁当「长期持有」用的同学,务必要配好finally和幂等兜底,别把看门狗当免死金牌。
复盘数字
那次超卖发生在凌晨 02:14,持续约 6 分钟,涉及 3 个商品共 11 笔重复下单,资损约 2400 元。修复上线后我们用压测复现:单热点商品 500 并发、业务耗时随机 20~50 秒,连续跑 30 分钟零重复下单;同时监控看门狗续期日志,每把锁稳定每 10 秒打印一次renew expiration,确认续期链路正常。后续我们又把锁粒度从「整个下单方法」细化到「单商品库存行」,热点商品的锁竞争下降了约 70%。
思考题
看门狗解决了「业务慢导致锁提前过期」,但引入了「业务永久卡死时锁也永久续期」。如果你的临界区里有调用第三方支付这种不可控耗时的操作,你会怎么设计锁的持有策略和超时?