做高并发项目做到一定阶段,分布式锁这道坎是绕不过去的。最近我在Windows本机上同时起了三个Redis实例,用Redisson的MultiLock把加锁、续期、解锁整套流程完整跑了一遍,顺手把分布式锁里最容易含糊的几个原理点都验证清楚了。这里不打算讲虚的,我直接按“为什么需要多个Redis、Windows下怎么搭多实例、MultiLock内部到底做了什么、代码怎么落地、坑在哪里”这个顺序来写,有环境有代码有结论,希望能给同样在研究分布式锁的Java后端一个完整的参考。
这个实验源自一个需要动手实操点评的项目,项目编号是p68,主题正好落在Redisson的MultiLock上。别以为MultiLock就是同时调三个RLock那么简单,真跑起来才会发现里面全是细节。下面开始。
1. 为什么我非要在Windows上折腾三个Redis
1.1 一个Redis实例的时代过去了
分布式锁最常见的三个场景:秒杀扣库存、订单状态流转、定时任务防重。这三个场景的本质是同一件事——多个进程并发操作同一份数据,必须保证同时只有一个进程在写。最粗暴的方案是在Redis里写一个带过期时间的key,用SETNX或SET NX EX,拿到key就代表拿到锁。单业务节点、单Redis的时候,这套方案确实能用,我早期很多项目就是这么干的,代码简单,性能也好。
一旦环境复杂化,问题就来了。最要命的是主从切换:线程A在主节点加锁成功,这条写命令还没来得及同步给从节点,主节点挂了,哨兵把从节点提升为新的主节点,新主节点上根本没有锁这个key。线程B在此时来加锁,一样能成功,于是两个线程同时进入临界区。库存扣成负数、订单重复创建,这些故障全都指向同一个源头——单点Redis的锁不具备故障转移能力。
就算你把架构简化成单节点Redis、不搞主从,业务的执行时长也会让锁很难受。锁过期时间设短了,业务没跑完锁就没了;设长了,客户端一旦真的崩溃,锁要很久才能释放,后续请求全被堵住。这些痛点合在一起,就是Redisson这类组件存在的理由。
1.2 MultiLock想解决的,正是单点背后的互斥保证
Redisson在RLock接口下封装了可重入、自动续期、阻塞等待这些能力。RLock之上,Redisson还提供了两个组合锁:RedissonMultiLock和RedissonRedLock。这次实验重点研究的是MultiLock。
MultiLock的核心语义非常严格:同时对多个Redis实例加同一个key的锁,全部实例都拿到锁,整个加锁才算成功。只要有一台失败,之前成功的锁会全部被回收。这样设计的直接收益是,锁的安全性不再押在单台Redis上——只要这三台实例不是同时宕机,锁就不会因为主从切换或单点故障而凭空丢失。
代价同样明显:任何一个节点不可用,加锁都会失败。我在本机第一次跑MultiLock时就被这个语义坑过,三个实例里有一个端口写错了,代码一直在等待,后来才知道是“全部成功”这个条件卡住了。把这个特性理解透以后,再去选择用普通锁、MultiLock还是RedLock,你就知道各自承受的故障模型是什么了。
1.3 这套方案适合谁参考
如果你是一名Java后端,正在准备分布式锁相关的面试题,或者手上正好有一个需要动手点评的实操项目,那这篇内容的节奏会比较合适。我会从零开始,在Windows 10/11上搭出三个互不干扰的Redis实例,配置Redisson连接,写完整的MultiLock加锁代码,再用Redis命令行验证底层数据结构,最后整理这段时间踩过的坑。Linux环境完全同理,只是安装Redis的方式略有差别。
说句真心话,很多人在笔记本上看源码看得头头是道,但一旦落地到“怎么让MultiLock在本地真正跑起来”这一步就开始卡壳。问题往往不在代码本身,而是环境细节:三个Redis实例没有真正独立运行,或者配置里端口和日志路径写错,或者压根没意识到MultiLock要求所有节点全部成功。下面先把环境问题彻底解决掉。
2. Windows下创建多个Redis实例的完整操作
2.1 方案选型:解压版、Docker、WSL还是服务化
Windows上跑Redis的常见路子有四条。第一是Docker Desktop里跑redis:7容器,环境干净,但依赖Hyper-V或WSL2,不少公司电脑的虚拟化功能被禁用,连起容器这一步都过不去。第二是WSL2里装Linux原生Redis,能用,但涉及子系统路径、端口转发、文件权限这些额外概念,对只想验证锁原理的人有点绕。第三是Memurai,这是Windows原生的Redis实现,稳定性和性能都不错,但主要用于商业环境,学习场景没必要。第四条路子就是社区编译的Redis-x64 zip解压包,解压即用,没有任何额外的平台依赖。
我选的是第四条。原因很简单,MultiLock实验需要同时跑多个实例,zip包复制三份,改个端口就是三个独立节点。这个模型比容器更接近真实的物理部署:每个实例有独立的配置目录、独立的日志文件、独立的端口。虽然Docker做起来也快,但在多实例场景下,你还得额外管理端口映射和容器网络,反而干扰对锁本身的理解。
2.2 从解压到三节点运行,一步步来
先说下载。从GitHub的Redis-x64仓库下载zip包,解压后目录里应该有redis-server.exe、redis-cli.exe、redis.windows.conf这些文件。建议解压到类似D:\dev\redis这样的纯英文路径,不带空格不加中文,这一步能省掉后面很多编码和路径解析的麻烦。
然后把整个目录复制三份,分别是redis-6379、redis-6380、redis-6381。每个目录就是一个独立实例,数据文件、日志、配置文件互不干扰。接下来修改每个目录里的redis.windows.conf。
以redis-6379为例,至少要调整这几项:
port 6379 bind 127.0.0.1 appendonly yes appendfilename "appendonly-6379.aof" dir D:/dev/redis/redis-6379/ logfile "redis-6379.log"另外两个目录把端口、日志文件名、dir路径换成6380、6381。有几个细节一定要提醒:redis配置文件不允许出现缩进,所有配置项顶格写;文件编码用UTF-8无BOM,Windows记事本默认保存容易带BOM,可能导致解析异常;appendonly建议改成yes,锁实验里重要数据别只放在内存里。
配置改完后,开三个PowerShell窗口分别启动:
cd D:\dev\redis\redis-6379 redis-server.exe redis.windows.conf其他两个窗口同理。启动后看到“Ready to accept connections”就是成功了。三个窗口保持前台运行,日志实时滚动,排查问题非常方便。
接着验证每个实例的连通性:
redis-cli.exe -p 6379 ping redis-cli.exe -p 6380 ping redis-cli.exe -p 6381 ping三个都返回PONG,说明三节点环境已经就绪。
2.3 让Redis实例更贴近生产:密码与服务化
本地实验不加密码能跑通,但研究分布式锁最好还是把密码加上。在配置文件里加一行requirepass,例如统一设置成Test@123,那么Redisson连接串里也要带对应密码。加密码有个额外好处——你之后看任何连接报错,会下意识先怀疑认证问题,排查思路会更有条理。
Windows服务化则是另一个可选项。用sc.exe能把redis-server注册成系统服务开机自启,命令大概是这样:
sc.exe create Redis6379 binPath= "\"D:\dev\redis\redis-6379\redis-server.exe\" \"D:\dev\redis\redis-6379\redis.windows.conf\""但我建议做原理验证时先别服务化。服务化以后,端口被抢占、重启后数据路径不对、日志写到哪都不直观。前台窗口虽然简陋,却是学习阶段最好的调试手段。等这套流程完全跑熟,再考虑服务化不迟。
3. Redisson MultiLock 原理拆解
3.1 MultiLock 和 RedLock 是什么关系
先说结论,RedissonMultiLock和RedissonRedLock是两个东西。RedLock是Redis原作者提出的一种分布式锁算法,核心观点是在N个互相独立的Redis节点上依次加锁,只要超过半数的节点成功,这次加锁就算成功,从而容忍少量节点故障。Redisson实现了这套算法,类名是RedissonRedLock,它继承自RedissonMultiLock,额外引入了failedLocksLimit参数,允许指定数量的节点加锁失败。
RedissonMultiLock本身没有那么强的容错语义。它的要求是“所有节点全部成功”,一个都不能少。这种设计思想和RedLock有明显区别:MultiLock不打算容忍节点故障,而是假设你有三台独立且稳定的Redis,用全量成功换取锁不会在任一台单点丢失。实际项目中如果你只能保证两台可用,RedLock可能更合适;如果三台都能保证,MultiLock的互斥性更强。
这个区别面试时经常被拿出来问。只记住“MultiLock是全部成功,RedLock是多数成功”还不够,更要能说出为什么会有这种设计差异,以及使用场景分别是什么。
3.2 一次完整的加锁过程是怎么走的
当代码调用multiLock.lock()时,Redisson内部遍历持有的RLock列表,对每个RLock依次执行tryLock。以单个RLock来看,底层执行了一段Lua脚本,我来贴一个简化版本,方便对照看:
if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', 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])这段脚本的含义:如果锁key不存在,创建一个Hash并把当前线程的标识写入,设置过期时间,返回nil表示加锁成功;如果key存在且持有者就是当前线程,计数加一,刷新过期时间,这实现的就是可重入;否则返回剩余过期时间,表示锁被别人持有。
MultiLock在这一步有个关键行为:只要有一个节点加锁失败,它不会装作什么都没发生,而是把之前已经成功获取的锁全部释放掉。我最初以为MultiLock只是简单的循环加锁,看了源码才发现它还负责失败后的补偿回收。这也是为什么MultiLock的失败路径比普通锁复杂得多。
加锁成功的总耗时大约是N个节点的RTT之和,因为所有调用是串行执行的。节点数和加锁延迟是线性关系,这一点在做性能评估时要有心理准备。
3.3 看门狗、重入、解锁的核心机制
Redisson最让人踏实的机制是看门狗Watchdog。调用lock()不传leaseTime时,锁默认的过期时间是30秒,Redisson同时会启动一个后台定时任务,每隔10秒检查一次锁是否还在,如果还在,就把过期时间重新刷回30秒。业务方法哪怕跑了几十秒,只要持有锁的客户端进程活着,锁就一直有效。
看门狗的生命周期绑定在客户端进程上,所以存在一个优雅的兜底:客户端突然宕机后,后台任务也随之消失,锁最多30秒就会自动过期,其他请求不会无限等待。这个设计比“永远不释放”的死锁方案高明得多。
但看门狗有一个重要前提:不能手动传leaseTime。只要你在tryLock里传了第二个参数,Redisson会认为你希望锁按固定时间释放,看门狗自动关闭。我在一次测试里给锁设了3秒,业务逻辑执行了4秒,结果第二个线程在第3秒以后成功进来,两个线程同时操作了同一份数据。这不是Redisson的问题,而是我把业务超时和锁过期时间的关系想得太简单了。
再聊重入和解锁。锁在Redis里的存储结构是Hash,field是客户端唯一ID加线程ID,value是重入次数。同一线程重复加锁,value加一;解锁时value减一,减到零才删除key。解锁Lua脚本会检查持有者身份,不是自己的锁绝不会乱删。这个设计保证了两个客户端即便拿到同一个key,也不能互相解锁。
发布订阅机制也值得一提。RedissonLock在拿锁失败后,会订阅锁释放的channel。持有锁的线程解锁并删除key后,会向channel发布一条释放消息,所有阻塞中的等待线程收到消息后醒来重新竞争。这个机制避免了无意义的固定轮询,也是Redisson锁响应很快的原因之一。
MultiLock的解锁则是对所有节点逐个执行unlock。正因为如此,释放锁时一定要小心:如果某个节点的锁已经因为超时被自动释放,unlock时可能抛出IllegalMonitorStateException。实战里我会先调用isHeldByCurrentThread确认自己还持有锁,再决定是否执行unlock。
4. 实操:用MultiLock写一段可运行的分布式锁代码
4.1 引入依赖并初始化RedissonClient
代码用Java配合Spring Boot实现。先引入Redisson的Spring Boot Starter,版本选3.17.x以上:
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.17.7</version> </dependency>然后初始化三个独立的RedissonClient。这里要特意强调:MultiLock里的RLock必须来自不同的RedissonClient,否则等于三把锁全落在同一个Redis节点上,MultiLock直接退化成一个普通锁,所有高可用意义全部消失。我在实验初期就在这个点上踩过坑,用一个client创建了三个RLock,用hgetall看数据才发现三个节点上根本没有三份锁记录。
Config config1 = new Config(); config1.useSingleServer() .setAddress("redis://127.0.0.1:6379"); // 如果有密码再加 .setPassword("Test@123") RedissonClient client1 = Redisson.create(config1); // config2 -> 127.0.0.1:6380, config3 -> 127.0.0.1:6381每个Config对应一个节点,配置互相独立。这里还要提醒一点:多个RedissonClient会各自创建线程池和连接池,内存占用会比单client高不少,本地实验时给JVM留够堆内存,别在项目里同时初始化一堆用不到的client。
4.2 加锁、业务执行、解锁的标准写法
下面是一段完整的MultiLock业务代码:
public void processOrder(Long orderId) { String lockKey = "lock:order:" + orderId; RLock lock1 = client1.getLock(lockKey); RLock lock2 = client2.getLock(lockKey); RLock lock3 = client3.getLock(lockKey); RedissonMultiLock multiLock = new RedissonMultiLock(lock1, lock2, lock3); boolean locked = false; try { locked = multiLock.tryLock(3, TimeUnit.SECONDS); if (!locked) { throw new RuntimeException("获取分布式锁失败,订单:" + orderId); } // 业务逻辑:查询库存、扣减、写订单 TimeUnit.MILLISECONDS.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (locked) { multiLock.unlock(); } } }这里的tryLock只传了一个参数:等待锁的最长时间3秒,不传leaseTime。这种情况下,锁默认30秒过期,由看门狗自动续期,业务方法跑多久都安全。如果你业务上需要固定过期时间,也可以改成 tryLock(3, 30, TimeUnit.SECONDS),第二个参数就是锁的自动过期时间,此时看门狗不生效,锁到达30秒准点释放,需要自己评估业务最长执行时间。
把unlock放进finally是铁的纪律。我在评审别人代码时见过不少Lock成功但finally没释放的案例,业务一抛异常,锁只能等超时被动释放,整个集群的请求瞬间全堵在这个key上,故障级别直接拉满。
4.3 实测验证:Redis里的数据长什么样
代码跑起来后,去三个Redis节点查询同一个key:
redis-cli.exe -p 6379 type lock:order:1001 redis-cli.exe -p 6379 hgetall lock:order:1001预期输出:
hash 1) "a5342f90-bc9c-4a3f-8e0a-123456789abc:38" 2) "1"field里的前半段是客户端生成的唯一ID,冒号后是线程ID,value为1表示重入次数。三个节点上的数据应当完全一致,这才说明MultiLock确实在多个节点上同时加锁成功。
如果你想更直观地验证互斥效果,开两个线程同时抢同一个锁key,等所有线程结束再看日志,会看到只有一个线程成功进入临界区,另一个线程在等待后返回false,或者等锁释放后再进入。这种观测方式比只看理论描述更有说服力。
5. 常见问题与排查技巧
5.1 问题速查表
把这段时间遇到的高频问题整理成一个速查表,按“现象-原因-解决”三列排开,方便遇到同样情况时快速定位。
| 现象 | 原因 | 解决 |
|---|---|---|
| redis-server启动后立即闪退 | 配置文件存在缩进、路径含中文、出现Windows不支持的参数 | 检查配置格式,换纯英文路径,去掉daemonize yes |
| redis-cli连接不上 | 端口写错、bind限制、密码不匹配 | 用ping命令逐个验证,逐项排查连接配置 |
| 端口被占用 | 上一个实例没有退干净 | netstat -ano | findstr 6379,taskkill对应PID |
| MultiLock一直获取锁失败 | 某个节点不可用,全成功语义触发 | 分别验证三个节点的单锁加锁是否正常 |
| 看门狗不续期 | tryLock里传了leaseTime,看门狗自动关闭 | 不传leaseTime,交给看门狗处理续期 |
| unlock抛IllegalMonitorStateException | 锁已被到期释放,或当前线程不持有锁 | 释放前用isHeldByCurrentThread判断 |
5.2 几个不容易发现的坑
第一个坑是Windows配置文件的编码问题。用记事本改redis.windows.conf再保存,很容易存成带BOM的UTF-8,Redis解析时可能报错或者干脆读不到配置。建议用VS Code打开文件,保存时直接选UTF-8无BOM,或者干脆从干净的Linux配置模板改起。
第二个坑是AOF路径。dir配置如果不写绝对路径,Redis启动后会跑到当前工作目录去找数据文件,在Windows服务化或从不同目录启动时特别容易出问题。我的习惯是dir和logfile全部写成绝对路径,并且路径统一用正斜杠。
第三个坑是MultiLock里所有RLock必须来自不同RedissonClient,这个前面已经强调过。再补充一个容易混淆的情况:三个RLock的key必须一致,如果不一致,MultiLock就退化成简单地对三个不同锁分别加锁,完全丧失了“同一把锁跨节点”的语义。
第四个坑是锁粒度。把整个订单模块用同一个key加锁,并发一高全部请求排队,系统的吞吐量瞬间归零。实战里应该按订单ID、用户ID、商品ID来拆锁,让不同业务请求尽量不争抢同一个锁。
5.3 性能与替代方案的个人建议
MultiLock的代价在加锁速度上非常明显,三个节点串行加锁,延迟大约是三倍RTT,再加上失败后的回收处理,整体耗时远超单实例锁。如果业务对延迟敏感,一定要提前做好压测,确定这个增加的安全性是值得的。
我个人在选型时有个倾向:如果能依赖公司现成的Redis高可用集群,优先使用Redisson普通分布式锁,把高可用交给集群;只有在需要跨多套独立Redis环境保证锁安全时,才考虑MultiLock或RedLock。此外,RedLock本身在学术界和工程界都存在争议,很多人认为它仍然不够安全,所以更要结合自己的故障模型来决策。
面试层面把这几个概念讲清楚就够了:普通锁依赖Redis高可用,MultiLock是全节点成功,RedLock是多数节点成功。如果还能补充一两个本地实验观察到的细节,比如三节点里的锁数据结构完全一致、失败后补偿释放已加锁节点等,那就是真正做过实验的人和只背理论的人之间的差距。
我在本机把三台Redis跑起来做完整套实验后,最大的感受是:分布式锁的原理在纸面上看非常抽象,一旦你看到了锁在Redis里的真实结构,理解了看门狗的触发条件,再亲手让MultiLock的三个节点同时加锁、同时释放,所有概念一下子就串起来了。如果你也要做类似的实验,我的建议是先单机锁跑通,再加到三个节点,最后去翻Redisson源码里RedissonLock和RedissonMultiLock这两个类,顺着加锁Lua脚本往下走,收获会比单纯背面试题大得多。再说一个最值得记住的教训:锁的释放一定要写在finally里,别把系统安全寄托在Redis超时兜底上——真等超时兜底时,你的服务大概率已经被锁拖垮一阵子了。