很多人第一次用 Redisson 分布式锁,都会看到一句话:默认有看门狗机制,会自动续期。

这句话听起来很简单,但源码里真正有意思的是:Redisson 到底什么时候开启看门狗?它多久续一次?如果业务线程挂了,它又怎么保证锁最终能释放?

今天我们就顺着 RedissonLock 的源码,把这条链路捋清楚。

先把 waitTime 和 leaseTime 说清楚

看 Redisson 分布式锁源码之前,最好先把两个时间概念分清楚:waitTimeleaseTime

很多人刚看到这个方法会有点懵:

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 比较成熟的地方。看门狗听起来像“每把锁都有一条狗”,但源码里不是这么干的。它更像一个统一的后台巡检任务,定期扫一批还需要续期的锁。

多久续一次?

继续看 RenewalTaskschedule 方法:

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 过期时间兜底。

用大白话说就是:

人还在,狗就继续巡逻;人没了,狗也没了,门上的临时锁到点自己掉。