我们将重点分析 tryLock(long waitTime, long leaseTime, TimeUnit unit) 的实现机制,因为它最能体现“可重试”和 Redisson 分布式锁的核心思想。
核心设计思想
Redisson 实现分布式锁的核心是利用 Redis 的 Hash 结构以及 Pub/Sub(发布/订阅)机制。
-
锁的数据结构:一个
Hash。-
Key: 你指定的锁的名称,例如
"myLock"。 -
Field:
UUID:threadId,一个由 UUID 和线程 ID 组成的唯一标识,用于标识哪个客户端的哪个线程持有了该锁。 -
Value: 一个数字,代表该线程对这个锁的重入次数。
-
-
可重入性 (Reentrancy):通过检查
Hash中的Field是否为当前线程的UUID:threadId来判断。如果是,就将Value(重入次数) 加 1,实现可重入。 -
锁续期 (Watchdog):为了防止业务代码执行时间过长导致锁被自动释放,Redisson 设计了“看门狗”机制。当一个线程成功获取锁后,会有一个后台线程(Watchdog)定时检查该线程是否还存活,如果存活,就自动延长锁的过期时间。
leaseTime设置为 -1 时,该机制默认开启。 -
失败重试与休眠唤醒 (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]);脚本逻辑解读:
-
if redis.call('exists', KEYS[1]) == 0: 如果锁(Hash Key)不存在,说明没有人在持有锁。当前线程就可以直接获取锁:创建一个 Hash,将field设置为自己的uuid:threadId,value(重入次数) 设置为 1,并设置过期时间。脚本返回nil,代表成功。 -
if redis.call('hexists', KEYS[1], ARGV[2]) == 1: 如果锁存在,并且field正是当前线程的uuid:threadId,说明是锁的重入。此时,直接将value(重入次数) 加 1,并重置过期时间。脚本返回nil,代表成功。 -
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)
}核心步骤解析:
-
首次尝试: 调用
tryLockInnerAsync执行 Lua 脚本。-
成功: 脚本返回
nil,方法直接返回true。 -
失败: 脚本返回一个
ttl(剩余过期时间)。
-
-
进入等待:
-
检查
waitTime是否已经超时,如果超时,直接返回false。 -
订阅 Channel: 线程会订阅一个以锁名命名的特殊 Channel (例如
redisson_lock__channel:{myLock})。 -
休眠: 使用
CountDownLatch或类似的同步工具,让当前线程进入休眠,等待一个信号。这个等待有一个超时时间,通常就是上次获取到的ttl。
-
-
唤醒与重试:
-
情况A (锁被释放): 当持有锁的客户端执行
unlock()操作时,它会向那个特殊的 Channel 发布一条“解锁”消息。所有订阅了该 Channel 的等待线程都会收到通知,它们的latch.countDown()会被调用,线程被立即唤醒,然后进入下一次循环,重新执行 Lua 脚本尝试获取锁。 -
情况B (等待超时): 如果在
ttl时间内都没有收到解锁消息(例如,原持锁客户端宕机,锁是靠 Redis 过期机制自动释放的),latch.await()会因超时而返回。线程被唤醒,同样进入下一次循环,再次尝试获取锁。
-
这个“订阅-休眠-唤醒-重试”的循环会一直持续,直到成功获取锁,或者 waitTime 超时。
总结
Redisson tryLock 的“可重试”机制,其底层实现可以精炼为以下几点:
-
原子操作: 通过 Lua 脚本保证了“检查锁、获取锁、设置过期时间”等一系列操作的原子性,避免了竞态条件。
-
可重入性: 在 Lua 脚本中判断锁的持有者是否为当前线程,如果是,则增加重入计数,实现了可重入锁。
-
高效等待: 失败后并非盲目
while(true)轮询,而是利用 Redis 的 Pub/Sub 机制。线程订阅解锁通知并进入休眠,由解锁操作来精准唤醒,大大降低了对 CPU 和 Redis 的无效请求压力。 -
看门狗续期: 通过后台线程定时延长锁的过期时间,解决了业务执行时间不确定可能导致的锁失效问题,增强了锁的可靠性。
这种结合了 Lua 脚本和 Pub/Sub 的设计,使得 Redisson 的分布式锁实现既高效又健壮,是目前业界非常成熟和广泛使用的方案。