一次 3.6 秒的 Full GC 让 Redisson 看门狗没续上锁:分布式锁的 4 个失效边界
2026/8/5 14:27:38 网站建设 项目流程

title: 一次 3.6 秒的 Full GC 让 Redisson 看门狗没续上锁:分布式锁的 4 个失效边界
tags: [Redisson, 分布式锁, Redis, JVM, Java]
category: 后端


对账对出来 47 笔重复扣减

我们做的是一个账户系统,扣款接口用 Redisson 的RLock保证同一账户串行。这套代码跑了大半年没出过事,直到某个月末对账,财务同事发过来一个表格:47 笔重复扣减,涉及 31 个账户,总金额 8 万多。

第一反应是代码里漏了加锁。翻了一遍,锁加得规规矩矩:

RLock lock = redissonClient.getLock("account:lock:" + accountId); lock.lock(); try { doDeduct(accountId, amount); } finally { lock.unlock(); }

lock()无参调用,走的是看门狗(watchdog)自动续期,理论上只要业务没执行完,锁就不会过期。看起来无懈可击。

问题出在 GC 日志上。把那 47 笔的时间戳和各实例的 GC 日志对了一遍,全部命中在 Full GC 期间——最长的一次 STW 是 3.6 秒。

看门狗的续期任务是跑在 Netty 的时间轮里的普通任务,STW 期间它和业务线程一样被冻结。锁的默认租期是 30 秒,续期周期是 10 秒。单次 3.6 秒的 STW 确实不至于让锁过期,但那台机器当时处于「Full GC 密集期」,20 秒内发生了 4 次 Full GC,累计 STW 11 秒多。加上续期任务本身要走一次 Redis 网络 IO,而 Redis 那会儿也因为大 key 删除出现了 200ms 级别的阻塞——多个因素叠在一起,续期窗口被吃穿了。

这篇把那次排查过程、Redisson 加锁链路的源码,以及分布式锁真正的失效边界写清楚。环境是 Redisson 3.17.7、Redis 6.2.6 主从 + Sentinel、JDK 11、Spring Boot 2.7.5。

先把 Redisson 的加锁链路拆开

很多人用 Redisson 只知道lock()/unlock(),出问题就不知道从哪查。把加锁的 Lua 脚本读一遍,很多疑惑会自然消失。

RedissonLock#tryLockInnerAsync里的脚本(3.17.x 版本,我做了注释):

-- KEYS[1]: 锁的 key,例如 account:lock:10086 -- ARGV[1]: 租期,毫秒,默认 30000(internalLockLeaseTime) -- ARGV[2]: 锁的持有者标识,格式为 UUID:threadId -- 分支一:锁不存在 if (redis.call('exists', KEYS[1]) == 0) then -- 用 Hash 结构,field 是持有者,value 是重入次数 redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; -- 返回 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]);

三个关键设计点:

为什么用 Hash 而不是 String。String 只能存「谁持有」,存不了「重入了几次」。Hash 的 field 存持有者、value 存计数,一次hincrby就完成了重入。这也解释了为什么 Redisson 的锁 key 在redis-clitype出来是 hash。

持有者标识为什么是UUID:threadId只用 threadId 不行——不同 JVM 里线程 ID 会重复。只用 UUID 也不行——同一个 JVM 里不同线程会互相当成同一个持有者,重入判断就错了。UUID由 Redisson 客户端实例启动时生成,拼上threadId才能唯一标识一个「进程内的线程」。

返回值的语义。返回nil是加锁成功,返回一个数字(PTTL)是失败且告诉你还要等多久。这个数字很重要,Redisson 用它来决定后续的等待策略——不是盲目自旋,而是订阅解锁频道 + 带超时的等待。

未获取到锁的等待逻辑在RedissonLock#lock里:

