ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

一次 3.6 秒的 Full GC 让 Redisson 看门狗没续上锁:分布式锁的 4 个失效边界

一次 3.6 秒的 Full GC 让 Redisson 看门狗没续上锁:分布式锁的 4 个失效边界 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]: 租期毫秒默认 30000internalLockLeaseTime -- 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-cli里type出来是 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} RFutureRedissonLockEntry 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 秒 RFutureBoolean future renewExpirationAsync(threadId); future.onComplete((res, e) - { if (e ! null) { // 续期失败只打日志不做任何补偿 log.error(Cant 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/3默认 10 秒。这个比例给了两次重试机会——第一次续期失败还有第二次、第三次三次都失败锁才过期。设计上是合理的但前提是这三次续期能真正执行。续期任务跑在HashedWheelTimer上本质是 JVM 内的一个线程。GC 的 STW 会冻结它和冻结业务线程是一样的。续期失败没有任何补偿。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); } }对应的 SQLUPDATE 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:MaxGCPauseMillis200Full GC 从每天十几次降到一周 1-2 次加了续期失败告警匹配Cant update lock日志上线三个月触发过 2 次都是 Redis 主从切换期间唯一索引兜底上线后重复扣减笔数0三个可以自己想想的问题如果业务执行时间确实需要 5 分钟比如大批量导出你会用看门狗自动续期还是显式指定一个很长的leaseTime各自的风险是什么tryLock(waitTime, leaseTime, unit)三参数版本里waitTime和leaseTime应该怎么配合如果waitTime leaseTime会发生什么假设你们的场景不允许用数据库唯一索引兜底比如操作的是外部第三方接口除了分布式锁还有什么办法保证不重复执行如果你的服务也在用 Redisson 的看门狗建议先去 grep 一下有没有Cant update lock这行日志。有的话说明你已经在失效边界上走过了。
返回列表