很多人第一次用 Redisson 分布式锁,都会看到一句话:默认有看门狗机制,会自动续期。
这句话听起来很简单,但源码里真正有意思的是:Redisson 到底什么时候开启看门狗?它多久续一次?如果业务线程挂了,它又怎么保证锁最终能释放?
今天我们就顺着 RedissonLock 的源码,把这条链路捋清楚。
先把 waitTime 和 leaseTime 说清楚
看 Redisson 分布式锁源码之前,最好先把两个时间概念分清楚:waitTime 和 leaseTime。
很多人刚看到这个方法会有点懵:
lock.tryLock(waitTime, leaseTime, unit);
这两个参数都和时间有关,但管的不是一件事。
waitTime 说的是:我最多愿意等多久去抢这把锁。
比如:
lock.tryLock(3, 10, TimeUnit.SECONDS);
这里的 3 秒就是 waitTime。如果锁现在被别人持有,当前线程最多等 3 秒。3 秒内抢到了,返回 true;3 秒后还没抢到,返回 false。
它影响的是“抢锁阶段”。
leaseTime 说的是:我抢到锁之后,这把锁最多自动保留多久。
还是这个例子:
lock.tryLock(3, 10, TimeUnit.SECONDS);
这里的 10 秒就是 leaseTime。如果当前线程成功拿到锁,Redisson 会给 Redis 里的锁设置 10 秒过期时间。10 秒后如果还没手动 unlock(),锁也会自动过期。
它影响的是“持锁阶段”。
所以一句话区分就是:
waitTime 管你最多等多久,leaseTime 管锁最多活多久。
这点和看门狗机制关系很大。看门狗只关心 leaseTime。只要你显式指定了 leaseTime,Redisson 就认为你已经知道这把锁该活多久,所以不会帮你续期。
只有你没有指定 leaseTime,Redisson 才会启动看门狗。
看门狗什么时候启动?
我们直接看 RedissonLock 里的加锁逻辑。
private RFuture<Boolean> tryAcquireOnceAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
CompletionStage<Boolean> acquiredFuture;
if (leaseTime > 0) {
acquiredFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
} else {
// internalLockLeaseTime 是默认的看门狗时间
acquiredFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
}
acquiredFuture = handleNoSync(threadId, acquiredFuture);
CompletionStage<Boolean> f = acquiredFuture.thenApply(acquired -> {
if (acquired) {
if (leaseTime > 0) {
internalLockLeaseTime = unit.toMillis(leaseTime);
} else {
// 看门狗续期逻辑
scheduleExpirationRenewal(threadId);
}
}
return acquired;
});
return new CompletableFutureWrapper<>(f);
}
这段代码刚看的时候可能有点绕,但其实就一个判断:
如果 leaseTime > 0,说明用户明确指定了锁的过期时间。Redisson 只负责把这个过期时间写进 Redis,不会开启看门狗。
如果 leaseTime <= 0,说明用户没有指定锁的过期时间。Redisson 会使用 internalLockLeaseTime,并且在加锁成功后调用 scheduleExpirationRenewal(threadId)。
这个 scheduleExpirationRenewal,就是看门狗续期逻辑的入口。
那 internalLockLeaseTime 默认是多少?
public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
super(commandExecutor, name);
this.commandExecutor = commandExecutor;
this.internalLockLeaseTime = getServiceManager().getCfg().getLockWatchdogTimeout();
this.pubSub = commandExecutor.getConnectionManager().getSubscribeService().getLockPubSub();
}
继续看配置:
/**
* This parameter is only used if lock has been acquired without leaseTimeout parameter definition.
* Lock expires after <code>lockWatchdogTimeout</code> if watchdog
* didn't extend it to next <code>lockWatchdogTimeout</code> time interval.
*
* Default is 30000 milliseconds
*/
public Config setLockWatchdogTimeout(long lockWatchdogTimeout) {
this.lockWatchdogTimeout = lockWatchdogTimeout;
return this;
}
默认是 30000 毫秒,也就是 30 秒。
所以平时我们写:
lock.lock();
或者:
lock.tryLock(3, TimeUnit.SECONDS);
这类没有明确指定 leaseTime 的加锁方式,Redisson 会先给锁设置 30 秒过期时间,然后启动后台续期任务。
但如果你写的是:
lock.lock(10, TimeUnit.SECONDS);
或者:
lock.tryLock(3, 10, TimeUnit.SECONDS);
Redisson 就不会启动看门狗。因为你已经告诉它:这把锁最多活 10 秒。
Redis 里的锁长什么样?
看门狗续期之前,我们还得先知道 Redisson 的锁在 Redis 里是怎么存的。
Redisson 的可重入锁不是简单地存一个字符串,而是用了 Hash。
加锁的 Lua 脚本在 tryLockInnerAsync 里:
return evalWriteSyncedNoRetryAsync(getRawName(), LongCodec.INSTANCE, command,
"if ((redis.call('exists', KEYS[1]) == 0) " +
"or (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]);",
Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
这里的 KEYS[1] 是锁名。
ARGV[2] 是当前线程对应的 lock name,是由 Redisson 客户端 id 和线程 id 拼出来。
逻辑也不复杂:
如果锁不存在,说明没人持有锁,可以加锁。
如果锁已经存在,但 Hash 里存在当前线程对应的 field,说明这是同一个线程在重入,也可以加锁。
加锁成功后,执行:
redis.call('hincrby', KEYS[1], ARGV[2], 1)
redis.call('pexpire', KEYS[1], ARGV[1])
hincrby 用来记录重入次数,pexpire 用来设置锁的过期时间。
打个比方,Redis 里的数据大概像这样:
lock_key:
clientId:threadId -> 1
如果同一个线程重入一次,就变成:
lock_key:
clientId:threadId -> 2
这也是为什么释放锁时不能直接删 key,而是要先把重入次数减一。
续期任务是怎么挂上去的?
刚才说到,加锁成功后会调用:
scheduleExpirationRenewal(threadId);
这个方法在 RedissonBaseLock 里:
protected void scheduleExpirationRenewal(long threadId) {
renewalScheduler.renewLock(getRawName(), threadId, getLockName(threadId));
}
protected void cancelExpirationRenewal(Long threadId, Boolean unlockResult) {
renewalScheduler.cancelLockRenewal(getRawName(), threadId);
}
它没有自己做续期,而是交给了 LockRenewalScheduler。
public void renewLock(String name, Long threadId, String lockName) {
reference.compareAndSet(null, new LockTask(internalLockLeaseTime, executor, batchSize));
LockTask task = reference.get();
task.add(name, lockName, threadId);
}
这里有个细节挺关键:LockRenewalScheduler 不是每来一把锁就新建一个线程。
它内部维护了一个 LockTask,同一类锁的续期任务会被收拢到这个 task 里,再按批次续期。
这也是 Redisson 比较成熟的地方。看门狗听起来像“每把锁都有一条狗”,但源码里不是这么干的。它更像一个统一的后台巡检任务,定期扫一批还需要续期的锁。
多久续一次?
继续看 RenewalTask 的 schedule 方法:
public void schedule() {
if (!running.get()) {
return;
}
long internalLockLeaseTime = executor.getServiceManager().getCfg().getLockWatchdogTimeout();
executor.getServiceManager().newTimeout(this, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
}
答案就在这里。
Redisson 不是等锁快过期了才续,而是每隔 lockWatchdogTimeout / 3 续一次。
默认 lockWatchdogTimeout 是 30 秒,所以默认每 10 秒续一次。
为什么要这么早续?
因为分布式环境里不能把时间卡得太死。网络抖动、Redis 慢查询、JVM STW、客户端线程调度延迟,都可能让续期动作晚一点执行。
如果锁 30 秒过期,你第 29 秒才续,一次小抖动就可能让锁意外过期。提前到三分之一时间续期,本质上是在给系统留缓冲。
真正续期时做了什么?
续期的核心逻辑在 LockTask 里。
CompletionStage<List<String>> f = executor.syncedEval(firstName, LongCodec.INSTANCE,
new RedisCommand<>("EVAL", new ContainsDecoder<>(keys)),
"local result = {} " +
"for i = 1, #KEYS, 1 do " +
"if (redis.call('hexists', KEYS[i], ARGV[i + 1]) == 1) then " +
"redis.call('pexpire', KEYS[i], ARGV[1]); " +
"table.insert(result, 1); " +
"else " +
"table.insert(result, 0); " +
"end; " +
"end; " +
"return result;",
new ArrayList<>(keys),
args.toArray());
这段 Lua 做得很谨慎。
它不是看到一个 key 就直接 pexpire,而是先判断:
redis.call('hexists', KEYS[i], ARGV[i + 1])
也就是说,它会确认这个锁的 Hash 里,是否还存在当前线程对应的 field。
如果存在,说明这把锁仍然属于当前 Redisson 客户端里的当前线程,于是执行:
redis.call('pexpire', KEYS[i], ARGV[1])
把过期时间重新设置为 lockWatchdogTimeout。
如果不存在,说明锁可能已经释放了,也可能已经不属于当前线程了,那就不能续。
看到这里,看门狗机制就很清楚了。
Redisson 的看门狗,本质上就是一个后台定时任务:只要确认锁还属于当前线程,就周期性把 Redis key 的过期时间重新设置为 lockWatchdogTimeout。
释放锁时,看门狗怎么停?
看门狗不能一直续。业务执行完之后,调用 unlock(),续期任务也要停掉。
释放锁的入口在 RedissonBaseLock:
private RFuture<Void> unlockAsync0(long threadId) {
CompletionStage<Boolean> future = unlockInnerAsync(threadId);
CompletionStage<Void> f = future.handle((res, e) -> {
cancelExpirationRenewal(threadId, res);
if (e != null) {
if (e instanceof CompletionException) {
throw (CompletionException) e;
}
throw new CompletionException(e);
}
if (res == null) {
IllegalMonitorStateException cause = new IllegalMonitorStateException(
"attempt to unlock lock, not locked by current thread by node id: "
+ id + " thread-id: " + threadId);
throw new CompletionException(cause);
}
return null;
});
return new CompletableFutureWrapper<>(f);
}
这里不管释放是否异常,都会先调用:
cancelExpirationRenewal(threadId, res);
最终会走到 RenewalTask:
void cancelExpirationRenewal(String name, Long threadId) {
LockEntry task = name2entry.get(name);
if (task == null) {
return;
}
if (threadId != null) {
task.removeThreadId(threadId);
}
if (threadId == null || task.hasNoThreads()) {
name2entry.remove(name);
stop();
}
}
这里还照顾了可重入锁的情况。
如果同一个线程加锁两次,只释放一次,不能把续期任务直接取消。因为锁还没真正释放,只是重入次数减了一。
LockEntry 里维护了线程和重入次数:
public void addThreadId(long threadId, String lockName) {
threadId2counter.compute(threadId, (t, counter) -> {
counter = Optional.ofNullable(counter).orElse(0);
counter++;
threadsQueue.add(threadId);
return counter;
});
threadId2lockName.putIfAbsent(threadId, lockName);
}
public void removeThreadId(long threadId) {
threadId2counter.computeIfPresent(threadId, (t, counter) -> {
counter--;
if (counter == 0) {
threadsQueue.removeIf(v -> v == threadId);
threadId2lockName.remove(threadId);
return null;
}
return counter;
});
}
只有这个线程对应的重入次数归零了,续期记录才会被移除。
这也解释了一个常见问题:为什么 Redisson 分布式锁一定要保证加锁和解锁是同一个线程?
因为它在 Redis Hash 里记录的 field,本身就带着线程身份。不是这个线程持有的锁,你不能随便解。
如果客户端解锁失败,会不会一直续期?
这是一个很容易被忽略的问题。
既然看门狗是在客户端侧续期,那如果我调用了 unlock(),但是解锁失败了,会不会出现一种很尴尬的情况:Redis 里的锁没删掉,客户端还一直帮它续期,最后锁永远不释放?
从源码看,一般不会。
关键还是刚才这段:
CompletionStage<Void> f = future.handle((res, e) -> {
cancelExpirationRenewal(threadId, res);
if (e != null) {
if (e instanceof CompletionException) {
throw (CompletionException) e;
}
throw new CompletionException(e);
}
if (res == null) {
IllegalMonitorStateException cause = new IllegalMonitorStateException(
"attempt to unlock lock, not locked by current thread by node id: "
+ id + " thread-id: " + threadId);
throw new CompletionException(cause);
}
return null;
});
注意顺序。
Redisson 是先执行:
cancelExpirationRenewal(threadId, res);
然后才判断 e != null,也就是才处理解锁异常。
换句话说,只要解锁流程走到了这个 handle 回调里,即使 unlockInnerAsync 返回的是异常,Redisson 也会先把当前线程对应的续期关系取消掉。
所以客户端解锁失败,并不会让看门狗一直续期下去。
但这里要说得更准确一点:解锁失败不等于 Redis 里的锁一定已经删掉了。
可能有几种情况。
第一种,解锁命令根本没发到 Redis。比如网络在发送前就失败了。这时 Redis 里的锁还在,但客户端侧续期已经取消了,所以它最多再活一个剩余 TTL,之后 Redis 会自动删除。
第二种,解锁命令已经在 Redis 执行成功了,只是响应回客户端时丢了。客户端看到的是失败,但 Redis 里的锁其实已经释放了。
第三种,可重入锁只释放了一层。比如同一个线程 lock() 了两次,只 unlock() 一次,那 Redis 里的重入次数还没归零,锁仍然存在。这种情况下不能简单理解成“解锁失败”,它只是还没完全释放。
所以这里真正重要的结论是:
Redisson 在解锁流程开始后,会先撤掉客户端侧的续期关系。即使 Redis 里的锁因为异常没有被立刻删除,它也不会被看门狗无限续命,最终会靠 TTL 自动过期。
如果客户端挂了怎么办?
这是看门狗机制最关键的地方。
看门狗能续期,是因为 Redisson 客户端还活着,后台定时任务还能继续跑。
如果 JVM 崩了,机器断电了,容器被杀了,续期任务自然也就没了。
这时候 Redis 里的锁不会立刻消失,但它还有一个最后的过期时间。默认情况下,最多再等 30 秒,也就是一个 lockWatchdogTimeout,Redis 会自动把这个 key 删除。
所以 Redisson 不是让锁永不过期。
它真正做的是:
客户端活着,就持续续期,避免业务没执行完锁却过期;客户端死了,就停止续期,让 Redis 兜底自动释放锁。
这个设计刚好解决了两个相反的问题。
如果没有续期,业务执行超过锁过期时间,锁可能提前释放,别的线程进来,临界区就乱了。
如果无限续期,客户端挂了锁还不释放,后面的业务全堵住。
Redisson 看门狗做的是中间这件事:活着就续,死了就过期。
最后总结一下
Redisson 分布式锁看门狗机制可以拆成几句话:
waitTime管抢锁时最多等多久,leaseTime管抢到锁后最多持有多久。- 只有不指定
leaseTime时,Redisson 才会启动看门狗。 - 默认锁过期时间是
lockWatchdogTimeout,也就是 30 秒。 - 看门狗默认每
lockWatchdogTimeout / 3执行一次,也就是 10 秒续一次。 - 续期前会先判断 Redis Hash 里是否还存在当前线程的 field,存在才续。
- 正常
unlock()后会取消续期。 - 即使客户端解锁失败,Redisson 也会先取消续期,锁不会一直被看门狗续下去。
- 客户端崩溃后续期任务停止,Redis key 会在最后一个过期周期后自动删除。
所以看门狗不是魔法。
它就是 Redisson 在客户端侧维护的一套定时续期机制。真正让它可靠的,不是“永远续期”,而是它始终给锁留了一个 Redis 过期时间兜底。
用大白话说就是:
人还在,狗就继续巡逻;人没了,狗也没了,门上的临时锁到点自己掉。