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

发布时间:2026/9/23 8:59:33

一次 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这行日志。有的话说明你已经在失效边界上走过了。
延伸阅读

更多相关文章

2026/9/20 0:26:12

Android开发实战:基于Eclipse的完整项目构建与核心技能解析

1. 项目概述与核心价值 最近几年,移动开发的热度有所起伏,但Android开发作为移动生态的基石,其市场需求依然稳定且庞大。很多朋友,无论是刚毕业的学生还是希望转型的开发者,都面临一个共同的问题:理论知识学…

2026/9/23 8:57:44

AI论文降重技巧:如何有效规避查重系统检测

1. 项目背景与核心痛点去年帮学弟改论文时发现个有趣现象:他用AI辅助生成的文献综述部分,在知网查重时被标红率高达80%。这并非传统意义上的抄袭,而是AI生成内容特有的"机器味"被查重系统识别为异常文本。这种现象在2023年后变得尤…

2026/9/23 8:57:44

开源+本地化部署实战:企业AI落地的关键路径与避坑指南

直接说结论:2026年这个时间节点,技术选型里最值得下注的不是某个具体框架,而是“开源本地化部署”这套组合拳。我自己过去大半年做的项目,从最初评估到最后落地,全部围绕这个核心展开。这篇文章不聊虚的,只…

2026/9/23 8:57:44

Oracle Hyperion合并报表国产替换怎么选?2026信创选型全攻略

随着 Oracle 海波龙(Hyperion)面临停服风险与信创合规要求,国产 EPM 合并报表产品已进入成熟替代期。海波龙停服不是传闻,而是已经落地的事实Oracle 早在 2021 年 12 月就已停止为该版本发布新的修复和安全补丁,11.1.2…

2026/9/23 8:57:44

身份证验证接口开发指南:原理、实践与优化

1. 身份证验证接口核心价值与应用场景身份证验证接口作为现代互联网服务的基础设施,其核心价值在于通过标准化方式实现"姓名-身份证号"一致性校验。与人工审核相比,这种自动化验证方式将原本需要几分钟甚至几小时的流程缩短至毫秒级&#xff0…

2026/9/23 8:57:44

三星Note2 N7100线刷救砖全攻略:Odin刷机教程与常见问题解决

1. 为什么现在还有人折腾三星Note2 N7100线刷三星Galaxy Note2 N7100是2012年发布的机型,放到现在已经有十多年历史。很多人觉得这机器早该进博物馆了,但实际情况是,二手市场流通量依然不小,而且有一批固定用户群体在持续使用——…

2026/9/23 8:52:44

Chip Genius原理与实战:U盘主控芯片识别技术解析

1. Chip Genius不是“U盘身份证”,而是主控芯片的X光机很多人第一次听说Chip Genius,是在U盘买回来发现容量虚标、速度慢得离谱、频繁掉盘的时候。朋友甩来一句:“你这U盘是不是扩容盘?拿Chip Genius扫一下就知道了。”——听起来…

2026/9/22 10:02:42

GAMP 5 基于风险的计算机化系统验证:软件分类与审计追踪实践

简介:《A Risk-Based Approach to Compliant GxP Computerized Systems》即业内熟知的GAMP 5指南,面向制药企业质量与IT合规人员、验证工程师及计算机化系统管理者,用于解决GxP法规环境下系统合规性难以科学落地的问题。文档以风险管理为主线…

2026/9/22 9:07:39

安全托管MSSP实战:从静态防御到人机协同的攻防运营与应急响应

简介:这份PPT围绕互联网业务安全托管服务展开,面向企业安全负责人、IT运维人员及关注MSSP/MSS选型的读者,重点回应传统安全过度依赖人工、碎片化静态防御难以对抗产业化攻击等痛点。资源共1个pptx文件,包体约30.63MB,以…

2026/9/23 0:01:54

3个实战技巧搞定形式英语:从看教程到跑通性能优化

3个实战技巧搞定形式英语:从看教程到跑通性能优化 看了一堆教程还是不会写项目?别慌,这种“眼高手低”的困境在开发者圈子里太常见了。很多人以为卡点在语法,其实真正拦路虎是缺乏将知识点串联成完整链路的能力。今天咱们不聊虚的,直接拿【形式英语】这…

2026/9/22 16:34:32

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行,Type-C接口算是典型的“看着简单,做起来全坑”的东西。光引脚就24个,高低速信号、电源、控制线全部塞在一个小小的连接器里,如果PCB布局不做规划,打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/22 20:01:30

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/22 13:25:41

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
咨询二维码