找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2059

积分

0

好友

271

主题
发表于 3 天前 | 查看: 18| 回复: 0

做分布式开发的时候,大家对 Redis 分布式锁应该都不陌生。为了防止锁死,比如服务器突然断电导致锁永远不释放,我们通常都会给锁加一个过期时间(TTL)

写代码的时候,我们心里的算盘是这样打的:

“我的业务逻辑跑完只需要 200 毫秒,但我为了保险,给锁设了 10 秒的过期时间。这 10 秒够我跑 50 次了,绝对稳如老狗。”

但现实往往喜欢给人“惊喜”。在线上高并发场景下,你可能会遇到一种极其诡异的并发现象:监控显示,线程 A 还在执行业务逻辑,锁的过期时间也没到(理论上),但线程 B 竟然大摇大摆地抢到了锁,开始修改同一份数据。 脏数据就这么产生了。

那么,这把明明还没过期的锁,到底是怎么失效的呢?问题的根源,往往藏在客户端和服务端对时间感知的差异里。

案发现场

为了复现这个问题,我们先看一段看似标准的分布式锁伪代码:

// 1. 加锁,设置 10秒 过期
if (redis.setnx("lock_key", "thread_A", 10s)) {
    try {
        // 2. 执行业务逻辑 (预计 200ms)
        doBusiness();
    } finally {
        // 3. 释放锁
        // (这里通常会校验是不是自己的锁,先省略)
        redis.del("lock_key");
    }
}

这段代码在绝大多数时间里都能完美工作,但你的价值往往就是去解决那极少数的疑难问题。直到有一天,服务器负载突然飙升,应用触发了一次严重的 Full GC,或者宿主机发生了短时间的卡顿。就在这瞬间,事故悄然发生。

时间被冻结了?

我们总以为时间是连续的、均匀流逝的。但在计算机的世界里,尤其是 Java 的世界里,时间是可以暂停的。导致锁失效的真凶,正是 JVM 的 STW(Stop-The-World)机制

我们把时间放慢看,在微观的时间轴上到底发生了什么:

  • (0s) 线程 A 成功从 Redis 拿到锁,过期时间设为 10s
  • (0.1s) 线程 A 刚开始执行 doBusiness(),才跑了一行代码。
  • (0.2s) 事故来了! JVM 触发了一次耗时极长的 Full GC,可能是因为内存泄漏,或者堆太大回收慢。此时,JVM 暂停了包括线程 A 在内的所有工作线程。
  • 线程 A 停在了第 0.2 秒,它觉得自己才刚开始跑。但 Redis 服务端的时间并没有停! Redis 那边的倒计时还在正常走。
  • (10.2s) 10 秒过去了,Redis 发现 lock_key 过期了,于是自动删除了这个 Key。
  • (10.3s) 线程 B 进来请求加锁,因为它发现 Redis 里没锁,所以成功拿到了锁。
  • (12s) Full GC 结束, 线程 A 被 JVM 唤醒了。它完全不知道自己“睡”了将近 12 秒,以为自己才跑了 0.2 秒,手里还“攥着”锁。于是线程 A 继续执行剩下的业务逻辑,往数据库里写数据。
  • (12.1s) 线程 B 同时也在写数据。

结果,线程 A 和线程 B 同时在操作同一份数据,锁的互斥性彻底失效。这就是分布式系统中最经典的时间跳变问题。你以为你拥有 10 秒,其实在 STW 面前,这 10 秒可能瞬间就蒸发了。

加长过期时间行不行?

很多同学的第一反应是:那我就把过期时间设长点,设成 10 分钟,GC 总不能停 10 分钟吧?这确实能降低概率,但治标不治本。

  1. 副作用大:如果你的服务真的挂了,锁要等 10 分钟才能自动释放,这期间相关业务就瘫痪了。
  2. 不可控:你永远无法精确预测下一次 STW 会持续多久,或者网络延迟会有多大。依赖一个“足够长”的猜测值不是严谨的工程方案。

Watchdog 续命机制

这是目前业界最主流的增强方案,比如 Java 领域知名的 Redisson 客户端就内置了这个机制,俗称看门狗(Watchdog)

