低概率、高危害:一次 20 秒的 GC 停顿,如何让 Redis 分布式锁形同虚设
2026/9/6 6:04:37 网站建设 项目流程

说实话,这个点我一开始也没当回事。

直到有次线上半夜告警:同一笔订单被两个线程同时处理了。排查一圈,最后定位到一个让我后背发凉的原因——持有 Redis 分布式锁的线程,正好撞上了一次 Full GC,看门狗没来得及续期,锁悄无声息地过期了,另一个实例把锁抢走了。

这事儿在面试里也特别容易被追问。今天就把 Redis 锁的原理、GC 怎么把它搞崩、以及它到底有多容易发生,从头到尾拆一遍。

先说结论,省得你划走

GC 停顿确实能让 Redis 锁的看门狗续期失败,这是分布式锁圈子里一个经典的理论弱点(Martin Kleppmann 怼 Redlock 的核心论点之一)。

但它是个低概率、高危害的尾部风险:在调优良好的现代 Java 服务里,平时基本碰不到;一旦碰到,后果是“两个线程同时拿到锁”,直接数据错乱。

划重点:Redlock(红锁)也救不了它。后面会解释为什么。

Redis 分布式锁到底是怎么工作的

要搞懂“为什么会被 GC 搞崩”,得先知道锁长什么样。

最基础的加锁,就是一条命令:

SET lock:order:1001"UUID:线程ID"NX PX30000
  • NX:不存在才设置,保证互斥;
  • PX 30000:30 秒自动过期,防死锁;
  • UUID:线程ID作为 value:解锁时先比对 value,确认是自己的锁才删,避免误删别人的锁。

解锁必须用 Lua 脚本,把“判断 value + 删除”打包成原子操作:

ifredis.call('get',KEYS[1])==ARGV[1]thenreturnredis.call('del',KEYS[1])elsereturn0end

到这里,一个“能用的”锁就有了。但问题来了。

整体流程可以用一张图串起来:

客户端请求加锁

SET lock NX PX 30000

是否设置成功

获得锁,执行业务

锁已被占用,等待重试

业务执行完毕

Lua 脚本校验 value

value 是否匹配

DEL 删除锁

锁已过期或非本人持有,不删除

释放锁完成

锁过期了,业务还没跑完怎么办

假设锁 30 秒过期,可你的业务要跑 40 秒。第 30 秒锁被 Redis 自动删了,别的服务进来,两个线程同时操作同一份数据——前面的互斥全白干了。

Redisson 的解法是看门狗(Watchdog)

它的逻辑很直白:你拿锁成功后,后台起一个定时任务,每隔一段时间就给锁"续命",把过期时间重新刷回 30 秒,直到你主动释放锁。

敲黑板,Redisson 看门狗有三个关键点,面试必考:

  • 默认锁租约 30 秒,每10 秒(租约的 1/3)续期一次;
  • 续期是用Netty 定时任务发的 Lua 脚本,重新PEXPIRE
  • 只有你不传 leaseTime 时才生效。你要是写了lock.lock(10, TimeUnit.SECONDS),看门狗直接罢工,10 秒一到锁必定释放,不管你业务跑没跑完。

看门狗的工作机制如下:

Redis看门狗线程业务线程Redis看门狗线程业务线程loop[每 10 秒]加锁成功(TTL=30s)启动定时续期任务PEXPIRE 刷新 TTL=30s续期成功业务完成,释放锁停止续期任务

GC 是怎么把看门狗搞瘫的

看门狗本质上是 JVM 里的一个定时任务线程。它要干活,前提是你这台机器的 JVM 得“活着”且“能调度”。

而 Full GC 会触发STW(Stop-The-World)停顿

STW 期间,所有 Java 线程都被冻结——包括你的业务线程,也包括看门狗那个定时任务线程。Redis 那边可不知道你这边卡住了,到点就把锁过期删除。等 GC 结束,业务线程“复活”,它还以为自己握着锁继续写,可实际上锁早没了,别的实例已经进来了。

两个线程同时进临界区,数据错乱就这么来的。

血泪教训:这不是我编的。Redisson 官方文档、Kleppmann 的《How to do distributed locking》都点名过这个场景。

GC 停顿导致锁失效的完整过程:

实例 B(抢锁)Redis实例 A 的 JVM实例 A(持有锁)实例 B(抢锁)Redis实例 A 的 JVM实例 A(持有锁)所有线程冻结,看门狗无法续期两个实例同时进入临界区,数据错乱加锁成功(TTL=30s)触发 Full GC(STW 停顿)30s 到期,锁自动过期删除SET NX 成功,抢到锁GC 结束,线程恢复业务继续执行(以为还持有锁)

