Redisson 实现分布式锁的「阻塞等待」和「锁释放通知」机制,核心依赖于 Redis 的 Pub/Sub(发布/订阅)功能 和 Java 的并发工具(如 Semaphore)。整个过程设计精巧,确保了在分布式环境下的高效协作。下面详细拆解其实现原理:
核心流程:获取锁失败时如何阻塞等待?
当线程 A 尝试获取锁(例如调用 lock.lock())但锁已被其他客户端(线程 B)持有时:
-
尝试获取锁 (SET + NX + PX):
-
Redisson 客户端向 Redis 发送一个 Lua 脚本(保证原子性)执行以下操作:
-- KEYS[1] = 锁名称 (e.g., "myLock") -- ARGV[1] = 锁超时时间 (毫秒, e.g., 30000) -- ARGV[2] = 客户端唯一标识 (UUID + ":" + threadId, e.g., "8743c9c0-0795-4907-87fd-6c719a6b4586:1") if (redis.call('exists', KEYS[1]) == 0) then redis.call('hset', KEYS[1], ARGV[2], 1); -- 锁不存在,成功获取 redis.call('pexpire', KEYS[1], ARGV[1]); -- 设置过期时间 return nil; -- 返回 nil 表示成功 end if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); -- 锁重入,计数+1 redis.call('pexpire', KEYS[1], ARGV[1]); -- 刷新过期时间 return nil; end return redis.call('pttl', KEYS[1]); -- 锁被他人持有,返回锁的剩余生存时间(毫秒) -
如果返回
nil,表示获取锁成功。 -
如果返回一个数字(剩余生存时间 TTL),表示锁被其他客户端持有,获取失败。
-
-
订阅锁释放频道:
- 当获取锁失败(返回 TTL > 0)时,线程 A 会 订阅一个特定的 Redis 频道。这个频道的名称与锁名称相关,例如
redisson_lock__channel:{myLock}。 - 订阅操作是异步的,Redisson 使用 Netty 的异步 API 处理 Redis 的 Pub/Sub。
- 当获取锁失败(返回 TTL > 0)时,线程 A 会 订阅一个特定的 Redis 频道。这个频道的名称与锁名称相关,例如
-
进入阻塞等待状态:
- 线程 A 会尝试在一个 Java Semaphore(信号量) 上获取许可 (
semaphore.tryAcquire())。这个 Semaphore 是 Redisson 为每个锁名称在客户端内部维护的。 - 初始时,Semaphore 的许可数为 0。
tryAcquire()会尝试获取许可,如果失败(因为许可为0),线程 A 会阻塞在tryAcquire(long waitTime, TimeUnit unit)方法上,等待指定的最大时间(通常是锁的剩余 TTL + 一定缓冲时间)。
- 线程 A 会尝试在一个 Java Semaphore(信号量) 上获取许可 (
核心流程:锁释放时如何通知等待线程?
当持有锁的线程 B 释放锁时(调用 lock.unlock()):
-
释放锁操作:
-
线程 B 向 Redis 发送一个 Lua 脚本释放锁:
-- KEYS[1] = 锁名称 -- KEYS[2] = 锁释放消息的频道名 (e.g., `redisson_lock__channel:{myLock}`) -- ARGV[1] = 锁释放消息内容 (固定值 0, 实际是一个信号) -- ARGV[2] = 锁超时时间 -- ARGV[3] = 客户端唯一标识 if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil; -- 锁不属于此客户端,无法释放 end local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); -- 重入计数减1 if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]); -- 重入计数未归零,只刷新过期时间 return 0; else redis.call('del', KEYS[1]); -- 重入计数归零,删除锁 redis.call('publish', KEYS[2], ARGV[1]); -- 关键!发布锁释放消息 return 1; end return nil; -
关键步骤:当锁被真正释放(重入计数归零并删除锁)时,脚本会执行
redis.call('publish', KEYS[2], ARGV[1]),向频道redisson_lock__channel:{myLock}发布一条消息(消息内容通常是一个简单的数字0,作为信号)。
-
-
订阅者收到通知:
- 所有订阅了
redisson_lock__channel:{myLock}频道的客户端(包括线程 A 所在的客户端)都会收到这条 "锁已释放" 的消息。 - Redisson 客户端的 Netty 事件处理器会捕获到这个 Pub/Sub 消息。
- 所有订阅了
-
唤醒阻塞线程:
- 收到锁释放消息后,Redisson 客户端内部会找到与锁名称
myLock关联的那个 Semaphore。 - 它调用
semaphore.release()释放一个许可。 - 这个
release()调用会唤醒一个正在该 Semaphore 上阻塞等待的线程(比如线程 A)。唤醒遵循一定的公平性(通常是 FIFO)。
- 收到锁释放消息后,Redisson 客户端内部会找到与锁名称
-
被唤醒线程再次尝试获取锁:
- 线程 A 从
semaphore.tryAcquire()的阻塞中醒来。 - 它立即再次尝试执行步骤 1 的 Lua 脚本去获取锁。
- 为什么需要再次尝试? 因为可能有多个线程同时被唤醒(或收到通知),锁只能被一个线程抢到。其他线程会再次进入步骤 2-3(订阅、阻塞等待)。
- 线程 A 从
关键设计要点与优化
-
避免无效轮询:
- 使用 Pub/Sub 通知机制避免了客户端不断轮询 Redis 检查锁状态,大大减少了网络开销和 Redis 压力。
-
Semaphore 的作用:
- 协调本地线程:Semaphore 是 Java 端的同步工具,用于管理同一个 JVM 内等待同一个锁的多个线程。它确保了在收到 Redis 通知时,只有一个(或公平地选择)等待线程被唤醒去尝试抢锁。
- 避免惊群效应:Pub/Sub 消息会发给所有订阅的客户端。每个客户端内部的 Semaphore 确保只有少量线程(通常是1个)被唤醒去竞争,而不是所有等待线程一拥而上冲击 Redis。
-
超时与兜底:
- 线程在 Semaphore 上阻塞等待时设置了超时(通常是锁的剩余 TTL + 一定时间)。如果在这个时间内没有收到锁释放通知(可能因为消息丢失、网络问题或持有锁的客户端崩溃但 TTL 未到),线程会主动取消订阅频道,然后再次尝试获取锁(回到步骤 1)。这提供了鲁棒性。
-
频道管理:
- Redisson 会智能地管理订阅。当某个锁没有等待线程时,客户端会取消订阅其频道,避免不必要的资源消耗。
-
公平性:
- Redisson 也提供了
RedissonFairLock,它在 Redis 端使用列表维护等待队列,实现了更严格的 FIFO 公平获取锁,代价是更复杂和略高的开销。
- Redisson 也提供了
总结流程
- 获取锁失败 -> 订阅锁释放频道 -> 在本地 Semaphore 阻塞等待。
- 锁被释放 -> Redis 发布释放消息 -> 订阅客户端收到消息 -> 释放 Semaphore 许可 -> 唤醒一个等待线程。
- 被唤醒线程 -> 立即再次尝试获取锁 -> (成功则执行业务;失败则回到步骤 1)。
这种结合 Redis Pub/Sub 的实时通知 和 Java Semaphore 的线程同步 的设计,使得 Redisson 分布式锁能够在锁释放时高效、及时地唤醒等待线程,避免了低效的轮询,同时保证了分布式环境下的协调有序性。
看门狗线程并不是为每个业务线程创建的,也不是通过直接监控业务线程的存活状态来判断是否续期。 它的工作方式更巧妙,是基于 锁的持有状态 和 客户端实例的生命周期。
让我们分解来看:
1. 看门狗线程的数量:按锁实例,非按业务线程
-
不是每个业务线程一个看门狗! 这是常见的误解。
-
实际规则:
- 当你通过
redissonClient.getLock("lockName")获取一个RLock对象时,这个RLock实例内部可能关联一个看门狗线程(如果后续用无参lock()获取锁)。 - 这个看门狗线程是 按
RLock实例(或者说按锁名)管理 的,而不是按调用lock()的业务线程。 - 同一个 JVM 进程内,对同一个锁名 (
lockName) 多次调用getLock()并lock(),通常只会有一个看门狗线程在负责续期这个锁。 (Redisson 内部有机制确保同一个锁名的看门狗是单例的)。 - 如果你获取了多个不同名称的锁 (e.g.,
getLock("lock1"),getLock("lock2")),并且都是用无参lock(),那么 每个锁会启动自己独立的看门狗线程。
- 当你通过
结论:看门狗线程的粒度是“锁实例”(由锁名标识),不是“业务线程”。
2. 看门狗如何知道是否需要续期?(不依赖监控业务线程!)
看门狗线程并不直接监控获取锁的那个业务线程是死是活。它判断是否需要续期的依据是:
-
锁的持有状态 (InternalLockState): 这是最核心的!
-
当业务线程调用
lock.lock()成功获取锁时,Redisson 客户端内部会记录这个锁被当前 JVM 进程持有(通常记录在某个 ConcurrentHashMap 里,键是锁名,值是一个包含持有计数、持有线程ID等信息的对象)。 -
看门狗线程定期执行时,会检查这个 内部状态记录:
- 如果状态显示这个锁仍然被当前 JVM 进程持有:看门狗就向 Redis 发送
PEXPIRE命令,重置锁的 TTL。 - 如果状态显示这个锁已被释放(例如,业务线程调用了
unlock())或当前进程不再持有它:看门狗线程就会停止续期,并最终结束自己。
- 如果状态显示这个锁仍然被当前 JVM 进程持有:看门狗就向 Redis 发送
-
-
客户端实例的生命周期:
- 看门狗线程是 Redisson 客户端 (
RedissonClient实例) 的一部分。 - 当你关闭 Redisson 客户端 (
redissonClient.shutdown()) 时,它会清理所有资源,包括停止所有关联的看门狗线程。
- 看门狗线程是 Redisson 客户端 (
3. 结合场景:业务线程异常 vs 锁持有状态
-
场景一:业务线程异常崩溃,但未调用
unlock()(JVM 进程还在)- 内部锁状态:由于业务线程崩溃前没有调用
unlock(),Redisson 内部仍然记录着该锁被当前进程持有。 - 看门狗行为:看门狗线程检查内部状态 → “哦,锁还被我们持有着呢” → 继续发送
PEXPIRE续期。 - 后果:锁不会释放!这就是为什么必须在
finally块中调用unlock() 的根本原因。看门狗只认内部状态,不管具体哪个线程获取的锁,也不管那个线程是否还活着。
- 内部锁状态:由于业务线程崩溃前没有调用
-
场景二:业务线程正常执行并调用
unlock()- 内部锁状态:
unlock()调用会更新内部状态,标记该锁已被释放。 - 看门狗行为:看门狗下次检查内部状态 → “锁已经释放了” → 停止续期,线程退出。
- 后果:锁被正确释放。
- 内部锁状态:
-
场景三:整个 JVM 进程崩溃
- 内部锁状态:随 JVM 消失而消失。
- 看门狗线程:随 JVM 崩溃而终止。
- Redis 端:锁的 TTL 不再被续期,到期后 Redis 自动删除,锁释放。
总结:看门狗的工作逻辑
-
启动条件:使用无参
lock()成功获取锁后,为该锁名启动一个看门狗线程(如果尚未存在)。 -
续期逻辑:
-
定期(默认 10 秒)执行。
-
检查内部状态:当前 Redisson 客户端实例是否还记录着持有这个锁?
- 如果 是 → 发送
PEXPIRE命令续期。 - 如果 否 → 停止续期,线程结束。
- 如果 是 → 发送
-
-
停止条件:
- 业务线程调用
unlock(),更新了内部状态(主要方式)。 - Redisson 客户端被关闭 (
shutdown())。 - 整个 JVM 进程崩溃(强制停止)。
- 业务线程调用
核心思想:看门狗只忠诚地根据 Redisson 客户端内部记录的锁持有状态来决定是否续期。它不关心具体哪个业务线程获取了锁,也不直接监控业务线程的死活。业务线程有责任在完成后(包括异常时)调用 unlock() 来更新内部状态,这才是通知看门狗“任务完成,无需再续”的标准方式。 依赖 JVM 崩溃后 TTL 过期是最后的、被动的安全保障。