我们将重点分析 tryLock(long waitTime, long leaseTime, TimeUnit unit) 的实现机制,因为它最能体现“可重试”和 Redisson 分布式锁的核心思想。

核心设计思想

Redisson 实现分布式锁的核心是利用 Redis 的 Hash 结构以及 Pub/Sub(发布/订阅)机制。

  1. 锁的数据结构:一个Hash

    • Key: 你指定的锁的名称,例如 "myLock"

    • Field: UUID:threadId,一个由 UUID 和线程 ID 组成的唯一标识,用于标识哪个客户端的哪个线程持有了该锁。

    • Value: 一个数字,代表该线程对这个锁的重入次数

  2. 可重入性 (Reentrancy):通过检查 Hash 中的 Field 是否为当前线程的 UUID:threadId 来判断。如果是,就将 Value (重入次数) 加 1,实现可重入。

  3. 锁续期 (Watchdog):为了防止业务代码执行时间过长导致锁被自动释放,Redisson 设计了“看门狗”机制。当一个线程成功获取锁后,会有一个后台线程(Watchdog)定时检查该线程是否还存活,如果存活,就自动延长锁的过期时间。leaseTime 设置为 -1 时,该机制默认开启。

  4. 失败重试与休眠唤醒 (Retry & Wake-up):当一个线程尝试获取锁失败时,它不会盲目地循环。它会订阅一个与锁同名的 Redis Channel,然后进入休眠。当持有锁的客户端释放锁时,它会向该 Channel 发布一条消息。所有订阅了该 Channel 的等待线程都会被唤醒,然后再次尝试获取锁。这大大减少了无效的 CPU 轮询,提高了性能。

源码分析

我们以 Redisson 3.16.x 版本左右的源码为参考(不同版本实现细节可能略有差异,但核心思想一致)。分析的入口是 RedissonLock.java 中的 tryLock(long waitTime, long leaseTime, TimeUnit unit)

这个方法最终会调用到一个核心的 tryAcquireAsync 方法,这个方法是所有获取锁逻辑的中心。

// RedissonLock.java
private <T> RFuture<T> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    if (leaseTime != -1) {
        return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    // 如果 leaseTime 为 -1,则使用默认的锁超时时间,并启动看门狗
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
            TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
        if (e != null) {
            return;
        }
 
        // lock acquired
        if (ttlRemaining == null) {
            // 启动看门狗(核心的锁续期机制)
            scheduleExpirationRenewal(threadId);
        }
    });
    return (RFuture<T>) ttlRemainingFuture;
}

这里的核心是 tryLockInnerAsync 方法,它通过执行一段 Lua 脚本来保证操作的原子性。

tryLockInnerAsync 和 Lua 脚本

tryLockInnerAsync 是真正与 Redis 交互以尝试获取锁的方法。它会异步地执行一个 Lua 脚本。Lua 脚本是保证分布式锁原子性的关键。

我们来看一下这个 Lua 脚本的简化逻辑(源码中的脚本更复杂,包含了对 Pub/Sub 消息格式的处理):

-- KEYS[1]: 锁的名称,例如 "myLock"
-- ARGV[1]: 锁的过期时间(leaseTime)
-- ARGV[2]: 锁的持有者标识,例如 "uuid:threadId"
 
-- 1. 检查锁是否存在
if (redis.call('exists', KEYS[1]) == 0) then
    -- 1.1. 锁不存在,直接获取锁
    redis.call('hset', KEYS[1], ARGV[2], 1); -- 设置持有者和重入次数 1
    redis.call('pexpire', KEYS[1], ARGV[1]); -- 设置过期时间
    return nil; -- 返回 nil 表示成功
end;
 
-- 2. 检查是不是当前线程持有锁(实现可重入)
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    -- 2.1. 是当前线程,重入次数加 1
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]); -- 刷新过期时间
    return nil; -- 返回 nil 表示成功
end;
 
-- 3. 锁被其他线程持有,返回锁的剩余过期时间(TTL)
return redis.call('pttl', KEYS[1]);