那到底要多长的 GC 停顿才会出问题

这才是大家最关心的:“实际场景里概率大吗?”

先算笔账。看门狗每 10 秒续一次,每次把 TTL 刷回 30 秒。所以任意时刻,锁的剩余存活时间其实在20~30 秒之间波动

要让锁真的过期被抢走,STW 停顿必须超过当前剩余 TTL,也就是大致要>20 秒的停顿,而且还得卡在续期窗口附近。

所以门槛其实很高:不是普通的 GC 停顿,而是一次 20 秒级别以上的“真·Full GC”或者系统级停顿。

不同 GC 选型,差别巨大:

  • ZGC / Shenandoah(JDK 11+/17+):停顿控制在10 毫秒以内,跟 20 秒差着三个数量级。这个场景基本不可能发生。
  • G1(JDK 9+ 默认,调优正常):常规 young/mixed GC 才 10~200 毫秒;真正触发 “Full GC” 是 G1 退化兜底(疏散失败、巨型对象、元空间耗尽),能拖到几十秒,但属于异常事件,不是常态。
  • CMS / Parallel(老版本或误配,大堆):Full GC 停顿随堆大小线性增长,几十秒是常事。2026 年已很少见,但存量系统里还有。

还有个容易忽略的点:GC 不是唯一凶手。容器里的 CPU 限流(cgroup throttle)、嘈杂邻居、VM 热迁移,都能造成秒到十秒级的停顿,效果跟 GC 一模一样。

为什么 Redlock 也救不了

很多人下意识觉得:“那我上红锁,5 个 Redis 节点,总稳了吧?”

救不了。因为停顿发生在你的业务客户端 JVM,不在 Redis 那边。你一卡住,5 个节点上的锁会同时在你这边视角里过期——红锁只是多了几个节点,对“客户端被暂停”这件事无能为力。

这正是 Kleppmann 的核心批判:Redlock 依赖“进程不会被长时间打断”的假设,而这个假设在真实环境里站不住脚。Redlock 提升的是可用性,不是这个场景下的安全性。

概率到底大不大

直接给判断:

  • ZGC/Shenandoah:概率≈0,从根上消除了长停顿;
  • 用调优良好的G1 微服务:稳态下,通常只在故障期扎堆出现(流量突增→分配暴涨→GC 压力→长停顿);
  • CMS/Parallel + 大堆误配:相对更可能,尤其在高负载时。

总结一句:正常运维的现代化服务里,触发概率不高;但它会在你最倒霉(故障)的时候跳出来,一跳就是数据级事故。

工程上怎么防

别慌,能防,而且不复杂。

第一,选对 GC。新服务直接上 ZGC 或 Shenandoah,长停顿从源头消失。

第二,关键区要短。分布式锁千万别包住长耗时的远程调用、大事务。锁里只做最必要的临界操作,剩下的异步搞。

第三,别把 Redis 锁当“正确性”保证。它更适合“效率型”用途(防重复执行、抢 Leader)。对扣库存、转账这种正确性敏感的场景,必须有兜底。

第四,上 Fencing Token(栅栏令牌)。这是真正的解法:每次加锁拿到一个单调递增令牌,写数据时带上,存储层拒绝比已见令牌更旧的写入。就算旧持有者 GC 后“复活”来写,也被一脚踢开。ZooKeeper(zxid)、etcd(mod-revision)天生带这个;Redis 本身不提供。

第五,监控报警。盯 GC 停顿时间、Full GC 次数。长停顿就是事故前兆,比事后查数据错乱便宜多了。

面试要是被问到,这么答

“GC 停顿会让看门狗续期失败,这是分布式锁的已知理论弱点,本质是要求客户端停顿不超过锁 TTL。实际中 Redisson 每 10 秒续期、锁剩 20~30 秒,要触发需要 20 秒以上的 STW,现代 GC 下稳态概率很低,属于故障期的尾部风险;而且 Redlock 因为停顿在客户端侧也救不了。所以关键数据场景我会加 fencing token 或 DB 约束兜底,而不是只依赖 TTL 锁。”

这段把机制、量化概率、Redlock 局限全点到了,比干巴巴说“会失败”有说服力得多。

写在最后

分布式锁不是银弹。它能解决“大部分时候只有一个线程干活”的效率问题,但扛不住"客户端被冻住几十秒"这种极端情况。

我的做法一直很简单:能用 Fencing Token 兜底的地方绝不裸奔,能用 ZGC 的地方不纠结。真出了事,你才会感谢当初多写的那个令牌校验。

有问题欢迎评论区交流,下篇打算聊聊 Redisson 看门狗的源码细节,感兴趣的点个关注。

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

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

立即咨询