Redission 分布式锁学习

111 阅读9分钟

Redisson 实现分布式锁的「阻塞等待」和「锁释放通知」机制,核心依赖于 ​​Redis 的 Pub/Sub(发布/订阅)功能​​ 和 ​​Java 的并发工具(如 Semaphore)​​。整个过程设计精巧,确保了在分布式环境下的高效协作。下面详细拆解其实现原理:


​核心流程:获取锁失败时如何阻塞等待?​

当线程 A 尝试获取锁(例如调用 lock.lock())但锁已被其他客户端(线程 B)持有时:

  1. ​尝试获取锁 (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)​​,表示锁被其他客户端持有,获取失败。

  2. ​订阅锁释放频道:​

    • 当获取锁失败(返回 TTL > 0)时,线程 A 会 ​​订阅一个特定的 Redis 频道​​。这个频道的名称与锁名称相关,例如 redisson_lock__channel:{myLock}
    • 订阅操作是异步的,Redisson 使用 Netty 的异步 API 处理 Redis 的 Pub/Sub。
  3. ​进入阻塞等待状态:​

    • 线程 A 会尝试在一个 ​​Java Semaphore(信号量)​​ 上获取许可 (semaphore.tryAcquire())。这个 Semaphore 是 Redisson 为​​每个锁名称​​在客户端内部维护的。
    • 初始时,Semaphore 的许可数为 0。
    • tryAcquire() 会尝试获取许可,如果失败(因为许可为0),线程 A 会​​阻塞​​在 tryAcquire(long waitTime, TimeUnit unit) 方法上,等待指定的最大时间(通常是锁的剩余 TTL + 一定缓冲时间)。

​核心流程:锁释放时如何通知等待线程?​

当持有锁的线程 B 释放锁时(调用 lock.unlock()):

  1. ​释放锁操作:​

    • 线程 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,作为信号)。

  2. ​订阅者收到通知:​

    • 所有订阅了 redisson_lock__channel:{myLock} 频道的客户端(包括线程 A 所在的客户端)都会收到这条 "锁已释放" 的消息。
    • Redisson 客户端的 Netty 事件处理器会捕获到这个 Pub/Sub 消息。
  3. ​唤醒阻塞线程:​

    • 收到锁释放消息后,Redisson 客户端内部会找到与锁名称 myLock 关联的那个 ​​Semaphore​​。
    • 它调用 semaphore.release()​释放一个许可​​。
    • 这个 release() 调用会​​唤醒一个正在该 Semaphore 上阻塞等待的线程​​(比如线程 A)。唤醒遵循一定的公平性(通常是 FIFO)。
  4. ​被唤醒线程再次尝试获取锁:​

    • 线程 A 从 semaphore.tryAcquire() 的阻塞中醒来。
    • 它​​立即再次尝试执行步骤 1 的 Lua 脚本​​去获取锁。
    • ​为什么需要再次尝试?​​ 因为可能有多个线程同时被唤醒(或收到通知),锁只能被一个线程抢到。其他线程会再次进入步骤 2-3(订阅、阻塞等待)。

​关键设计要点与优化​

  1. ​避免无效轮询:​

    • 使用 Pub/Sub 通知机制避免了客户端不断轮询 Redis 检查锁状态,大大减少了网络开销和 Redis 压力。
  2. ​Semaphore 的作用:​

    • ​协调本地线程​​:Semaphore 是 Java 端的同步工具,用于管理​​同一个 JVM 内​​等待同一个锁的多个线程。它确保了在收到 Redis 通知时,只有一个(或公平地选择)等待线程被唤醒去尝试抢锁。
    • ​避免惊群效应​​:Pub/Sub 消息会发给所有订阅的客户端。每个客户端内部的 Semaphore 确保只有少量线程(通常是1个)被唤醒去竞争,而不是所有等待线程一拥而上冲击 Redis。
  3. ​超时与兜底:​

    • 线程在 Semaphore 上阻塞等待时设置了超时(通常是锁的剩余 TTL + 一定时间)。如果在这个时间内没有收到锁释放通知(可能因为消息丢失、网络问题或持有锁的客户端崩溃但 TTL 未到),线程会​​主动取消订阅频道​​,然后​​再次尝试获取锁​​(回到步骤 1)。这提供了鲁棒性。
  4. ​频道管理:​

    • Redisson 会智能地管理订阅。当某个锁没有等待线程时,客户端会取消订阅其频道,避免不必要的资源消耗。
  5. ​公平性:​

    • Redisson 也提供了 RedissonFairLock,它在 Redis 端使用列表维护等待队列,实现了更严格的 FIFO 公平获取锁,代价是更复杂和略高的开销。

