Redisson 看门狗机制详解
注意:从语雀导出的文章格式有问题,请从语雀查看
在分布式系统中,分布式锁是协调多节点并发访问共享资源的核心组件。Redis 因其高性能和丰富的数据结构,成为实现分布式锁的热门选择。Redisson 作为 Redis 的 Java 客户端,不仅提供了简单易用的分布式锁 API,其内置的"看门狗" 机制** 更是解决了锁的自动续期难题,极大地提升了锁的可靠性和易用性。
一、 为什么需要看门狗?------ 从锁的释放讲起
要理解看门狗,首先要明白一个关键问题:锁应该在什么时候被释放?
一个最直接的答案是:在业务逻辑执行完毕后,由锁的持有者主动释放。这听起来很简单,但在分布式环境中,情况要复杂得多。
考虑以下场景:
- 正常情况:线程 A 获取锁 -> 执行业务逻辑 -> 释放锁。
- 异常情况 :线程 A 获取锁 -> 在执行业务逻辑时,所在服务器发生长时间 GC、网络抖动、或是服务实例意外宕机 -> 线程 A 被阻塞或"死亡",无法执行释放锁的代码。
在异常情况下,如果锁没有被释放,它就会一直存在于 Redis 中,导致其他所有线程都无法再获取到这个锁,这就是可怕的 "死锁" 问题。
为了避免死锁,我们通常会给锁设置一个 过期时间(TTL)。这样,即使锁的持有者崩溃,锁也会在 TTL 过后自动失效。
但是,设置 TTL 又引入了新的问题:
- TTL 设置多久?
- 如果设置得太短,业务逻辑还没执行完,锁就自动释放了。此时线程 B 可以获取到锁,导致数据不一致。
- 如果设置得太长,万一持有锁的线程真的崩溃了,其他线程需要等待很长时间才能重新获取锁,系统可用性降低。
看门狗机制就是为了解决这个"TTL 设置难题"而生的。它的核心思想是:只要持有锁的线程还活着,并且业务还在执行,我就不断地延长锁的过期时间。
二、 看门狗机制的核心原理
Redisson 的看门狗机制可以概括为 "只要锁未被显式释放,且持有锁的客户端依然存活,就自动为锁续命"。
其工作流程如下图所示:
具体来说,其工作流程如下:
- 获取锁与看门狗启动 :
- 当使用
<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">lock()</font>方法(不传入<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">leaseTime</font>参数)获取锁时,看门狗机制会被激活。 - 锁在 Redis 中的默认超时时间是 30 秒。
- 在成功获取锁之后,Redisson 会向一个后台的守护线程池中提交一个定时任务。
- 当使用
- 定时续期 :
- 这个定时任务首次会在
<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">锁默认超时时间 / 3</font>后执行,也就是 10 秒 后。 - 任务执行时,它会检查当前客户端是否还持有这个锁(通过比较 Redis 中锁的值和客户端的唯一 ID)。
- 如果仍然持有,它就通过执行
<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">PEXPIRE</font>命令,将锁的过期时间重新设置为 30 秒。 - 这个"检查 -> 续期"的循环会每隔 10 秒执行一次,直到锁被释放或客户端宕机。
- 这个定时任务首次会在
- 释放或终止 :
- 主动释放 :当业务代码执行完毕,调用
<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">unlock()</font>方法时,在释放锁的同时,会取消这个对应的定时任务。 - 被动终止:如果持有锁的 JVM 进程崩溃,那么看门狗线程也会随之停止,续期任务自然终止。锁在最后一次续期后的 30 秒会自动过期,避免了永久死锁。
- 主动释放 :当业务代码执行完毕,调用
关键点总结:
- 触发条件 :使用无参
<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">lock()</font>或<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">tryLock()</font>方法,且不指定<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">leaseTime</font>。 - 锁超时时间:默认 30 秒。
- 续期间隔 :默认
<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">超时时间 / 3</font>,即 10 秒。 - 续期操作:将锁的 TTL 重置为初始的超时时间(30秒)。
三、 源码层面的简单剖析
让我们深入到 Redisson 源码中,看看看门狗是如何实现的(以 ## **edisson 3.x 版本为例)。
锁获取与看门狗启动 :
在 <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">RedissonLock#lock()</font> 方法中,最终会调用 <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">tryAcquireAsync</font> 方法。在异步获取锁成功后,会调用 <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">scheduleExpirationRenewal</font> 方法。
- java
java
// RedissonLock.class
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
// ... 省略前期代码 ...
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
// ... 省略后续代码 ...
}
private Long tryAcquire(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
// 调用内部异步方法
return get(tryAcquireAsync(waitTime, leaseTime, unit, threadId));
}
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
RFuture<Long> ttlRemainingFuture;
if (leaseTime != -1) {
// 如果指定了leaseTime,则不会启动看门狗
ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {
// 使用默认leaseTime(-1),会启动看门狗
ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime, TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
}
// 获取锁成功后,执行后续操作
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// lock acquired
if (ttlRemaining == null) {
// 这里是关键:安排过期时间的续期
if (leaseTime != -1) {
internalLockLeaseTime = unit.toMillis(leaseTime);
} else {
// 启动看门狗任务!
scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
看门狗任务的核心 :
<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">scheduleExpirationRenewal</font> 方法会创建一个 <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">ExpirationEntry</font> 并启动一个定时任务。这个任务的核心是 <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">renewExpiration</font> 方法。
- java
java
// RedissonBaseLock.class
protected void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
// 创建一个延时任务
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
// 核心:异步执行续期命令
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getRawName() + " expiration", e);
// 如果出错,不再续期
return;
}
if (res) {
// 续期成功,则递归调用自己,安排下一次续期
renewExpiration();
}
// 如果res为false,表示锁已经不存在(已被释放),则任务结束。
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); // 延时时间为 internalLockLeaseTime / 3
ee.setTimeout(task);
}
Lua 脚本续期 :
<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">renewExpirationAsync</font> 方法会执行一段 Lua 脚本,这是保证原子性的关键。
- lua
java
-- 位于 RedissonLock.renewExpirationAsync 方法中
-- 判断 Redis 中锁的值是否仍是当前客户端的标识
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 如果是,则执行 pexpire 命令,重置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1;
end;
return 0;
- `<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">KEYS[1]</font>`<font style="color:rgb(15, 17, 21);">:锁的 Key。</font>
- `<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">ARGV[1]</font>`<font style="color:rgb(15, 17, 21);">:新的过期时间(默认 30000 毫秒)。</font>
- `<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">ARGV[2]</font>`<font style="color:rgb(15, 17, 21);">:客户端唯一标识(</font>`<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">getLockName(threadId)</font>`<font style="color:rgb(15, 17, 21);">)。</font>
从源码可以看出,整个流程通过异步回调和非阻塞的延时任务巧妙地串联起来,既保证了性能,又实现了可靠的自动续期。
四、 如何使用与配置
1. 启用看门狗(默认行为)
java
java
RLock lock = redisson.getLock("myLock");
// 以下两种方式都会启用看门狗
lock.lock();
// 或
lock.tryLock();
2. 禁用看门狗(手动指定租约时间)
如果你为锁指定了 <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">leaseTime</font> 参数,看门狗机制将不会被启用。锁会在指定的 <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">leaseTime</font> 后自动过期,无论业务是否执行完毕。
java
java
// 获取锁后,最多持有10秒,10秒后自动释放,不会续期
lock.lock(10, TimeUnit.SECONDS);
// 尝试获取锁,等待100秒,获取成功后持有5秒
boolean isLocked = lock.tryLock(100, 5, TimeUnit.SECONDS);
if (isLocked) {
try {
// ... 业务逻辑,必须在5秒内完成 ...
} finally {
lock.unlock();
}
}
3. 关键配置参数
你可以在 Redisson 的配置文件中修改看门狗的默认参数:
java
java
Config config = new Config();
config.setLockWatchdogTimeout(30 * 1000); // 单位毫秒,默认就是30000ms
RedissonClient redisson = Redisson.create(config);
<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">lockWatchdogTimeout</font>:这个参数控制了看门狗机制中锁的默认超时时间。续期间隔是该时间的 1/3。
五、 最佳实践与注意事项
一定要在 finally 块中解锁 :
这是最基本也是最重要的一点。确保无论业务逻辑正常执行还是抛出异常,锁都能被释放,并取消看门狗任务。
- java
plain
lock.lock();
try {
// ... 业务逻辑 ...
} finally {
lock.unlock();
}
- 评估业务执行时间 :
看门狗保证了长任务下的锁安全,但并不意味着你可以无限制地执行长任务。长时间持有锁会严重影响系统吞吐量和响应速度。请尽量优化业务逻辑,缩短锁的持有时间。 - 明确使用场景 :
- 使用看门狗:适用于无法准确预估业务执行时间的场景,希望最大程度避免因锁自动过期而导致的数据不一致。
- 指定 leaseTime:适用于能够明确知道业务最大执行时间的场景,可以作为一层额外的保护,防止看门狗因未知原因(如系统时钟问题)失效而导致锁无法释放。
- 避免混用 :
不要在同一个锁上,有时用看门狗,有时又指定<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">leaseTime</font>,这会造成行为的不一致,增加维护的复杂性。 - 理解它不是"银弹" :
看门狗解决了持有者存活时的续期问题,但无法解决所有分布式锁的难题,例如:- Redis 脑裂:在主从切换时,可能会导致锁在旧主和新主上同时被持有。
- 时钟跳跃:如果服务器发生剧烈的时钟跳跃,可能会影响 TTL 的判断。
对于可靠性要求极高的场景,可以考虑使用 Redisson 的 RedLock(红锁),但它也带来了更高的复杂性和性能开销。
六、 总结
Redisson 的看门狗机制是一个精妙而实用的设计,它将开发者从手动设置锁超时时间的困境中解放出来。通过后台守护线程的定期续期,它实现了 "业务不停,锁不释放" 的智能效果,极大地增强了分布式锁在不可靠网络和复杂业务环境下的健壮性。
理解其原理、正确使用并规避其陷阱,是你在分布式系统中用好 Redisson 分布式锁的关键一步。