当前位置:首页 > 问答 > 正文

Redisson 里 MultiLock 怎么加锁和释放,源码细节拆解聊聊

MultiLock 是什么

简单说,MultiLock 就是把多个独立的 RLock 锁(lock1, lock2, lock3)捆绑在一起,当作一个锁来用,它的核心目标是保证在分布式环境下,对所有捆绑的锁进行原子性的操作,要么一次性全部加锁成功,要么全部失败,防止死锁,这在需要同时锁定多个资源的事务场景中非常关键。

加锁过程源码拆解

加锁的入口方法是 lock(),但核心逻辑在 tryLockInnerAsync 方法中,我们一步步跟进去看。

  1. 入口与参数准备 当我们调用 multiLock.lock() 时,最终会调用到 RedissonMultiLock 类的 tryLock 方法,这个方法会循环尝试获取锁,真正的重头戏是一个叫 tryLockInnerAsync 的异步方法,这个方法会向所有 Redis 节点发送一段 Lua 脚本,Lua 脚本是 Redis 执行原子操作的关键。

  2. 核心 Lua 脚本分析 这是加锁最精华的部分,脚本很长,但我们可以拆开看它的逻辑,脚本接收几个参数:锁的过期时间 leaseTime、每个锁对应的客户端 ID(Redisson 会用 UUID 和线程 ID 组合成一个唯一标识)。

    • 第一步:声明一个结果表。 脚本一开始会创建一个表(比如叫 result)来存储每个锁操作的结果。
    • 第二步:循环处理每一个锁。 脚本会用一个循环,依次处理 MultiLock 中包裹的每一个 RLock 对象。
    • 第三步:单锁加锁逻辑。 对于循环中的每一个锁,它会执行和单锁类似的命令: redis.call('hset', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); 这里 KEYS[1] 是当前循环到的锁的键名,ARGV[2] 是客户端 ID,ARGV[1] 是锁的过期时间,这个命令的意思是:在 Redis 的 Hash 数据结构中,设置这个锁键,字段是客户端 ID,值是 1(这个值用于可重入计数),然后给这个锁键设置一个过期时间。
    • 第四步:收集结果。 将上一步 hset 命令的结果(如果键是新建的返回 1,如果是已存在返回 0)收集到之前声明的 result 表中。
    • 第五步:计算总耗时。 在循环开始前记录当前时间,循环结束后再记录一次,用这两个时间计算出给所有锁发送命令的总耗时。
    • 第六步:检查剩余有效期。 这是非常关键的一步!脚本会检查计算出的总耗时,然后用传入的锁过期时间 leaseTime 减去这个总耗时,得到 remainingLiveTime(剩余存活时间)。
    • 第七步:统一过期时间。 如果剩余存活时间小于等于 0,说明在加锁过程中,最先被设置的那个锁可能已经快过期甚至已经过期了,这非常危险,脚本会遍历之前的结果表,对任何已经加锁成功的锁执行删除操作(del命令),确保所有锁都不会被留下,然后返回 nil,表示加锁失败。
    • 第八步:返回成功。 如果剩余存活时间大于 0,说明所有锁都成功设置,并且它们至少还有 remainingLiveTime 这么长的有效期,此时脚本返回这个剩余时间。
  3. 客户端处理结果 客户端的 Java 代码拿到 Lua 脚本的返回值,如果返回值不是 nil,就表示加锁成功,这个返回值(剩余存活时间)还会被用来在客户端启动一个看门狗(Watchdog)线程,这个线程会在锁过期前定期(比如每隔过期时间的1/3)去续期,从而实现类似“可重入锁”的无限等待效果。

释放锁过程源码拆解

释放锁的入口是 unlock(),核心同样在 unlockInnerAsync 方法的 Lua 脚本里。

  1. 核心 Lua 脚本分析 解锁脚本同样会循环处理 MultiLock 中的每一个锁。

    • 第一步:声明计数器。 脚本会初始化两个计数器:count(总共成功释放的锁数量)和 failedLocks(释放失败的锁列表,记录其索引)。
    • 第二步:循环处理每一个锁。 和加锁一样,遍历所有锁。
    • 第三步:单锁释放逻辑。 对于每个锁,执行单锁的释放逻辑: local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); 这里 ARGV[3] 还是客户端 ID,这个命令是将 Hash 中该客户端 ID 对应的值减 1,这个值就是重入次数。
      • 如果减 1 之后的值大于 0,说明这个锁还被当前线程重入持有着,那么就给这个锁重新设置一下过期时间。
      • 如果减 1 之后的值等于 0,说明这个锁已经彻底释放了(没有重入了),那么就直接删除(del)这个锁键。
    • 第四步:处理结果。 如果上一步的 del 命令成功执行(返回 1),或者在重入情况下续期成功,就算这个锁释放操作成功,count 计数器加 1,如果操作失败(比如锁不存在,或者锁不属于当前客户端),就把这个锁在 MultiLock 中的索引号记录到 failedLocks 列表中。
    • 第五步:返回结果。 循环结束后,脚本返回一个表,里面包含三个信息:count(成功数)、failedLocks(失败锁索引列表)、ARGV[3](客户端 ID)。
  2. 客户端处理结果 客户端的 Java 代码拿到脚本返回的结果后,会进行检查:

    • count 的值等于锁的总数,说明所有锁都释放成功,那么解锁操作就完成了。
    • failedLocks 列表不为空,说明有些锁释放失败了(比如这个锁本来就不属于当前客户端,或者已经被意外删除了),Redisson 会抛出一个异常,通知用户释放锁时发生了问题。

总结一下关键点

通过源码拆解,我们可以看到 MultiLock 的设计精髓:

  • 原子性幻觉: 它通过一个 Lua 脚本“几乎原子性”地完成所有锁的操作,虽然命令在网络上还是逐个发送的,但服务器端脚本的执行是原子的,并且脚本内部逻辑保证了“全成功”或“全失败”的语义。
  • 耗时补偿: 加锁脚本中计算总耗时并调整剩余存活时间的设计非常巧妙,它避免了因为网络延迟导致先加的锁过早过期的问题。
  • 批量操作: 无论加锁还是解锁,都是通过一次网络通信(执行一个 Lua 脚本)完成对所有锁的操作,减少了网络开销,提高了性能。
  • 状态一致性: 解锁后对结果的检查确保了资源状态的一致性,如果部分锁释放失败,会通过异常提醒使用者,防止出现不可预知的行为。

Redisson 的 MultiLock 并不是一个真正的“原子锁”,而是通过精巧的 Lua 脚本和客户端逻辑,在分布式环境下模拟出了原子锁的行为,从而安全地解决了多个资源同时锁定的难题。

Redisson 里 MultiLock 怎么加锁和释放,源码细节拆解聊聊