​总结流程​

  1. ​获取锁失败​​ -> ​​订阅锁释放频道​​ -> ​​在本地 Semaphore 阻塞等待​​。
  2. ​锁被释放​​ -> ​​Redis 发布释放消息​​ -> ​​订阅客户端收到消息​​ -> ​​释放 Semaphore 许可​​ -> ​​唤醒一个等待线程​​。
  3. ​被唤醒线程​​ -> ​​立即再次尝试获取锁​​ -> (成功则执行业务;失败则回到步骤 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. 看门狗如何知道是否需要续期?(不依赖监控业务线程!)​

看门狗线程​​并不直接监控​​获取锁的那个业务线程是死是活。它判断是否需要续期的依据是:

  1. ​锁的持有状态 (InternalLockState):​​ 这是最核心的!

    • 当业务线程调用 lock.lock() 成功获取锁时,Redisson 客户端内部会记录这个锁被当前 JVM 进程持有(通常记录在某个 ConcurrentHashMap 里,键是锁名,值是一个包含持有计数、持有线程ID等信息的对象)。

    • 看门狗线程定期执行时,会检查这个 ​​内部状态记录​​:

      • ​如果状态显示这个锁仍然被当前 JVM 进程持有​​:看门狗就向 Redis 发送 PEXPIRE 命令,重置锁的 TTL。
      • ​如果状态显示这个锁已被释放(例如,业务线程调用了 unlock())或当前进程不再持有它​​:看门狗线程就会停止续期,并最终结束自己。
  2. ​客户端实例的生命周期:​

    • 看门狗线程是 Redisson 客户端 (RedissonClient 实例) 的一部分。
    • 当你关闭 Redisson 客户端 (redissonClient.shutdown()) 时,它会清理所有资源,包括停止所有关联的看门狗线程。

​3. 结合场景:业务线程异常 vs 锁持有状态​

  • ​场景一:业务线程异常崩溃,但未调用 unlock() (JVM 进程还在)​

    • ​内部锁状态​​:由于业务线程崩溃前没有调用 unlock(),Redisson 内部仍然记录着该锁被当前进程持有。
    • ​看门狗行为​​:看门狗线程检查内部状态 → “哦,锁还被我们持有着呢” → 继续发送 PEXPIRE 续期。
    • ​后果​​:锁​​不会释放​​!这就是为什么​​必须在 finally 块中调用 unlock()​ 的根本原因。看门狗只认内部状态,不管具体哪个线程获取的锁,也不管那个线程是否还活着。
  • ​场景二:业务线程正常执行并调用 unlock()

    • ​内部锁状态​​:unlock() 调用会更新内部状态,标记该锁已被释放。
    • ​看门狗行为​​:看门狗下次检查内部状态 → “锁已经释放了” → 停止续期,线程退出。
    • ​后果​​:锁被正确释放。
  • ​场景三:整个 JVM 进程崩溃​

    • ​内部锁状态​​:随 JVM 消失而消失。
    • ​看门狗线程​​:随 JVM 崩溃而终止。
    • ​Redis 端​​:锁的 TTL 不再被续期,到期后 Redis 自动删除,锁释放。

​总结:看门狗的工作逻辑​

  1. ​启动条件​​:使用无参 lock() 成功获取锁后,为该锁名启动一个看门狗线程(如果尚未存在)。

  2. ​续期逻辑​​:

    • 定期(默认 10 秒)执行。

    • ​检查内部状态​​:当前 Redisson 客户端实例是否还记录着持有这个锁?

      • 如果 ​​是​​ → 发送 PEXPIRE 命令续期。
      • 如果 ​​否​​ → 停止续期,线程结束。
  3. ​停止条件​​:

    • 业务线程调用 unlock(),更新了内部状态(主要方式)。
    • Redisson 客户端被关闭 (shutdown())。
    • 整个 JVM 进程崩溃(强制停止)。

​核心思想:看门狗只忠诚地根据 Redisson 客户端内部记录的锁持有状态来决定是否续期。它不关心具体哪个业务线程获取了锁,也不直接监控业务线程的死活。业务线程有责任在完成后(包括异常时)调用 unlock() 来更新内部状态,这才是通知看门狗“任务完成,无需再续”的标准方式。​​ 依赖 JVM 崩溃后 TTL 过期是最后的、被动的安全保障。