// 简化后的核心逻辑 long threadId = Thread.currentThread().getId(); Long ttl = tryAcquire(-1, leaseTime, unit, threadId); if (ttl == null) { return; // 拿到锁了,直接返回 } // 订阅这把锁的解锁通知频道:redisson_lock__channel:{lockName} RFuture<RedissonLockEntry> future = subscribe(threadId); commandExecutor.syncSubscription(future); try { while (true) { ttl = tryAcquire(-1, leaseTime, unit, threadId); if (ttl == null) { break; } if (ttl >= 0) { // 最多等 ttl 毫秒;期间如果收到解锁通知,Semaphore 会被 release 提前唤醒 getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS); } else { getEntry(threadId).getLatch().acquire(); } } } finally { unsubscribe(future, threadId); }

这段比很多人手写的 Redis 锁高明的地方在于:没有自旋。手写版常见的写法是while (!setnx()) { Thread.sleep(50); },50ms 一次轮询,100 个线程等锁就是每秒 2000 次 Redis 请求。Redisson 用 Pub/Sub 把「等待」变成了事件驱动,锁释放时才会唤醒一个等待者。

解锁脚本里对应的就是那个publish

-- 不是自己的锁,不能解,返回 nil 让客户端抛异常 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;

hexists那一行就是防误删。手写锁最常见的 bug——A 的锁超时了,B 拿到锁,A 执行完DEL把 B 的锁删了——在这里被 Lua 的原子性挡住了。

看门狗是怎么工作的,以及它什么时候会失灵

看门狗的入口在tryAcquireAsync:只有leaseTime == -1(也就是调lock()不传参)时才启动。如果你写的是lock(30, TimeUnit.SECONDS),看门狗根本不会启动,30 秒后锁必然释放。

// RedissonBaseLock#scheduleExpirationRenewal 的核心 private void renewExpiration() { ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName()); if (ee == null) { return; } // 用 Netty 的 HashedWheelTimer 起一个延迟任务 Timeout task = commandExecutor.getConnectionManager().newTimeout(timeout -> { ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName()); if (ent == null) { return; } Long threadId = ent.getFirstThreadId(); if (threadId == null) { return; } // 发一次 pexpire 把租期重置回 30 秒 RFuture<Boolean> future = renewExpirationAsync(threadId); future.onComplete((res, e) -> { if (e != null) { // 续期失败只打日志,不做任何补偿 log.error("Can't update lock " + getRawName() + " expiration", e); EXPIRATION_RENEWAL_MAP.remove(getEntryName()); return; } if (res) { renewExpiration(); // 续期成功,递归调度下一次 } else { cancelExpirationRenewal(null); } }); }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); // 30000 / 3 = 10000ms ee.setTimeout(task); }

三个必须知道的事实:

  1. 续期周期是租期的 1/3,默认 10 秒。这个比例给了两次重试机会——第一次续期失败还有第二次、第三次,三次都失败锁才过期。设计上是合理的,但前提是这三次续期能真正执行。
  2. 续期任务跑在HashedWheelTimer,本质是 JVM 内的一个线程。GC 的 STW 会冻结它,和冻结业务线程是一样的。
  3. 续期失败没有任何补偿log.error之后直接return,业务线程完全不知道自己的锁已经没了,还在继续操作数据。这是我认为 Redisson 最需要注意的一点——锁失效对业务是静默的

回到我们那次事故:GC 密集期 + Redis 抖动,导致连续多次续期失败或超时,锁在 Redis 侧被pexpire到期删除。另一台机器的线程拿到了锁,两边同时执行扣减。

分布式锁真正的 4 个失效边界

排查完那次事故,我们内部整理了一份「Redisson 不能保证什么」的清单。

失效边界触发场景现象我们的应对
GC / STW 超过租期Full GC、大对象分配、Safepoint 卡顿锁被别人抢走,两个线程并发执行缩短临界区 + 业务层幂等兜底
主从切换丢锁主节点写入锁后未同步就宕机,Sentinel 选新主新主上没有这把锁,别人能加锁成功关键路径改用 DB 唯一索引/乐观锁
客户端与 Redis 网络分区网络抖动导致续期请求超时同上监控续期失败日志并告警
业务执行时间不可控慢 SQL、外部 HTTP 调用无超时临界区变长,放大前三种风险所有外部调用强制设超时

主从切换那条值得多说两句。Redis 的主从复制是异步的,SET返回成功不代表从库已经收到。如果这一瞬间主库挂了,Sentinel 把从库提升为主,那把锁就凭空消失了。这是 Redis 分布式锁的架构级缺陷,不是 Redisson 的实现问题。

