做分布式系统绕不开分布式锁,这篇文章我拿实际项目里的 Redisson MutiLock(联锁)来说事:它到底解决了什么问题、加锁解锁的过程是怎么设计的、基于什么原理,以及我如何在一个只有 Windows 的测试环境里,硬生生创建了多个 Redis 实例把整套机制跑通。标题里的 p68 是我们项目里的一个里程碑编号,不重要,重点是这套联锁机制本身。如果你正准备面试或者在项目里做主从、多 Redis 场景下的锁方案,这部分值得好好看。
1. 为什么单实例锁不够用,MutiLock 解决的是哪类问题
1.1 单 Redis 节点下分布式锁的真实短板
先说一个很基础的场景:多个服务实例同时操作共享资源,比如扣库存、抢优惠券,传统本地锁(synchronized、ReentrantLock)只能锁住单个进程,在分布式环境下根本不管用。于是大家开始选 Redis 做锁,因为 Redis 是单线程处理命令,SETNX 天然就是原子操作,Redisson 在这个基础上封装了一大套成熟的分布式锁 API,默认情况下你用RLock加锁,它底层就是向某个 Redis 节点执行 Lua 脚本完成加锁,客户端使用起来几乎无感知,一行lock()就能顶上。
单实例 Redis 锁最常见的两个痛点,我实际踩过。第一个是“锁被误删”。A 线程加了锁,因为业务耗时太长,锁自动过期了,此时 B 线程成功加锁,然后 A 线程业务终于跑完,执行删除锁的命令,结果把 B 的锁删掉了。听起来很蠢,但真的会频繁发生。Redisson 默认的锁值是一个 36 位 UUID 加线程 ID 的字符串,删除锁时用 Lua 脚本对比 value 一致才删除,这个会规避误删,但不能根治锁过期导致的并发问题。第二个痛点是“单点故障”。Redis 挂了,所有依赖锁的服务全部拿不到锁,只能报错或者等待,这在生产环境是非常致命的,因为锁服务本身变成了整个系统的单点。
1.2 主从架构和哨兵架构下,锁忽然不锁了
为了消除单点,最常见的方案是给 Redis 做主从复制加哨兵(Sentinel)实现高可用。主节点写数据,异步复制到从节点,主节点故障时哨兵把某个从节点提升为新主节点。这个方案对“缓存数据”而言完全没毛病,但对“分布式锁”而言有个恶性的时序问题:客户端向主节点申请了一把锁,主节点还没来得及把这份锁数据同步到从节点,主节点就宕机了,哨兵把从节点提升为主节点,这个新的主节点上根本没有刚才那把锁的数据。于是另一个客户端也来申请同一把锁,发现没有锁,加锁成功。这就出现了两个客户端同时持有一把锁的情况,分布式锁的安全语义直接被打破了。
有些人会说,把复制模式改成同步复制啊,锁数据同步完再返回加锁成功,这不就解决了。理论上可以,但主从同步本来就是设计成异步的,为了一把锁把整个集群的写性能拖慢,代价很大;而且主节点刚同步完就宕机,能不能保证选举出来的一定是那个刚同步完的从节点,也存在不确定性。所以在真实场景里,依靠 Redis 自身主从方案解决锁的高可用问题,是一件费力不讨好且不彻底的事。这就是为什么需要一个更全局的锁方案:把锁同时放在多个相互独立的 Redis 节点上。Redisson 对应的解决方案就是 MutiLock,官方叫法 RedissonMultiLock,中文一般叫联锁或红锁,它的目标就是解决“单个 Redis 节点故障导致锁失效”这个核心痛点。
注意:MutiLock 的前提是这几个 Redis 节点是互相独立的,不搞主从不复制,各自保存完整数据。如果只是把一个主从集群的多个节点拿来做 MutiLock,主从同步带来的数据缺失问题依然存在,联锁就失去了意义。这个我在项目里专门验证过,很多人会忽略。
2. MutiLock 锁的实现原理,把加锁和解锁彻底看明白
2.1 MutiLock 是什么,它和普通 RLock 的差别在哪
MutiLock 是 Redisson 提供的多节点锁对象,它内部维护一个List<RLock>,也就是一组普通的 Redisson 锁。比如你创建了 3 个独立的 Redis 客户端(连接不同的 Redis 节点),用每个客户端获取一个 RLock,然后把这三个 RLock 组合成一个RedissonMultiLock。当调用multiLock.lock()时,它会依次对三个 RLock 执行加锁,只有三个 RLock 都加锁成功,整个 MutiLock 才认为加锁成功;只要有一个失败,它会把已经加锁成功的那些 RLock 全部解锁,保证锁的“全有或全无”语义。
这个设计思想借鉴了经典的 RedLock 算法,但 Redisson 做了实用化改造。RedLock 算法要求客户端向 N 个独立的 Redis 节点轮询加锁,必须在大多数节点(大于 N/2)上成功才算加锁成功;而 MutiLock 默认要求全部节点加锁成功才返回成功。有人会问,全部成功是不是太苛刻了,如果 3 个节点里有一个临时网络抖动,锁不就加不上了吗。这正是它的取舍:用可用性换安全性。分布式锁的终极目标是保证互斥,宁可加锁失败也不能出现两个客户端同时拿到锁,这在资金类、订单类业务里是底线。网络抖动只是暂时加不上锁,重试就好;但如果因为容错导致锁不互斥,造成的损失是不可逆的。
2.2 加锁过程的底层细节,以及锁重入和过期时间的处理
MutiLock 在加锁时,并不是各节点独立加锁、独立设置过期时间就完了,它有一个全局统一的过期时间管理逻辑。如果调用lock()不传 leaseTime(租约时间),Redisson 会给每个子锁都设置一个默认 30 秒的过期时间,同时启动 WatchDog 自动续期任务,每过 10 秒(默认是 leaseTime 的三分之一)检查一次锁是否还持有,如果还在持有就自动把过期时间重置为 30 秒,防止业务还没执行完锁就过期了。如果你自己手动指定了 leaseTime,比如 10 秒,那么 WatchDog 不会启动,到 10 秒锁自动释放,哪怕业务没跑完也得占着锁等它超时,所以日常开发里除非有特殊需求,不然强烈建议用默认 leaseTime 的方式,把续期交给 WatchDog。
再往底层看,Redisson 加锁的执行靠的是 Lua 脚本,脚本里同时完成了三件事:用exists判断锁是否存在;用hexists判断是否当前线程持有;用hincrby维护可重入计数。锁的 key 一般叫lock:business, 它的 value 是一个 hash,field 是UUID:threadId,value 是重入次数。同一个线程重复加锁时,重入数加一;释放一次,重入数减一,减到 0 才真正删除 key。这样设计的好处非常直观:业务代码里如果有递归调用或嵌套方法,每个方法都通过锁工具类去加锁解锁,可重入机制保证了不会死锁。这一点对真实业务至关重要,因为一个服务里 A 方法加锁后调用 B 方法,B 方法又尝试加同一把锁,如果锁不可重入就互相卡死了。
MutiLock 加锁的另一个核心细节是“加锁顺序和失败回滚”。它按列表顺序依次去加锁,假设三个节点,第一个成功、第二个成功、第三个失败(超时或网络异常),此时 MutiLock 会立刻把前两个已成功的锁释放掉。这个过程是同步的,保证不会留下半把锁在某个节点上。为什么要做回滚?因为如果不回滚,第一个节点上锁长时间存在,其它线程来申请这把锁时会被拒之门外,实际上就是一个“幽灵锁”,严重影响系统的可用性。这个回滚机制是 MutiLock 和普通逐节点手动加锁最本质的差别之一,自己写轮询加锁很容易漏掉这一步。
2.3 WatchDog 在联锁场景下如何工作,以及自动续期的边界
WatchDog 是 Redisson 里非常受欢迎的设计,平时的分布式锁面试题基本都会问到。先说它的实现原理:Redisson 加锁后,如果没被指定 leaseTime,它会启动一个后台定时任务,这个任务的执行频率是 leaseTime / 3,默认 30 秒租约就是每 10 秒执行一次,每次执行就是把锁的过期时间重新设置为 30 秒。这个任务什么时候停?客户端在解锁时,会把这个定时任务取消掉。如果客户端在持有锁期间宕机,任务自然就停了,没人续期,锁会在 30 秒后自动消失,不用人工干预。这个“兜底超时释放”机制,保证了分布式环境下即使持有者进程崩溃,锁最终也能自我解脱,不至于全县系统卡死。
在 MutiLock 场景下,WatchDog 的表现稍微有点不一样。每个子锁因为也是 RLock,所以各自会有 WatchDog,但 Redisson 在联锁层面做了统一管理,只要持有方进程正常,所有子锁都会同步进行续期。我之前担心过一个问题:如果三个节点里有一个节点网络抖动,续期失败了,但另外两个节点续期成功,MutiLock 会怎么处理?实测下来,Redisson 的续期是按子锁各自执行的,某个子锁续期失败会导致整个联锁在后续的锁状态检查中出问题,业务方调用isHeldByCurrentThread()可能返回 false。这其实是一个隐患:联锁认为自己在持锁,但实际其中一个节点上的锁已经过期了,另外两个节点还有锁,互斥性被破坏。所以联锁对网络稳定性的要求比单实例锁高得多,不是搭好就万事大吉。
提示:生产环境的联锁节点建议放在同一机房,最好同区域内不同机器,避免跨地域网络带来不可控的续期延迟。这个细节在我们的项目实践中被验证过,跨地域联锁在弱网下表现很不稳定。
3. 在 Windows 上创建多个 Redis 实例,完整实操过程
3.1 为什么我选择在 Windows 上做这个实验
很多生产环境和开发环境都是 Linux,按道理用 Docker Compose 一次性拉三个 Redis 容器,速度又快又干净。但我这次在 Windows 上做,原因有三个:第一,本地开发电脑是 Windows,装 Docker Desktop 有些工作环境有虚拟化限制,跑不起来;第二,这个项目需要给别人做现场演示,把三个 Redis 进程直接跑在 Windows 服务里,演示起来更直观;第三,Windows 版本的 Redis 虽然功能没有 Linux 全,但做分布式锁验证完全够用,三个实例用不同端口隔离,一样能模拟多节点的效果。
如果你用 Docker,本质也差不多:三个独立容器,分别映射 6381、6382、6383 端口,关键是要保证容器里的数据不共享,不能用同一个数据卷。在 Windows 上,本质就是解压三份 Redis,分别修改端口和持久化文件路径,然后启动三个 redis-server.exe 进程。整个思路和 Docker 没区别,核心是“隔离”——端口隔离、数据文件隔离、进程隔离。
3.2 下载和解压 Redis for Windows,以及目录规划
Windows 版 Redis 有几家在做,官方目前没有直接提供 Windows 版本,主流的是微软的 legacy 分支和 tporadowski 的 5.0.14 版本。我实际用的是 tporadowski 的 Redis-x64-5.0.14.zip,这个版本在国内下载方便,功能也比较完整,支持 Lua 脚本、发布订阅、持久化等核心能力,对验证分布式锁完全够用。解压后目录里最重要的就是redis-server.exe和redis.windows.conf两个文件。
我们规划三个实例,最清晰的做法是把解压后的目录复制三份,分别命名为 redis-6381、redis-6382、redis-6383。虽然也可以共用一个可执行文件、指定不同的配置文件,但复制三份能让每个实例的数据文件(RDB 快照和 AOF 日志)彻底隔离,排查问题的时候也一眼能看出哪个进程对应哪个实例。Windows 上 Redis 默认的持久化文件路径就在可执行文件同一个目录下,如果不隔离,三个实例写同一个 dump.rdb,文件会被互相覆盖,即使端口不同也会出各种奇怪问题,这个坑我见过有人踩。
3.3 逐个修改端口、持久化路径和访问保护配置
每个实例的配置文件中,至少需要修改以下几项。第一是端口,这是隔离的基础,三个实例分别监听 6381、6382、6383。第二是持久化文件路径,dir配置项决定了 RDB 和 AOF 文件写到哪里,分别指向各自目录,例如dir D:/redis-lab/redis-6381/,注意 Windows 路径在 Redis 配置里可以用正斜杠。第三是appendonly,建议设为 yes,并分别设置appendfilename,避免多个实例共享 appendonly.aof。第四是protected-mode,本地测试可以保持默认的 yes,但如果你需要从别的机器连接,就必须改成 no 或设置密码。第五是可以设置requirepass,三个实例可以用同一个密码,方便测试。
我需要强调一下端口选择和bind配置。如果三个实例都在本机,客户端可以通过127.0.0.1:6381/6382/6383分别连接,默认配置文件里bind 127.0.0.1就够了。如果用0.0.0.0的绑定方式,就相当于把 Redis 暴露给局域网,在没有密码保护的情况下非常危险,之前有过很多利用未授权访问 Redis 写入定时任务攻击服务器的案例,这个安全底线不能破。
3.4 启动三个 Redis 进程并验证连通性
启动方式很简单:快捷键 Win+R 输入 cmd,打开命令行,分别进入三个目录执行redis-server.exe redis.windows.conf。为了便于区分,建议开三个独立的命令行窗口,不要用同一个窗口后台挂三个进程,否则日志混在一起没法排查问题。如果配置文件里的daemonize设置成 yes,Windows 版本下也未必生效,直接在窗口里保持前台运行是最稳妥的。
启动后,可以另开一个窗口,用redis-cli.exe -p 6381 ping逐个验证,返回 PONG 就说明实例正常。为了保险,还可以执行info replication,能看到role:master且connected_slaves:0,这说明三个节点都是互相独立的主节点,没有任何主从关联。这一步非常重要,因为如果之前测试过主从,某个节点会带slaveof配置,会把锁数据同步掉,直接破坏 MutiLock 的独立节点前提。我验证时遇到过一次,原因是复制的配置文件残留了slaveof配置项,排查了很久才发现。
提示:Windows 防火墙有时会拦截本机到这些端口的连接,虽然多数情况下 127.0.0.1 的回环连接不会被拦,但如果你的开发机开过端口策略,还是提前放行这三个端口比较省事。
4. 用 Spring Boot 集成 Redisson,写代码验证 MutiLock
4.1 创建多 Redis 客户端,并把三个锁组装成 MutiLock
理论上联锁可以直接用 Redisson 的 RedissonClient,但大多数应用项目已经把 Spring Boot 集成好了,最简单的方式是配置 Redisson 连接多个地址,让 RedissonClient 内部维护对应节点。不过需要注意的是,一个 RedissonClient 只能配一个主连接地址,MultiLock 需要的是一个客户端连接多个节点,所以推荐的做法是创建多个 RedissonClient 实例,再把它们各自获得的 RLock 组合成 MultiLock。示例代码可以是这样:
Config config = new Config(); config.useSingleServer() .setAddress("redis://127.0.0.1:6381") .setPassword("yourpassword"); RedissonClient client1 = Redisson.create(config); // 同理创建 client2、client3,分别指向 6382、6383 RLock lock1 = client1.getLock("order:pay:10001"); RLock lock2 = client2.getLock("order:pay:10001"); RLock lock3 = client3.getLock("order:pay:10001"); RedissonMultiLock multiLock = new RedissonMultiLock(lock1, lock2, lock3);这里有个容易忽略的细节:三个 RLock 的锁 key 必须一致,都是order:pay:10001,因为它们代表“同一把分布式锁”,只是分布在三个节点上。如果你图省事,给每个节点配了不同的 key,那三个锁互不认识,MutiLock 形同虚设,该并发的照样并发。我在项目里见过有人犯这个错,一定要特别留意。另外,创建三个 RedissonClient 会对应三个连接池,资源占用比较多,测试结束记得调用shutdown()释放连接,不然本地连接数会一直涨。
4.2 核心业务代码:加锁、执行业务、解锁的完整姿态
使用 MutiLock 和在代码里使用单个锁差不多,但有几个细节必须遵循。第一,解锁必须放在 finally 里,确保异常情况下锁也能释放;第二,如果业务执行时间不确定,加锁时不传 leaseTime,让 WatchDog 接管续期,比你自己拍脑袋想一个时间更可靠;第三,多锁实例的锁状态并不完全同步,拿到锁后建议通过multiLock.isHeldByCurrentThread()做一次状态确认,这个判断在联锁里等于同时检查所有子锁的持有状态。下面是我在项目里实际用到的代码模板:
public void payOrder(String orderId) { RedissonMultiLock multiLock = multiLockFactory.create(orderId); boolean locked = false; try { // 尝试加锁,最多等待 3 秒,租约用默认续期模式 locked = multiLock.tryLock(3, TimeUnit.SECONDS); if (!locked) { throw new RuntimeException("系统繁忙,请稍后重试"); } // 执行支付核心逻辑 doPay(orderId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("加锁被中断", e); } finally { if (locked) { multiLock.unlock(); } } }tryLock 的第二个参数不传 leaseTime,会让 Redisson 走 WatchDog 自动续期逻辑。这里要特别提醒一点:tryLock 的第一个参数是获取锁的最大等待时间,单位是秒,如果三个节点加锁耗时过长,超过等待时间就会返回 false,导致业务拿不到锁。本地三个 Redis 实例加锁一般都在几十毫秒内完成,但如果跨机房或者网络差,等待时间要相应放大,否则高峰期会出现大量拿锁失败。
4.3 验证互斥性:临时暂停第 3 个节点,观察两种模式的表现
为了验证 MutiLock 确实在三个节点上都加了锁,我做了一个很直观的对照实验。先用一个线程获得 MutiLock,并故意睡眠 10 秒;在锁持有期间,打开一个新的 Java 进程,尝试获取同一个 MutiLock。正常情况下第二个进程拿不到锁,并阻塞在 tryLock 的等待时间内。此时我把第 3 个 Redis 实例(端口 6383)的窗口直接 Ctrl+C 停掉,在单个 RLock 模式下,持有锁的进程会把第三个节点上的锁续为失效超时,新进程在另外两个节点上加锁成功,最终会出现数据不一致;但在 MutiLock 模式下,新进程因为第三个节点不可达,在尝试锁 3 的时候直接抛出异常或超时,整体加锁失败,无法进入临界区,也就保证了互斥不破。
这个实验强烈建议自己跑一遍,比看十篇原理文章都有效。它直观地告诉你:MutiLock 是“木桶效应”的锁,任何一块木板(节点)断了,整个桶就装不了水(拿不到锁);而单实例锁是“单点决堤”,一个节点挂了,整个锁就完全失守。这样的权衡在安全敏感的业务里是值得的,因为它把“锁失效导致并发”的概率从“单点故障概率”降到了“所有节点同时故障的概率”。
5. 实战中的坑,MutiLock 与多实例 Redis 的常见问题排查
5.1 我在过程中遇到过的 5 个典型问题及解决办法
我用一个表格把这些坑记录下来,方便对比排查,这些全部是真实操作中遇到的:
| 现象 | 根因 | 解决办法 |
|---|---|---|
| 锁一直提示加锁失败,但单实例锁正常 | 三个节点中有一个节点不可达,或者密码配置不一致 | 逐个用 redis-cli ping 确认,检查 requirepass 是否一致 |
| 三个 RedissonClient 互相抢占锁,出现并发执行 | 三个 RLock 的 key 不一样 | 严格使用同一个 key,比如统一加业务前缀 |
| 进程退出后锁没有自动释放 | 客户端进程被强制 kill,WatchDog 无法执行 | 等待锁超时自动消失;无法等待就重启实例并设置合理过期时间 |
| 使用 Docker 方式启动会发现持久化数据互相干扰 | 数据卷共用或配置了 slaveof | 每个容器独立数据卷,去掉主从关联 |
| MutiLock 加锁耗时明显高于单实例 | 逐个节点加锁是串行的,节点增多耗时线性增长 | 合理控制节点数量,3 个足够;用 tryLock 控制最大等待时间 |
第一个问题的典型特征是你以为创建了 3 个客户端,但实际上其中一个连接的地址端口写错,比如把 6382 写成了 6381,这种情况下两个客户端都在连同一个实例,第三个实例上的锁其实是空转的,一旦第三个实例发生故障,锁的可用性根本达不到预期。检查的最好办法是写一段脚本,把每个 RedissonClient 的节点信息打印出来,逐个比对。
5.2 Redis 客户端连接池配置里隐藏的坑,以及 shutdown 的重要性
Redisson 默认的连接池配置比较保守,connectionPoolSize默认 50,idleConnectionTimeout默认 10000 毫秒。在联锁场景下,一次加锁操作要并发或串行访问多个节点,如果业务并发量高,可能瞬间打满连接池,导致新的加锁请求等待连接超时。我的建议是:对锁服务这种核心组件,把connectionPoolSize调到 100 以上,三实例并行操作时压力会被放大,连接池配置不能按单实例的习惯来。
另外要注意shutdown()的时机。Redisson 客户端内部有守护线程,不主动关闭,进程不会自动退出。在测试代码里,每一个 tryLock 分支结束后,都要在 finally 块里调用所有RedissonClient.shutdown(),否则 Java 进程会一直挂在那。我见过不少同事把测试代码跑完,进程迟迟不退,以为是程序卡住了,其实只是连接没释放。shutdown 之后,如果你再去调multiLock.unlock(),会抛 IllegalStateException,所以释放顺序应该是先解业务锁,再关闭客户端实例,这个次序不要颠倒。
5.3 关于锁的有效期和业务超时,两个容易想当然的点
很多刚开始写分布式锁的同学会陷入“过期时间设多大合适”的纠结,对单锁来说是,对 MutiLock 更是如此。默认 30 秒租约,WatchDog 每 10 秒续期一次,对一个正常的 HTTP 请求来说完全够用。但如果你在一个线程里执行了耗时的批处理任务,比如同步几万条数据,单次任务可能超过 30 秒。WatchDog 续期不会导致锁永久不释放,因为客户端只要还持着锁并且进程是活的,它就一直续期;但如果你显式指定了 leaseTime,比如 10 秒,那么业务跑了 20 秒,锁在第 10 秒就过期了,另一个线程在第 11 秒就可能拿到锁,这会造成严重的并发问题。所以我的经验是:永远不要自己预设业务耗时,除非你能拍胸脯保证所有业务都在某个时间内完成;否则就把它交给 WatchDog 自动续期,这是 Redisson 设计者留给你最大的善意。
再有一个想当然的地方:我们经常认为锁过期时间越长越安全,反正业务跑完会主动解锁。但如果客户端意外崩溃,锁并不会立刻消失,而是等过期时间到了才被 Redis 自动清理。如果你把 leaseTime 设成 5 分钟,客户端崩了,这 5 分钟内所有线程都拿不到锁,系统直接不可用五分之一。所以参数设计的核心其实不是“业务最长耗时”,而是“故障恢复时可接受的锁占用最长时间”,在这个问题上,默认的 30 秒其实是一个综合权衡下来的合理值。
6. 一些个人经验,以及后续还可以怎么扩展
这个项目的联锁部分做完之后,我又补了一套“降级方案”作为飞检:也就是当 MutiLock 加锁失败时,可以降级为单节点锁吗?我的结论是:尽量避免。因为一旦降级,安全性就退回到单节点水平,和干脆不用联锁没有本质区别。更合理的做法是:业务方根据锁的重要性分级,核心资金链路强制走联锁,加锁失败直接失败;非核心链路如果锁冲突影响不大,可以用普通 RLock 并配合数据库唯一约束做兜底。
从扩展性上看,MutiLock 虽然是分布式锁面试中一个很有含金量的考点,但实际业务中的健康度监控还缺一环:三个节点的网络质量、锁的获取耗时、WatchDog 续期失败次数,都应该纳入现有监控体系。我在项目里给加锁过程埋了几个计数器,一旦单次加锁耗时超过 200 毫秒或者续期失败率超过 1%,就触发告警,这个在实际运维中换回了不少安心。
最后分享一个小技巧:如果你也和我一样需要反复验证锁机制,建议在三个 Redis 实例之外,再开一个旁观实例专门记录各种锁事件的日志。配合 Redis 的 monitor 命令,你可以在旁路看到一个线程加锁、续期、解锁的完整时间线,排查问题效率比翻代码快得多。这套方法我到现在都在用,实测下来很稳。