news 2026/10/16 5:02:21

Redisson分布式锁核心机制拆解:锁重试与WatchDog看门狗

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redisson分布式锁核心机制拆解:锁重试与WatchDog看门狗

没有标题党,直接说结论:分布式锁这块,Redisson 是我在项目里踩过一圈坑之后最终留下的方案。原因就两个——它把“锁重试”和“锁续期”这两件最容易写坏的事,用一套还比较优雅的机制处理掉了。当年我自己用 SETNX 写锁,遇到慢任务超时释放、另一个实例重复执行的问题,排查到凌晨三点,那种滋味现在还记得。所以这篇文章不打算讲“Redisson 怎么用”这种入门话题,而是把锁重试和 WatchDog 看门狗这两块的核心机制拆开,讲清楚它们到底解决了什么问题、底层怎么实现、实战里哪些参数容易配错,以及我在真实项目里踩过哪些坑。

1. 从一次“锁过期引发的重复写入”事故说起

1.1 事故现场:SETNX 方案为什么扛不住慢任务

先说一个我印象特别深的线上事故。当时有个定时任务跑在两个实例上,为了保证同一时间只有一个实例在处理数据,同事用 Redis 的 SETNX 写了一次“简易分布式锁”,大致长这样:

Boolean flag = redisTemplate.opsForValue().setIfAbsent("job:lock", "1", 30, TimeUnit.SECONDS); if (flag) { try { // 处理数据,平均 1-2 秒 } finally { redisTemplate.delete("job:lock"); } }

代码看起来没啥问题,但线上跑了几个月之后,某天数据出现了重复。排查发现原因非常典型:那一次任务处理的数据量暴增,单次执行时间从 2 秒涨到了 50 秒。锁设置的 30 秒过期时间到了之后锁自动释放,第二个实例立刻抢到锁,于是两个实例同时处理同一批数据,最终产生了脏数据。

这类问题的根源其实不在于“锁过期时间设短了”,而是锁的过期时间是写死的,但业务执行时间是波动的。你预估 30 秒够用,就可能在某次异常流量下不够用;你预估 300 秒,万一实例宕机,锁就要等 300 秒才能自动释放,整个服务等于被“卡死”了 5 分钟。

1.2 分布式锁设计的三个核心约束

自己写的临时方案踩坑之后,我去把分布式锁的要求梳理了一遍,发现核心约束其实就三个:

  • 互斥性:任意时刻,同一把锁只能被一个客户端持有。这是分布式锁的底线,做不到就等于没锁。
  • 安全性:持锁客户端一旦崩溃,锁必须能自动释放,否则其他客户端永远拿不到锁。所以“过期时间”而不是“永不失效”是必须的。
  • 可用性:拿不到锁时不能简单返回失败或无限阻塞,需要有合理的重试策略,同时锁的持有时间要能匹配业务,不能因为业务慢就导致锁被别人抢走。

用这三个标准去对照 SETNX 临时方案,问题就很清晰了:互斥性没问题,安全性勉强靠过期时间撑着,可用性上“过期时间写死”这个设计天然就无法匹配波动的业务耗时。

Redisson 进入我的视野,就是因为它的锁重试和 WatchDog 机制恰好补上了第三个约束:拿不到锁时可以等待并重试;拿到锁之后锁的过期时间可以由“看门狗”动态续期,业务多长,锁就能续多久。下面这两块机制,才是 Redisson 和手写 SETNX 拉开差距的关键。

2. 锁重试机制:不是死循环轮询,而是“等通知再抢”

2.1 getLock() 和 tryLock() 到底做了什么

很多刚接触 Redisson 的同事,最容易把getLock()当成“加锁”来用,其实完全不是一回事。

RLock lock = redissonClient.getLock("order:create:1001");

getLock()只是创建了一个RLock实例,本质上是获取一个“锁的句柄”,此时 Redis 里没有任何锁记录。真正去抢占锁的是lock()、tryLock()这些方法:

// 方式一:阻塞直到拿到锁,拿不到就一直等 lock.lock(); // 方式二:最多等待 3 秒,3 秒内抢不到就返回 false boolean locked = lock.tryLock(3, TimeUnit.SECONDS); // 方式三:最多等待 3 秒,拿到后锁租约 10 秒自动过期 boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);

这里要特别留意tryLock的两种重载。两个参数的tryLock(waitTime, unit)表示“锁租约不指定,交给 WatchDog 续期”;三个参数的tryLock(waitTime, leaseTime, unit)表示“锁最多活 leaseTime 秒,到点自动释放,不管业务有没有跑完”。这个差异会在第 3 章详细展开,非常多线上事故就是从这里开始的。

2.2 抢锁失败的等待链路:从 Lua 脚本到 Pub/Sub 订阅

tryLock的核心价值在于“等待”和“重试”。我直接说底层链路,Redisson 抢锁并不是拿 Java 代码判断 key 是否存在,而是执行一段 Lua 脚本,保证“检查锁是否存在 + 写入锁 + 设置过期时间”是原子的。

-- 简化后的思路,保留了核心判断逻辑 if (redis.call('exists', KEYS[1]) == 0) then redis.call('hset', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return 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,field 是“客户端 ID + 线程 ID”,value 是重入计数。这就是 Redisson 锁可重入的实现基础。脚本返回值为nil表示加锁成功,返回剩余过期时间(pttl)表示锁被别人持有。

当返回锁被占用后,进入关键分支。Redisson 的“锁重试”不是大家想象的那种“每隔几百毫秒重新执行一次 Lua 脚本”,而是走了“订阅 + 信号量阻塞”的路线:

  1. 加锁失败时,客户端判断剩余等待时间是否大于 0,如果是,就订阅该锁对应的 channel。
  2. 阻塞在一个信号量Semaphore上,等待“锁释放消息”来唤醒。
  3. 当持有锁的客户端执行unlock()释放锁时,会向该 channel 发布一条消息。
  4. 正在等待的客户端收到消息后,信号量释放一个许可,唤醒等待线程。
  5. 线程醒来后,重新执行第 1 步的 Lua 脚本尝试加锁。
  6. 如果在 waitTime 内始终没等到锁,tryLock返回 false;如果中途抢到了锁,返回 true。

这套设计的精妙之处在于:抢锁失败的客户端不会白白消耗 Redis 连接和 CPU 去高频轮询,而是“等通知”。锁的持有者一释放,等待者第一时间就能被唤醒去抢锁,延迟极低,同时绝大多数时间内是空闲挂起的。

2.3 重试参数怎么设置才合理

tryLock的 waitTime 是“获取锁的最大等待时间”,这个参数必须结合业务设计,我见过不少设置不当的案例。

场景waitTime 建议原因
定时任务 / 批处理3~10 秒大量实例同时抢锁,等待时间太长会让堆积的任务占用线程池
对延迟敏感的接口1~3 秒拿不到锁就直接走降级逻辑,避免用户长时间等待
后台长任务5 秒左右即可不要试图在 tryLock 阶段等很久,关键是拿到锁之后靠 WatchDog 续期

我个人的习惯是:waitTime一般不超过 10 秒。先问你一个问题——“这个任务拿不到锁,我能不能接受直接返回失败、下次再跑?”如果能接受,waitTime 设短一点反而更健康,因为分布式锁的一个核心作用就是限流兜底,不应该让所有请求都堆在锁上死等。

3. WatchDog 机制:30 秒租约背后的自动续期逻辑

3.1 为什么默认锁超时是 30 秒

Redisson 的lock()和两参数tryLock(waitTime, unit)默认会给锁设置一个 30 秒的过期时间,这个值来自配置项lockWatchdogTimeout,默认30000毫秒。

这里的核心问题是:为什么是 30 秒,而不是 10 秒或者 1 分钟?

我的理解是,这个值本意是“兜底”,不是“业务期望的执行时间”。客户端拿到锁之后,锁的真正生命周期由 WatchDog 动态调整,30 秒只是“最长不续期时间”——换句话说,30 秒足够覆盖绝大多数正常业务的单次执行;万一业务超过 30 秒,WatchDog 就会自动续期,把锁的生命周期继续拉长。而对于宕机的客户端,30 秒意味着最坏情况下,锁会在 30 秒后自动消失,不会死锁。

所以这个 30 秒更像“安全网”而不是“计时器”。它的数值大小需要权衡两件事:太短会导致锁频繁过期或续期压力大;太长则客户端宕机后,其他客户端等待锁释放的时间过久。默认 30 秒在绝大多数场景下是合理的,不建议轻易调小。

3.2 续期任务的调度实现

WatchDog 的续期逻辑,简单说就是“持有锁的客户端每隔一段时间重置一次过期时间”。具体来说:

  1. lock()抢锁成功后,如果未指定 leaseTime,Redisson 会开启一个续期任务。
  2. 这个任务每隔lockWatchdogTimeout / 3(默认 10 秒)执行一次。
  3. 每次执行时,客户端重新执行 Lua 脚本,把锁的过期时间重新设置为 30 秒。
  4. 当业务代码执行完毕,调用unlock()释放锁时,续期任务会被取消。

这里的“每隔 10 秒续一次”有讲究:如果锁的实际剩余时间已经低于 20 秒,说明上一个续期周期内发生了异常,这个设计给了充足的时间窗口,让续期动作有足够机会在锁过期前完成。

WatchDog 续期用的同样是 Lua 脚本,核心就是检查锁是否仍然是“当前客户端持有”,如果是,就重置过期时间。这里有个任何人都不能忽略的前提:续期动作是持有锁的客户端自己做出来的。这带来一个非常好的性质——如果持有锁的客户端进程崩溃了,续期任务自然也就消失了,不会再有人去续期,锁会在最多 30 秒后被 Redis 自动清理。

3.3 看门狗只在“不传 leaseTime”时工作

这是 Redisson 里最容易被误解的机制,一定要单独说清楚:当你手动传了 leaseTime,WatchDog 就直接罢工了。

// 传了 leaseTime=10 秒,锁最多活 10 秒,WatchDog 不参与 boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS); // 不传 leaseTime,锁默认 30 秒,WatchDog 会持续续期 boolean locked = lock.tryLock(3, TimeUnit.SECONDS);

这个设计是故意的。Redisson 的设计哲学是:如果你显式指定了 leaseTime,就说明你很清楚自己的业务需要多长时间,锁到期就释放是“你自己要求的”,不需要看门狗来干预。如果你不指定,那么 Redisson 默认你无法预估业务耗时,于是用 30 秒兜底,再用 WatchDog 动态续期来保证锁不会提前过期。

明白了这个设计,再去回看很多线上问题就豁然开朗了。比如我见过一个同事用lock.lock(10, TimeUnit.SECONDS)给一个可能要跑 30 秒的批量任务加锁,结果任务跑了一半锁就过期了,另一个实例抢到锁开始重复执行。问题不是 Redisson 有 bug,而是使用者传了 leaseTime,导致 WatchDog 完全不介入。

4. 源码走读:重试与续期在 Redisson 里怎么协作

4.1 tryAcquireAsync:一次抢锁请求的完整生命周期

tryLock的完整内部链路,可以拆成下面几步来看:

tryLock() -> tryAcquireAsync() -> tryAcquireOnceAsync() -> 执行 Lua 脚本尝试加锁 -> 成功则返回 null,并启动 WatchDog 续期 -> 失败则返回剩余 ttl -> 如果 ttl != null -> 计算剩余等待时间 -> subscribe() 订阅锁释放消息 -> 信号量阻塞等待 -> 被唤醒后再次尝试加锁

这里的tryAcquireOnceAsync执行的就是我们第 2 章贴的那段 Lua 脚本。返回null表示加锁成功;返回具体数值表示锁还被别人持有,需要进入等待链路。

我贴一个非常简化的伪代码,帮助理解整体协作关系:

CompletableFuture<Long> ttlFuture = tryAcquireAsync(leaseTime, unit, threadId); ttlFuture.whenComplete((ttl, e) -> { if (ttl == null) { // 加锁成功,如果没指定 leaseTime,启动看门狗续期 if (leaseTime == -1) { scheduleExpirationRenewal(threadId); } return; } // ttl 不为 null,说明锁被占用,进入等待流程 if (waitTime <= 0) { // 等不了,直接失败 return false; } // 订阅锁释放消息 subscribe(threadId); // 信号量阻塞,收到释放消息后被唤醒 getEntry().getLatch().tryAcquire(waitTime); // 被唤醒后,再次调用 tryAcquire 尝试加锁 tryAcquireOnceAsync(...); });

这里有几个容易忽略的细节:

  • 信号量的许可数量是 1。锁释放时会 release 一个许可,只唤醒一个等待线程,避免“惊群效应”导致多个客户端同时抢锁、大量请求打到 Redis。被唤醒的线程要再跑一次 Lua 脚本抢锁,抢不到就继续等下一轮通知。
  • 订阅动作本身也占用等待时间。如果 Redis 连接异常或订阅耗时长,这部分时间会计入 waitTime,表现为实际等待时间略短于预设值。
  • 锁重试过程中,WatchDog 并没有启动。因为此时客户端还没拿到锁,没有锁可续期。锁的续期由当前持有者负责,和等待者无关。

4.2 续期任务何时启动、何时销毁

续期任务的启动点就是第 4.1 节的代码:加锁成功且leaseTime == -1时调用scheduleExpirationRenewal(threadId)。

这个任务的执行逻辑,用伪代码描述就是:

private void renewExpiration() { // 每隔 timeout/3 执行一次,默认 10 秒 Timeout task = group.schedule( () -> { // 执行 Lua:检查锁是否仍由当前线程持有,若是则重置过期时间为 30 秒 renewExpirationAsync(threadId); // 递归安排下一次续期 }, lockWatchdogTimeout / 3, TimeUnit.MILLISECONDS ); }

销毁时机有三个:

  • 业务代码主动调用unlock()。释放锁的 logic 里会取消续期任务,先停止续期,再执行释放锁的 Lua 脚本。
  • 加锁客户端崩溃。进程都没了,定时任务自然不再执行,锁会在最多 30 秒后自动过期。
  • 续期时发现锁已经不存在或已被其他客户端持有。这种情况说明锁已经丢失或被动释放,续期任务会被终止。

要注意一个反直觉的细节:unlock()是先取消续期任务,再删除 Redis 里的锁。如果反过来先删锁再取消任务,可能在删锁之后、取消任务之前的几十毫秒窗口里,有一轮续期脚本把已经不被持有的“残留锁”重新设置上过期时间,造成锁残留。Redisson 的顺序设计避开了这个坑。

4.3 一个很多人忽略的问题:锁重试期间续期是否有效

这个问题经常在交流群里看到:线程 A 在tryLock等待期间,线程 B 持有锁并开启了 WatchDog,如果 B 的业务很长,A 的等待时间会不会被 B 的续期拉长?

答案是“不会也不能”。A 的tryLock等待时间由 waitTime 限制,无论 B 续多久,A 到点就会返回 false。B 的续期只会影响“B 的锁什么时候真正释放”,不影响 A 的等待上限。

但从业务角度考虑,这里有一个设计建议:如果 A 等待 B 释放锁后必须执行,而且 B 可能跑很久,那么 A 的 waitTime 要设置得足够大,或者干脆用lock()阻塞式获取。但阻塞式获取也要谨慎——如果 B 是长任务,A 会一直阻塞在线程池里,线程池可能被耗尽。实际项目中常见做法是给 A 一个“宽松但有限”的等待时间,比如 30 秒或 60 秒,同时设计好拿不到锁时的降级路径。

5. 实战中的坑与最佳实践

5.1 指定 leaseTime 导致锁提前失效

这是 Redisson 使用中最高频的坑。很多人一看tryLock(waitTime, leaseTime, unit)有三个参数,觉得很“完整”,就直接传了 leaseTime,结果业务超过这个时间后锁自动消失。

我给你一个非常具体的错误示例:

// 错误思路:以为 leaseTime 是“最大持有时间”,超过就自动释放 // 结果业务实际耗时 20 秒,锁 5 秒就过期了 boolean locked = lock.tryLock(3, 5, TimeUnit.SECONDS); if (locked) { doBiz(); // 执行 20 秒 }

正确做法是:除非你有明确的理由让锁“必须在一段时间后自动失效”,否则使用两参数tryLock(waitTime, unit),把租约交给 WatchDog 去动态续期。如果你确实要传 leaseTime,一定要预估好业务的最长执行时间,并留出足够的余量。

5.2 看门狗续期在耗时任务中的临界点

WatchDog 虽然能续期,但它不是银弹。有一个边界情况需要自己处理:如果单次业务逻辑中有一段长时间阻塞,导致续期任务本身一直无法被调度,锁仍然可能过期。

举例来说,你的业务代码拿到锁之后,调用了一个第三方接口,这个接口超时设置是 60 秒,当前线程阻塞了 60 秒。在这 60 秒内,续期任务理论上应该每隔 10 秒执行一次,但如果这段阻塞发生在持有 netty 事件循环线程或专门的调度线程上,续期可能无法及时执行。更常见的情况是应用发生长时间 Full GC,整个 JVM 暂停了几十秒,这段时间内所有定时任务都不会跑,等 GC 结束恢复时,锁可能已经在 Redis 端过期了。

这类问题没有完美的解决方案,最实用的缓解手段是:把“长时间阻塞的外部调用”从持锁代码里拆出去,或者把锁的粒度缩小——锁只保护核心资源访问的那几行代码,不要在锁内做整个业务流程。

5.3 从 Redis 主从切换聊聊“锁丢失”的边界

Redisson 默认的单节点锁,在 Redis 主从切换的场景下存在一个理论上的“锁丢失”窗口:

  1. 客户端 A 在主节点上成功加锁。
  2. 主节点还没把锁同步到从节点,突然宕机。
  3. 从节点晋升为主节点,此时它没有 A 的锁记录。
  4. 客户端 B 在新的主节点上加锁成功,同一把锁被两个客户端同时持有。

这个场景过于极端,但并非不可能。解决思路有两个:

  • 使用 Redisson 的红锁(RedLock),多个独立 Redis 节点依次加锁,超过半数成功才算拿到锁。
  • 接受这个极端窗口,靠业务侧的幂等性兜底。

我的观点是:绝大多数中大型互联网业务,优先保证“可用性”,也就是主从切换后新的主节点能快速接管、锁也能正常获取,而不是为了极端一致性牺牲可用性。此时选择默认的单点锁,配合业务的幂等设计,是更务实的做法。只有对一致性要求极其苛刻的场景,才值得引入 RedLock 的运维和性能成本。

5.4 重试参数与业务容错的设计建议

最后给一套实战参数设计,可以直接参考落地:

@Autowired private RedissonClient redissonClient; public boolean tryCreateOrder(Long orderId) { String lockKey = "order:create:" + orderId; RLock lock = redissonClient.getLock(lockKey); boolean locked = false; try { // 最多等 3 秒,锁租约交给 WatchDog 续期 locked = lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { log.warn("获取订单锁失败,orderId: {}", orderId); return false; } // 核心业务逻辑 doCreateOrder(orderId); return true; } finally { if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } } }

几个习惯养成之后再也没出过锁相关的线上事故:

  1. unlock 前必须判断isHeldByCurrentThread()。防止业务代码内部把锁提前释放,或者发生锁重入边界问题之后,线程释放了不属于自己的锁。
  2. 尽量把锁的粒度控制在真实需要互斥的代码块上,不要把远程调用、大文件读写、慢查询都包在锁里。
  3. waitTime 和业务容错强相关。能降级的业务,waitTime 设 1~3 秒;不能降级的批处理,开到 10 秒左右,但一定要配合线程池隔离,防止线程被锁集体卡死。

从我个人的实战体会来说,分布式锁这个组件,很多时候“死锁”反而不是最可怕的问题,最可怕的是锁“提前失效”导致并发穿透。Redisson 的 WatchDog 正是为了把这个问题从设计层面解决掉,而锁重试机制解决了“拿不到锁怎么办”的可用性问题。理解了这两个机制的协作,你才能真正把一个分布式锁用明白,而不是停留在 API 调用层面。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 5:02:20

从零搭建AI Agent编排架构:历史资料汇编的工程化实践

1. 为什么我要从零搭一套资料汇编的 Agent 编排架构做历史专题资料汇编这件事&#xff0c;听起来像是文科生的活儿&#xff0c;但真正干过一轮的人都知道&#xff0c;它本质上是一个信息工程问题。我前后接手过三个类似的项目&#xff0c;从地方史志的专题整理&#xff0c;到某…

作者头像 李华
网站建设 2026/10/16 5:00:55

办公Agent文件验收流程拆解:从模型答对到任务真正完成

最近在梳理一个办公 Agent 的文件验收流程&#xff0c;翻着翻着发现一个特别有意思的现象&#xff1a;模型在很多测试用例上已经答得相当漂亮&#xff0c;字段抽得又准又全&#xff0c;可一旦放进真实业务链路里&#xff0c;任务照样被判失败。不是模型不聪明&#xff0c;是验收…

作者头像 李华
网站建设 2026/10/16 5:00:55

大模型全链路平台:从训练到推理的工程化实践与核心痛点解析

1. 大模型全链路平台到底在解决什么问题1.1 从一个真实困境说起去年下半年&#xff0c;我参与了一个行业垂直模型的项目。团队里有人负责数据清洗&#xff0c;有人搞训练框架&#xff0c;有人做推理服务部署&#xff0c;还有人专门盯着线上服务的响应延迟和显存占用。项目启动时…

作者头像 李华
网站建设 2026/10/16 5:00:55

办公Agent文件验收:从“模型答对”到“任务做对”的工程关卡

最近翻到一份办公Agent项目的文件验收流程文档&#xff0c;读完之后最大的感触是&#xff1a;这份流程里几乎没有一条写着“让模型答对”。它通篇在问的都是另一个问题——你交给用户的那个文件&#xff0c;到底能不能用、敢不敢用。过去我们做类似系统&#xff0c;最紧张的是模…

作者头像 李华
网站建设 2026/10/16 5:00:12

SSM+Vue前后端分离商品管理系统实战:从数据库设计到部署上线

最近帮一家做五金配件批发的公司搭了一套在线商品管理系统&#xff0c;技术栈是SSM加Vue&#xff0c;前后端分离。说实话&#xff0c;这套组合在电商后台、进销存系统里已经算"老朋友"了&#xff0c;看着不起眼&#xff0c;但真正做完一遍&#xff0c;你会发现里面藏…

作者头像 李华