它的原理并不复杂:

  1. 线程 A 拿到锁时,Redisson 设置的锁过期时间可能比业务预期长(例如默认 30 秒)。
  2. Redisson 会在后台启动一个守护线程
  3. 每隔一段时间(默认是锁过期时间的 1/3,即 10 秒),这个守护线程就去 Redis 检查一下:线程 A 还持有锁吗?
  4. 如果还持有,就自动执行 EXPIRE 命令,把锁的过期时间重新续满到 30 秒。

这样一来,只要持有锁的 JVM 进程没挂,即使主业务线程因为 Full GC 被暂停,只要 GC 结束,后台的守护线程也会恢复工作并成功续期,锁就永远不会在业务执行期间过期。只有当持有锁的机器彻底宕机,看门狗线程也挂了,锁才会因为无人续期而自动释放,这反而成了一个优点。

看门狗就万无一失了吗?

对于普通业务,Redisson 的看门狗机制已经足够可靠。但如果你处理的是金融级的核心业务,还需要考虑一种更极端的“黑天鹅”场景:如果 GC 暂停发生在“最后一步”怎么办?

想象一下这个场景:

  1. 线程 A 拿到了锁,看门狗线程也在正常工作,定期续期。
  2. 线程 A 完成了所有业务计算,正准备执行数据库 UPDATE 语句。
  3. 突然,一次超长 Full GC 来袭。 这次 GC 停得太久,连后台的“看门狗”线程也被暂停了。
  4. 锁在 Redis 中过期了。
  5. 线程 B 成功拿到了锁,并修改了数据库中的数据。
  6. GC 结束。 线程 A 苏醒,它不需要再去检查或请求 Redis,而是直接把之前准备好的那条 UPDATE 语句发给了数据库。

数据再次被覆盖。你看,即使有看门狗,在极端情况下,仅靠分布式锁依然无法保证 100% 的互斥安全。这并非 Bug,而是在异步网络模型中,CAP 理论的权衡体现。仅仅依靠一个中心化的锁服务和不可靠的本地时间感知,难以做到绝对安全。

终极兜底:乐观锁

要彻底解决这个问题,我们不能只把希望寄托在锁上,必须让存储层数据库参与进来做最终的一致性兜底。这个方案通常被称为 Fencing Token(栅栏令牌),或者更通俗地讲,就是利用乐观锁版本号机制。

  1. 加锁时返回递增令牌:线程 A 抢 Redis 锁时,锁服务(可以是增强的 Redis 逻辑或另一个系统)同时生成并返回一个单调递增的数字作为 Token,比如 33。
  2. 携带令牌更新数据:线程 A 在后续操作数据库时,必须带上这个 Token。

    UPDATE account SET money = 100 WHERE id = 1 AND current_token < 33;
    
    -- 或者使用更常见的乐观锁版本号模式:
    UPDATE account SET money = 100, version = version + 1 WHERE id = 1 AND version = old_version;
  3. 数据库层校验:如果在线程 A 暂停期间,线程 B 抢占了锁,拿到了更大的 Token 34 并成功更新了数据(同时会更新版本号或 Token 值)。那么当线程 A 醒来执行上面的 SQL 时,条件 current_token < 33version = old_version 就不再成立,更新会失败(影响行数为 0),从而避免了数据覆盖。

写在最后

回到最初的问题:锁明明还没过期,为什么会被别人抢走?

核心答案在于,在分布式系统中,本地进程的时间远程服务的时间可能因为 GC 停顿、时钟漂移等原因而不同步。你的 10 秒,在 Redis 看来可能已经结束了。

因此,在设计分布式锁方案时,对于大多数场景,使用类似 Redisson 这样具备看门狗机制的成熟客户端是很好的选择。但是,如果你面对的是对一致性要求极高的核心业务,尤其是金融交易场景,请务必在数据库层面增加乐观锁作为最后的防线。在分布式领域,对时间保持一份敬畏,往往能让你的系统更加健壮。

如果你对这类 Java 后端的高并发、分布式锁的底层原理以及更多分布式系统的设计模式感兴趣,欢迎来到 云栈社区 与更多开发者一起交流探讨。




上一篇:Nacos服务注册的默认设计:为何在云原生时代首选临时实例?
下一篇:MySQL LIMIT 1 查询反而变慢 50 倍?剖析索引优化与执行计划误区
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-3-10 10:59 , Processed in 0.517853 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表