Redisson 提供了RedissonRedLock(多个独立 Redis 实例,过半加锁成功才算成功)来缓解。但 RedLock 的争议很大——Martin Kleppmann 在 2016 年那篇《How to do distributed locking》里指出,RedLock 依赖各节点时钟的相对准确,且同样无法解决 GC 停顿导致的锁失效。

我不建议在金额相关的场景把安全性完全押在分布式锁上。我们最后的做法是双保险:Redisson 锁负责减少并发冲突(性能优化),数据库层面用唯一索引 + 状态机负责正确性(安全兜底)。

@Transactional(rollbackFor = Exception.class) public void deduct(Long accountId, String bizNo, BigDecimal amount) { // 第一道:业务流水表的 biz_no 上有唯一索引 // 重复请求会直接抛 DuplicateKeyException,从根上杜绝重复扣减 int inserted = txnMapper.insertIgnore(new AccountTxn(accountId, bizNo, amount)); if (inserted == 0) { throw new BizException("重复请求,bizNo=" + bizNo); } // 第二道:用带条件的 UPDATE 做原子扣减,不做「先查后改」 // balance >= #{amount} 这个条件由数据库在行锁内判断,不会有并发窗口 int updated = accountMapper.deductIfEnough(accountId, amount); if (updated == 0) { throw new BizException("余额不足,accountId=" + accountId); } }

对应的 SQL:

UPDATE account SET balance = balance - #{amount}, version = version + 1, update_time = NOW() WHERE id = #{accountId} AND balance >= #{amount}

有了这两道,就算分布式锁完全失效,也只会是「重复请求被 DB 拒绝」,不会变成「重复扣钱」。锁的作用退化成减少 DB 层的冲突和异常日志——这个定位我觉得才是正确的。

顺手改掉的三个用法问题

复盘时把项目里所有RLock的用法扫了一遍,还揪出三个问题:

问题一:lock()放在 try 外面,unlock()放在 finally 里。

// 有问题的写法 RLock lock = redissonClient.getLock(key); try { lock.lock(); // 如果这里抛异常(比如 Redis 连不上) doBusiness(); } finally { lock.unlock(); // 这里会抛 IllegalMonitorStateException,掩盖原始异常 }

正确的写法是lock()在 try 之前,或者在 finally 里判断isHeldByCurrentThread()

问题二:用tryLock()不判断返回值。有两处代码调了tryLock(3, TimeUnit.SECONDS)但没接返回值,等于没加锁。这种在 Code Review 里很容易漏掉,我们后来加了一条 SpotBugs 规则强制检查。

问题三:锁粒度过粗。有个批量操作的接口,锁的 key 是固定字符串"batch:import",等于全局串行。压测时这个接口的吞吐上不去,改成按业务维度分片("batch:import:" + shardId)后 TPS 从 15 提到 210。

改完之后的数据

  • 临界区里的外部 HTTP 调用全部加了 2 秒超时,最长临界区从 4.2 秒降到 800 毫秒
  • JVM 参数从 CMS 换成 G1,-XX:MaxGCPauseMillis=200,Full GC 从每天十几次降到一周 1-2 次
  • 加了续期失败告警(匹配Can't update lock日志),上线三个月触发过 2 次,都是 Redis 主从切换期间
  • 唯一索引兜底上线后,重复扣减笔数:0

三个可以自己想想的问题

  1. 如果业务执行时间确实需要 5 分钟(比如大批量导出),你会用看门狗自动续期,还是显式指定一个很长的leaseTime?各自的风险是什么?
  2. tryLock(waitTime, leaseTime, unit)三参数版本里,waitTimeleaseTime应该怎么配合?如果waitTime > leaseTime会发生什么?
  3. 假设你们的场景不允许用数据库唯一索引兜底(比如操作的是外部第三方接口),除了分布式锁还有什么办法保证不重复执行?

如果你的服务也在用 Redisson 的看门狗,建议先去 grep 一下有没有Can't update lock这行日志。有的话,说明你已经在失效边界上走过了。

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

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

立即咨询