分布式锁这块,大家用得最多的就是 Redisson 的RLock。单个 Redis 节点上tryLock、unlock一套下来,配合看门狗续期,确实能解决大部分并发问题。但一旦牵扯到跨节点、跨实例的强一致场景,单把锁就撑不住了。这时候 Redisson 提供的MultiLock就派上用场。这篇文章不绕弯子,直接拆解 MultiLock 的原理、源码层面的加锁路径、实际使用中的坑,以及它和 Redisson 普通锁的本质区别。
先说个结论:MultiLock 不是"把多把锁各自加一遍"那么简单,它的核心语义是"要么全部拿到,要么一把都不拿"。这个语义在分布式环境下做跨资源保护时非常关键。
1. MultiLock 解决什么问题
1.1 单节点分布式锁的局限性
单把 Redisson 锁的流程大家都很熟:向 Redis 写入一个带过期时间的 key,key 存在则加锁成功,key 不存在则加锁失败,通过 Lua 脚本保证检查和写入的原子性。这套机制在单节点 Redis 实例上工作的很好,性能高、延迟低、实现简单。
但单节点锁有一个天然的假设前提:这个 Redis 节点必须是可靠的。一旦节点出问题,整个锁体系就跟着出问题。比如这个节点发生了主从切换,旧主节点上的锁 key 还没来得及同步到从节点,新的主节点起来了,此时另一个客户端就能对同一个资源加锁成功,这就出现了两个客户端同时持有"同一把锁"的情况,业务层面就会产生数据竞争。
单节点锁的另一个问题是资源分布。假设你的业务涉及多个独立的 Redis 集群,或者多个不同的存储系统,你想在两个资源上都加锁,保证操作的原子性。单把锁只能保护其中一个资源,另一个资源仍然可能被其他线程直接操作。要同时保护多个资源,就需要一把"组合锁"。
1.2 主从架构下的锁失效场景
很多团队把 Redis 部署成主从模式,主节点负责写,从节点负责读,Redisson 默认也是连主节点。如果锁的 key 只写在主节点上,主从之间通过异步复制同步数据。问题是"异步"这两个字,意味着从节点上的数据可能落后于主节点。
我在一个高并发交易系统里就遇到过这种问题。当时用的是单节点 Redisson 锁,线上偶发出现两个线程同时执行同一个订单的退款操作。排查下来,原因就是主 Redis 节点发生了故障切换,一个线程在旧主节点加的锁,随着旧主节点下线而消失,另一个线程在新主节点上顺利加锁成功,两个线程就同时进入了临界区。
这个场景下,普通的 Redisson 锁解决不了。你不可能依赖 Redis 的主从同步来保证锁的持久性,因为同步始终是异步的,存在时间窗口。想让分布式锁在故障切换场景下依然可靠,要么引入类似 Redlock 的算法,要么就是多个独立节点同时加锁,降低所有节点同时出问题的概率,这正是 MultiLock 的定位。
1.3 MultiLock 的设计目标
MultiLock 的设计目标很明确:通过同时对多个独立的 Redis 节点或资源加锁,把单点故障的风险摊出去。只要不是所有节点同时不可用,锁就仍然成立。
它不是替代 Redisson 单节点锁,而是解决单节点锁在特定场景下的不足。如果你有多个资源需要一起保护,或者你的 Redis 部署形态存在主从切换风险,MultiLock 是一个比"单锁"更合理的选择。但如果你的场景只是单 Redis 实例上的普通互斥,用 MultiLock 反而是多余的,性能和代码复杂度都不划算。
2. MultiLock 的核心设计思路
2.1 从单锁到多锁的抽象
Redisson 的 MultiLock 从 API 层面来看,就是把多个RLock组合成一个RLock,然后用统一的接口去操作这组锁。这里有一个很多人忽略的细节:MultiLock 本身并不持有真正的锁状态,它只是一个门面,真正的锁状态还是分散在各个底层RLock对应的 Redis key 上。
代码层面是这样的:
RLock lock1 = redissonClient1.getLock("resource:order:1001"); RLock lock2 = redissonClient2.getLock("resource:order:1001"); RLock multiLock = redissonClient.getMultiLock(lock1, lock2);你可以给同一个 RedissonClient 创建多个锁,也可以给不同的 RedissonClient 创建锁再组合。底层不管是同一个 Redis 还是多个 Redis,getMultiLock接收的是若干RLock对象,Redis 实例不一定是同一个。
这样设计的价值在于抽象一致。调用方不需要关心自己是加了单锁还是多锁,统一走tryLock、unlock接口。但内部实现上,MultiLock 和单锁的差异非常大,后面我会详细拆解。
2.2 加锁的成功条件:全部持有
MultiLock 加锁成功的条件非常严格:必须先对列表里所有的锁依次加锁,全部成功,MultiLock 才算获取成功。
这个"全部"意味着如果其中任何一把锁加锁失败,之前已经加锁成功的锁会被立即释放,然后整体返回失败。这个动作叫做"回滚",它是 MultiLock 保证"要么全部拿到,要么一把都不拿"的关键。
为什么必须回滚?举个例子:你有三个锁节点 A、B、C,线程 T1 在 A 和 B 上加锁成功,C 加锁失败。如果 T1 不及时释放 A 和 B,T2 来了之后,它试图加锁,A 和 B 一直被占用,T2 只能一直重试或者失败。就算最后 C 上的竞争结束了,T2 成功拿到了 C,但它拿不到 A 和 B。这样业务的进程就死锁了。回滚机制就是在失败时释放已经成功的锁,避免锁资源被无意义占用。
2.3 锁顺序与占用期的关系
MultiLock 加锁时遍历List<RLock>的顺序,直接决定了锁资源的竞争效率。
默认实现中,MultiLock 按传入锁的顺序依次加锁。这个顺序在实际工程中非常关键:如果多个线程以不同的锁顺序去加同一组锁,就可能出现死锁。比如线程 A 先锁 lock1,再锁 lock2;线程 B 先锁 lock2,再锁 lock1。A 拿到 lock1 后等待 lock2,B 拿到 lock2 后等待 lock1,两个线程互相等待,直到超时。虽然 Redisson 在超时后会释放已持有的锁避免永久死锁,但等待超时的时间成本是实打实的。
因此,使用 MultiLock 时,所有请求必须保持一致的加锁顺序。我通常会在公共代码里封装一个固定顺序的锁工厂,避免调用方自由排列顺序。
3. 加锁与释放的核心实现
3.1 MultiLock 加锁的调用链路与逐节点加锁
MultiLock 的加锁链路看起来非常直接,tryLock内部遍历所有锁,逐个调用底层的tryLock。
伪代码逻辑是这样的:
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) { // 判断总等待时间 // 基于当前时间计算剩余可用时间 for (RLock lock : locks) { long remainTime = ...; // 剩余等待时间 boolean res = lock.tryLock(remainTime, leaseTime, unit); if (!res) { // 加锁失败,回滚所有已获取的锁 unlockInner(Arrays.asList(locks.subList(0, index))); return false; } } return true; }每个底层锁的tryLock走到最后,是执行 Lua 脚本:
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; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]);这是 Redisson 单锁的可重入加锁脚本。到了 MultiLock 层面,这段脚本会被每个节点独立执行,锁的持有者标识、可重入计数、过期时间各自独立。
这里有一个细节:加锁不是并发的,是串行遍历。Redis 客户端对每个节点单独发命令,一个节点一个节点地试。这也是 MultiLock 性能不如单锁的直接原因。如果要锁的节点数量多,加锁耗时线性上涨,waitTime 必须给足。
3.2 加锁接口参数:leaseTime、waitTime 与重试
掌握 MultiLock 的tryLock参数,基本就掌握了它的性格。
leaseTime是锁的自动释放时间,单位毫秒。如果不传 leaseTime 或者传 -1,Redisson 会启动看门狗机制,默认续期 30 秒,每 10 秒续期一次。MultiLock 和普通锁一样,也支持看门狗,但行为有差异,这点后面单讲。
waitTime是最多等待多久。MultiLock 的总等待时间不是所有节点等待时间之和,而是在整个调用周期内共享的预算。假设 waitTime 是 3 秒,第一个锁花了 2 秒才拿到,那么第二个锁最多还能等 1 秒,第三个锁几乎不可能有机会尝试。源码里通过System.currentTimeMillis()计算每个锁的剩余等待时间,就是这个逻辑。
这就带来一个重要的实操指导:MultiLock 的 waitTime 不能设置得太小。如果节点多、网络抖动,前面几个节点消耗了大部分等待时间,后面的节点直接没机会尝试。我在实测中倾向于把 waitTime 设为单锁的 1.5 到 2 倍。
重试机制方面,Redisson 默认的tryLock在锁被占用时会返回 false,不会自动无限重试。如果你需要重试,业务层要自己循环调用,并且要注意重试之间做一点间隔,避免无意义地打爆 Redis。
3.3 释放锁的机制与遍历解锁
释放锁时,MultiLock 也是遍历所有锁,逐个执行 unlock。每个锁资源独立删除,任何一个节点释放失败,Redisson 的默认策略是继续尝试释放其他节点。
这里有个实际工程问题:如果某个节点在加锁成功后、释放前发生故障,这把锁只能等 leaseTime 到期自动过期。MultiLock 在这方面和单锁一样,没有魔法,最终一致性依赖 Redis 的 key 过期机制。
释放锁的实现逻辑如下:
public void unlock() { // 遍历所有锁,逐个释放 for (RLock lock : locks) { lock.unlock(); } }如果中途某个节点 unlock 抛异常,Redisson 会中断释放吗?源码层面的处理值得注意:MultiLock 的 unlock 方法里,对每个锁依次调用,异常会往上抛,但不会回滚已经执行过的解锁动作。也就是说,已经释放的节点会保持释放状态,未释放的节点可以再手动处理。如果你在业务代码里遇到 unlock 异常,不要重复执行整个 MultiLock 的 unlock,否则已经释放的锁会再次试图释放,可能抛"锁不存在"的异常。
3.4 看门狗在 MultiLock 下的表现
看门狗是 Redisson 的特色机制,它会在锁的 leaseTime 到期前自动续期。对单锁来说,这个机制很简单:底层有一个定时任务,每 10 秒检查一次锁是否还存在,如果存在就重新设置过期时间。
MultiLock 的看门狗行为我没法用"每个锁都有独立看门狗"来简单概括。Redisson 的 MultiLock 内部维护了一个ScheduleService,在加锁时如果没有指定 leaseTime,看门狗会被启动,并周期性地为所有已加锁的锁批量续期。
你需要知道的关键点是:看门狗为 MultiLock 续期时,续期的前提是锁还有效。如果某个锁已经因为异常丢失(比如 Redis 节点宕机),看门狗在续期时发现 key 不存在,它会停止这个锁的续期,但不会自动回滚其它锁。这可能导致部分锁被续期,部分锁已经失效。
这个行为在实际故障中会造成锁的"半失效"状态,业务层看起来 MultiLock 还在,但实际部分底层锁已经不存在了。要应对这种情况,方案是给加法传入明确的 leaseTime,而不是完全依赖看门狗。明确的 leaseTime 配合业务执行时间上限,能避免"锁还在但实际已丢失"的尴尬局面。
4. 典型案例:用 MultiLock 保证跨库/跨实例一致性
4.1 典型业务场景
最典型的场景是:一个业务操作需要同时修改两个独立系统中的数据,这两个系统各自有一套 Redis 锁,业务要求要么同时操作,要么都不操作。
举个例子,某开发者在电商系统里做了一个库存和优惠券的组合扣减操作。库存服务使用 Redis A 的锁,优惠券服务使用 Redis B 的锁。普通的做法是分别在两个服务里加锁,但这样无法保证整体原子性:库存锁拿成功,优惠券锁拿失败,库存已经被扣了,优惠券还没扣,业务就处于中间状态。
改成 MultiLock 之后,逻辑变成:先尝试同时获得库存锁和优惠券锁,全部成功后才执行扣减,任何一个失败就直接释放已获得的锁,整体不执行。这样就从锁层面避免了中间状态。
4.2 MultiLock 与普通锁对比
用一个表格直观对比:
| 维度 | 普通 RLock | MultiLock |
|---|---|---|
| 锁的对象 | 单个 Redis key | 多个独立 Redis key |
| 加锁成功条件 | 单个 key 成功 | 所有 key 全部成功 |
| 失败处理 | 直接返回失败 | 释放已获得的锁,整体失败 |
| 性能 | 高,一次网络交互 | 低,N 次串行网络交互 |
| 适用场景 | 单资源互斥 | 多资源组合操作的原子保护 |
| 故障容忍 | 单点故障锁失效 | 提高整体可用性,但非绝对 |
这里的"非绝对"需要强调:MultiLock 并不能完全消除故障场景下的锁失效问题,它只是把多个独立节点的故障概率相乘。只要不是所有节点同时出问题,锁状态就是安全的。但如果所有节点同时不可用,MultiLock 一样崩溃。
4.3 实操配置注意事项(waitTime 的取舍)
实操中,MultiLock 参数配置是最容易踩坑的地方。我建议的配置口径如下:
leaseTime:必须设置,且要大于业务最大执行时间。如果业务执行时间不确定,至少设置一个合理上限,比如 30 秒或 60 秒。不要完全依赖看门狗。waitTime:设为单锁的 1.5 到 2 倍。如果业务并发高,适当在加锁失败后增加 sleep 重试。- 锁数量:控制在 3 到 5 个以内。超过这个数量,加锁耗时和等待时间会不成比例地膨胀。
下面是一个典型的调用示例:
long waitTime = 3000L; long leaseTime = 30000L; boolean locked = multiLock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS); if (locked) { try { // 执行业务操作 doBiz(); } finally { multiLock.unlock(); } } else { // 加锁失败,走降级逻辑 handleLockFailure(); }这里面最容易被忽略的是leaseTime的语义:如果业务执行超过 leaseTime,锁会自动释放,其他线程就能进入临界区,而此时业务可能还没结束,造成并发冲突。所以 leaseTime 必须认真评估,不要随手写个 10 秒就完事。
5. 常见误区与踩坑实录
5.1 误区:MultiLock 一定比单锁安全
这是最常见的认知偏差。MultiLock 只是把锁的可靠性从单点提升到了多点,并没有改变分布式锁最根本的困境:网络分区、进程暂停、时钟跳跃。
举一个我在调研 Redlock 时看到的经典反例:线程 T1 获取了 MultiLock 的所有锁,执行过程中发生了长时间的 GC 停顿,超过了 leaseTime,锁自动过期。另一个线程 T2 获取了同样的锁,开始执行业务。T1 GC 恢复后,继续执行,两个线程同时进入临界区。MultiLock 在这种情况下无能为力,因为它依赖的仍然是 Redis 的过期时间,而 GC 停顿是应用层面的不可控因素。
所以,MultiLock 适合解决"节点故障"维度的锁失效问题,但不适合作为"业务执行时间绝对可靠"的保证。关键业务还是要依赖幂等、版本号、乐观锁等手段做兜底。
5.2 故障场景:部分节点加锁失败
线上最容易遇到的问题是部分节点加锁失败后,业务日志里有"unlockattempt to unlock lock, not locked by lock owner`"之类的异常。这个异常通常不是业务逻辑错误,而是时序问题。
假设线程 T1 通过 MultiLock 加锁,A 成功、B 失败。MultiLock 内部回滚 A。但在回滚的瞬间,另一个线程 T2 可能已经获取了 A 的锁。这时 T1 的回滚动作如果被误判为"释放自己持有的锁",就会与 T2 产生冲突。
Redisson 源码通过 Lua 脚本来校验持有者标识,只有持有者标识匹配时才删除 key。这保证了回滚操作不会误删别人的锁。但异常依然会冒出,因为回滚时 T2 可能已经持有锁,T1 的 unlock 动作在 T2 的视角里是第三方操作,会被拒绝。
排查这个问题的正确姿势是:把加锁失败的日志和回滚日志打点,确认失败发生在哪一个节点。不要简单重试整个 MultiLock,因为重试周期内可能会产生同样的争抢。
5.3 leaseTime 一致性、线程问题与重试风险
另一个高频坑是 leaseTime 在各节点之间配置不一致。MultiLock 的每个底层锁可以独立传入 leaseTime,但你几乎不应该这么做。如果 A 的 leaseTime 是 30 秒,B 的 leaseTime 是 10 秒,B 先过期,其他线程就能通过 B 进入临界区,MultiLock 的"同时存在"语义就被破坏了。
线程问题方面,MultiLock 要求加锁和解锁必须由同一个线程完成。Redisson 的锁内部会记录持有者标识,这个标识与线程绑定。如果你在异步回调里解锁,很可能出现"当前线程不是锁持有者"的报错。我见过有人把tryLock放在一个线程,把unlock放在另一个线程,结果锁永远解不开,直到过期。MultiLock 的每个底层锁都是同样的约束。
重试风险则体现在:如果你在业务层面循环尝试 MultiLock,锁竞争激烈时,多次加锁失败会反复触发回滚和释放,导致 Redis 网络开销剧增。建议设置最大重试次数,比如 3 次,超过后直接走降级流程,而不是无限循环。
6. 我个人的落地建议
结合我看过的源码和实际踩坑经历,落地 MultiLock 时我会按下面的原则来:
第一,不要轻易上 MultiLock。大部分业务场景的单资源互斥,用 Redisson 单锁就够了。单锁的可靠性问题主要集中在主从切换窗口,这个概率并非每天发生。如果业务对极端一致性有硬性要求,再去考虑多节点组合锁。
第二,如果决定使用 MultiLock,锁节点的数量不要贪多。三个独立节点已经能显著降低整体故障概率。再加更多节点,收益递减,代价却线性上升。
第三,leaseTime 必须显式设置,不要依赖看门狗。我在交易系统里的做法是:根据业务的最大执行时间评估,再往上加 30% 的余量。如果业务偶尔超时,宁可让锁提前释放后靠业务幂等兜底,也不要让锁长时间占用坑了其他请求。
第四,加锁失败后的回滚流程一定要在最终代码里体现为"释放之前已经成功的锁",而不是简单地打印错误。依托 Redisson 的内部回滚机制,加上完整链路日志,能让你在故障复盘时迅速定位问题发生在哪个节点。
MultiLock 是一个好工具,但分布式锁本质上没有银弹。理解它的设计动机和缺陷边界,比记住几个 API 更重要。你在动手接入之前,先想清楚两个问题:我的业务真的需要多个独立节点同时加锁吗?如果其中一个节点挂了,我的系统能承担锁失效的后果吗?想清楚这两点,再下手写代码不迟。