脚本逻辑解读:

  1. if redis.call('exists', KEYS[1]) == 0: 如果锁(Hash Key)不存在,说明没有人在持有锁。当前线程就可以直接获取锁:创建一个 Hash,将 field 设置为自己的 uuid:threadIdvalue (重入次数) 设置为 1,并设置过期时间。脚本返回 nil,代表成功。

  2. if redis.call('hexists', KEYS[1], ARGV[2]) == 1: 如果锁存在,并且 field 正是当前线程的 uuid:threadId,说明是锁的重入。此时,直接将 value (重入次数) 加 1,并重置过期时间。脚本返回 nil,代表成功。

  3. return redis.call('pttl', KEYS[1]): 如果锁存在,但 field 不是当前线程的标识,说明锁被别人持有。此时,脚本返回该锁剩余的过期时间 (TTL),单位是毫秒。

可重试逻辑的实现

现在我们回到 Java 代码层面,看看 tryLock 如何利用 Lua 脚本的返回值来实现“重试”。

tryLock 的逻辑可以简化为如下伪代码:

long deadline = System.currentTimeMillis() + unit.toMillis(waitTime); // 计算最终截止时间
 
while (true) {
    long ttl = tryAcquire(); // 执行上述 Lua 脚本,尝试获取锁
 
    if (ttl == null) {
        // Lua 脚本返回 nil,表示成功获取锁
        return true;
    }
 
    // 获取锁失败,ttl 是锁的剩余过期时间
    if (System.currentTimeMillis() > deadline) {
        // 等待时间已过,获取锁失败
        return false;
    }
 
    // 计算需要休眠的时间
    // 关键点:不会一直等到锁过期,而是订阅一个 channel 后休眠一小段时间
    CountDownLatch latch = new CountDownLatch(1);
    subscribeToLockChannel(latch); // 订阅 channel,当锁释放时会收到通知,并调用 latch.countDown()
 
    // 等待,最多等待 ttl 的时间。
    // 如果在等待期间收到了锁释放的通知(latch.countDown()被调用),会立即被唤醒
    latch.await(ttl, TimeUnit.MILLISECONDS);
 
    // 循环继续,再次尝试获取锁 (tryAcquire)
}

核心步骤解析:

  1. 首次尝试: 调用 tryLockInnerAsync 执行 Lua 脚本。

    • 成功: 脚本返回 nil,方法直接返回 true

    • 失败: 脚本返回一个 ttl (剩余过期时间)。

  2. 进入等待:

    • 检查 waitTime 是否已经超时,如果超时,直接返回 false

    • 订阅 Channel: 线程会订阅一个以锁名命名的特殊 Channel (例如 redisson_lock__channel:{myLock})。

    • 休眠: 使用 CountDownLatch 或类似的同步工具,让当前线程进入休眠,等待一个信号。这个等待有一个超时时间,通常就是上次获取到的 ttl

  3. 唤醒与重试:

    • 情况A (锁被释放): 当持有锁的客户端执行 unlock() 操作时,它会向那个特殊的 Channel 发布一条“解锁”消息。所有订阅了该 Channel 的等待线程都会收到通知,它们的 latch.countDown() 会被调用,线程被立即唤醒,然后进入下一次循环,重新执行 Lua 脚本尝试获取锁。

    • 情况B (等待超时): 如果在 ttl 时间内都没有收到解锁消息(例如,原持锁客户端宕机,锁是靠 Redis 过期机制自动释放的),latch.await() 会因超时而返回。线程被唤醒,同样进入下一次循环,再次尝试获取锁。

这个“订阅-休眠-唤醒-重试”的循环会一直持续,直到成功获取锁,或者 waitTime 超时。

总结

Redisson tryLock 的“可重试”机制,其底层实现可以精炼为以下几点:

  1. 原子操作: 通过 Lua 脚本保证了“检查锁、获取锁、设置过期时间”等一系列操作的原子性,避免了竞态条件。

  2. 可重入性: 在 Lua 脚本中判断锁的持有者是否为当前线程,如果是,则增加重入计数,实现了可重入锁。

  3. 高效等待: 失败后并非盲目 while(true) 轮询,而是利用 Redis 的 Pub/Sub 机制。线程订阅解锁通知并进入休眠,由解锁操作来精准唤醒,大大降低了对 CPU 和 Redis 的无效请求压力。

  4. 看门狗续期: 通过后台线程定时延长锁的过期时间,解决了业务执行时间不确定可能导致的锁失效问题,增强了锁的可靠性。

这种结合了 Lua 脚本和 Pub/Sub 的设计,使得 Redisson 的分布式锁实现既高效又健壮,是目前业界非常成熟和广泛使用的方案。