Redisson分布式锁核心原理
Redisson分布式锁利用Lua脚本保证原子性,通过“看门狗”机制自动为锁续期,解决了锁超时失效问题。它还实现了可重入和基于发布订阅的高效等待唤醒,功能强大且可靠。
我们来深入浅出地讲解一下 Redisson 分布式锁的原理。
相比于我们自己用 SETNX + EXPIRE 实现的简易版分布式锁,Redisson 提供了功能更强大、更可靠的实现。它的核心优势在于解决了简易版锁的几大痛点:
- 原子性问题:
SETNX和EXPIRE分开执行不是原子操作。 - 锁自动续期:如果业务执行时间超过锁的过期时间,锁会被自动释放,导致其他线程拿到锁,引发并发问题。
- 锁被误删:线程A持有的锁,可能被线程B错误地释放。
- 可重入性:同一个线程无法重复获取自己已经持有的锁。
- 阻塞等待:其他线程获取不到锁时,应该是高效地等待,而不是疯狂地循环(自旋)消耗CPU。
Redisson 通过一系列精巧的设计解决了以上所有问题。其核心原理可以概括为:Lua 脚本保证原子性 + Watchdog(看门狗)实现自动续期 + Redis 数据结构实现可重入和锁识别。
下面我们分点来详细解析。
1. 核心基石:Lua 脚本保证原子性
Redisson 的所有加锁和解锁操作都不是简单的 Redis 命令,而是通过执行预先编写好的 Lua 脚本来完成的。
为什么用 Lua 脚本?
因为 Redis 执行 Lua 脚本是原子性的。整个脚本中的所有命令会在 Redis 服务器端作为一个整体执行,期间不会被其他命令打断。这完美解决了 SETNX 和 EXPIRE 分步执行的原子性问题。
加锁的 Lua 脚本(逻辑伪代码):
-- KEYS[1] 是锁的 key,比如 "myLock"
-- ARGV[1] 是锁的过期时间,比如 30000 毫秒
-- ARGV[2] 是锁的唯一标识,通常是 UUID + 线程ID
-- 'exists KEYS[1]' 检查锁是否存在
if (redis.call('exists', KEYS[1]) == 0) then
-- 如果锁不存在,使用 HSET 创建一个 Hash 结构来存储锁
-- field 是锁的唯一标识,value 是重入次数 1
redis.call('hset', KEYS[1], ARGV[2], 1);
-- 设置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
-- 返回 nil,表示加锁成功
return nil;
end;
-- 如果锁已经存在,检查 field (锁的唯一标识) 是否是当前线程的
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 如果是当前线程持有,说明是重入
-- 重入次数 +1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
-- 重新设置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
-- 返回 nil,表示加锁成功
return nil;
end;
-- 如果锁存在但不是当前线程的,返回锁的剩余过期时间 (TTL)
return redis.call('pttl', KEYS[1]);
从这个脚本可以看出:
- 数据结构:Redisson 锁在 Redis 中不是一个简单的 String,而是一个 Hash 结构。Key 是锁名,Field 是唯一的客户端ID+线程ID,Value 是这把锁被重入的次数。
- 可重入性:通过检查 Hash 中是否存在当前线程的 Field,实现了锁的可重入。每次重入,只是将 Value 加一。
- 防止误删:锁的唯一标识(
ARGV[2])确保了只有加锁的客户端才能解锁。
2. 核心机制:Watchdog(看门狗)自动续期
这是 Redisson 分布式锁最核心、最亮眼的设计。
解决了什么问题?
解决了业务执行时间过长,导致锁提前过期而被自动释放的问题。
工作原理:
- 加锁成功后启动:当一个线程成功获取到锁之后,如果用户没有指定锁的释放时间(即调用
lock()方法),Redisson 会默认设置一个 30 秒的过期时间。同时,它会在后台启动一个定时任务,这就是“看门狗”。 - 定时检查与续期:看门狗会每隔
lockWatchdogTimeout / 3(默认是 30s / 3 = 10s)检查一下,当前持有锁的客户端是否还“活着”。如果还活着,就会自动将锁的过期时间重置为 30 秒。这个操作被称为“续期”或“续命”。 - 锁释放后停止:一旦业务执行完毕,线程调用
unlock()方法释放了锁,或者持有锁的客户端宕机了,看门狗就会停止。锁因为不再被续期,会在 30 秒后自动过期,从而避免了死锁。
流程图示:线程A.lock() -> 加锁成功 (TTL=30s) -> 启动看门狗 -> (过了10s) -> 看门狗续期 (TTL重置为30s) -> (又过了10s) -> 看门狗再次续期 (TTL重置为30s) -> 业务执行完毕 -> 线程A.unlock() -> 看门狗停止 -> 锁被删除。
3. 高效等待:基于发布/订阅(Pub/Sub)的唤醒机制
当一个线程尝试获取锁失败后,它不会一直循环尝试(空轮询),那样会浪费大量 CPU。
Redisson 的做法:
- 订阅频道:获取锁失败的线程会订阅一个与锁名相关的特殊 Redis Channel(例如:
redisson_lock__channel:{myLock})。 - 进入等待:订阅后,线程会进入一个类似于
Semaphore的等待状态,阻塞自己,等待信号。 - 发布信号:当持有锁的线程执行完业务,调用
unlock()方法释放锁时,它会向那个特殊的 Channel 发布一条消息。 - 唤醒并重试:所有订阅了该 Channel 的等待线程都会收到这条消息,然后被唤醒,开始重新尝试获取锁。
这种事件驱动的方式,取代了无效的循环,极大地提高了性能和效率,避免了对 Redis 服务器不必要的轮询压力。
4. 整体流程总结(以 lock() 为例)
- 尝试加锁:客户端线程调用
redisson.getLock("myLock").lock()。 - 执行 Lua 脚本:Redisson 将加锁逻辑封装成 Lua 脚本发送给 Redis 执行,保证原子性。
- 成功:
- 脚本在 Redis 中创建一个 Hash,记录下线程ID和重入次数。
- 设置默认的 30 秒过期时间。
- Redisson 客户端启动一个看门狗(Watchdog)后台线程,每 10 秒检查一次,如果锁还存在,就重置过期时间为 30 秒。
lock()方法返回,线程继续执行业务代码。
- 失败(锁被其他线程持有):
- 当前线程通过 Redis 的 Pub/Sub 机制,订阅锁释放的消息频道,并进入阻塞等待状态。
- 当锁被释放时,持有锁的客户端会发布一条消息。
- 等待的线程被唤醒,从第一步开始重新尝试获取锁。
- 成功:
5. 解锁流程
- 执行 Lua 脚本:调用
unlock()方法时,同样会执行一段 Lua 脚本。 - 身份校验:脚本首先会判断当前锁的持有者是否是请求解锁的这个线程(通过对比 Hash 里的 Field)。
- 不是:直接返回,什么也不做(防止误删)。
- 是:
- 将重入次数减 1。
- 如果减 1 后次数仍大于 0,说明是重入锁,只更新 Hash 的值,不删除锁。
- 如果减 1 后次数等于 0,说明锁可以被完全释放,则删除这个锁的 Key。
- 发布消息:锁被成功删除后,会向消息频道发布一条消息,唤醒其他正在等待的线程。
- 取消看门狗:客户端会取消看门狗的定时任务。
简单代码示例
// 1. 获取 RedissonClient 实例
RedissonClient redisson = Redisson.create(config);
// 2. 获取锁实例
RLock lock = redisson.getLock("my-lock");
try {
// 3. 尝试加锁,这里会一直等待直到获取锁
// 默认超时30秒,但有看门狗会自动续期
lock.lock();
// 4. 执行业务逻辑
System.out.println("线程 " + Thread.currentThread().getId() + " 获取了锁,开始执行任务...");
Thread.sleep(40000); // 模拟一个长时间任务,超过默认的30秒
System.out.println("线程 " + Thread.currentThread().getId() + " 任务执行完毕。");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 5. 确保锁一定会被释放
if (lock.isHeldByCurrentThread()) {
lock.unlock();
System.out.println("线程 " + Thread.currentThread().getId() + " 释放了锁。");
}
}
// 关闭客户端
redisson.shutdown();
在这个例子中,即使业务执行了 40 秒,超过了默认的 30 秒过期时间,锁也不会丢失,因为看门狗会在第 10、20、30 秒时自动将锁的有效期续期回 30 秒,从而保证了业务逻辑的正确执行。
总结来说,Redisson 分布式锁是一个设计精良、功能完备且生产可用的工业级解决方案。