没有标题党,直接说结论:分布式锁这块,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 脚本”,而是走了“订阅 + 信号量阻塞”的路线:
- 加锁失败时,客户端判断剩余等待时间是否大于 0,如果是,就订阅该锁对应的 channel。
- 阻塞在一个信号量
Semaphore上,等待“锁释放消息”来唤醒。 - 当持有锁的客户端执行
unlock()释放锁时,会向该 channel 发布一条消息。 - 正在等待的客户端收到消息后,信号量释放一个许可,唤醒等待线程。
- 线程醒来后,重新执行第 1 步的 Lua 脚本尝试加锁。
- 如果在 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 的续期逻辑,简单说就是“持有锁的客户端每隔一段时间重置一次过期时间”。具体来说:
lock()抢锁成功后,如果未指定 leaseTime,Redisson 会开启一个续期任务。- 这个任务每隔
lockWatchdogTimeout / 3(默认 10 秒)执行一次。 - 每次执行时,客户端重新执行 Lua 脚本,把锁的过期时间重新设置为 30 秒。
- 当业务代码执行完毕,调用
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 主从切换的场景下存在一个理论上的“锁丢失”窗口:
- 客户端 A 在主节点上成功加锁。
- 主节点还没把锁同步到从节点,突然宕机。
- 从节点晋升为主节点,此时它没有 A 的锁记录。
- 客户端 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(); } } }几个习惯养成之后再也没出过锁相关的线上事故:
- unlock 前必须判断
isHeldByCurrentThread()。防止业务代码内部把锁提前释放,或者发生锁重入边界问题之后,线程释放了不属于自己的锁。 - 尽量把锁的粒度控制在真实需要互斥的代码块上,不要把远程调用、大文件读写、慢查询都包在锁里。
- waitTime 和业务容错强相关。能降级的业务,waitTime 设 1~3 秒;不能降级的批处理,开到 10 秒左右,但一定要配合线程池隔离,防止线程被锁集体卡死。
从我个人的实战体会来说,分布式锁这个组件,很多时候“死锁”反而不是最可怕的问题,最可怕的是锁“提前失效”导致并发穿透。Redisson 的 WatchDog 正是为了把这个问题从设计层面解决掉,而锁重试机制解决了“拿不到锁怎么办”的可用性问题。理解了这两个机制的协作,你才能真正把一个分布式锁用明白,而不是停留在 API 调用层面。