ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

分布式锁进阶:Redisson MultiLock 联锁原理与多实例实战

分布式锁进阶:Redisson MultiLock 联锁原理与多实例实战 做分布式系统绕不开分布式锁这篇文章我拿实际项目里的 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 提供的多节点锁对象它内部维护一个ListRLock也就是一组普通的 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 是一个 hashfield 是UUID:threadIdvalue 是重入次数。同一个线程重复加锁时重入数加一释放一次重入数减一减到 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 进程并验证连通性启动方式很简单快捷键 WinR 输入 cmd打开命令行分别进入三个目录执行redis-server.exe redis.windows.conf。为了便于区分建议开三个独立的命令行窗口不要用同一个窗口后台挂三个进程否则日志混在一起没法排查问题。如果配置文件里的daemonize设置成 yesWindows 版本下也未必生效直接在窗口里保持前台运行是最稳妥的。启动后可以另开一个窗口用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写代码验证 MutiLock4.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的窗口直接 CtrlC 停掉在单个 RLock 模式下持有锁的进程会把第三个节点上的锁续为失效超时新进程在另外两个节点上加锁成功最终会出现数据不一致但在 MutiLock 模式下新进程因为第三个节点不可达在尝试锁 3 的时候直接抛出异常或超时整体加锁失败无法进入临界区也就保证了互斥不破。这个实验强烈建议自己跑一遍比看十篇原理文章都有效。它直观地告诉你MutiLock 是“木桶效应”的锁任何一块木板节点断了整个桶就装不了水拿不到锁而单实例锁是“单点决堤”一个节点挂了整个锁就完全失守。这样的权衡在安全敏感的业务里是值得的因为它把“锁失效导致并发”的概率从“单点故障概率”降到了“所有节点同时故障的概率”。5. 实战中的坑MutiLock 与多实例 Redis 的常见问题排查5.1 我在过程中遇到过的 5 个典型问题及解决办法我用一个表格把这些坑记录下来方便对比排查这些全部是真实操作中遇到的现象根因解决办法锁一直提示加锁失败但单实例锁正常三个节点中有一个节点不可达或者密码配置不一致逐个用 redis-cli ping 确认检查 requirepass 是否一致三个 RedissonClient 互相抢占锁出现并发执行三个 RLock 的 key 不一样严格使用同一个 key比如统一加业务前缀进程退出后锁没有自动释放客户端进程被强制 killWatchDog 无法执行等待锁超时自动消失无法等待就重启实例并设置合理过期时间使用 Docker 方式启动会发现持久化数据互相干扰数据卷共用或配置了 slaveof每个容器独立数据卷去掉主从关联MutiLock 加锁耗时明显高于单实例逐个节点加锁是串行的节点增多耗时线性增长合理控制节点数量3 个足够用 tryLock 控制最大等待时间第一个问题的典型特征是你以为创建了 3 个客户端但实际上其中一个连接的地址端口写错比如把 6382 写成了 6381这种情况下两个客户端都在连同一个实例第三个实例上的锁其实是空转的一旦第三个实例发生故障锁的可用性根本达不到预期。检查的最好办法是写一段脚本把每个 RedissonClient 的节点信息打印出来逐个比对。5.2 Redis 客户端连接池配置里隐藏的坑以及 shutdown 的重要性Redisson 默认的连接池配置比较保守connectionPoolSize默认 50idleConnectionTimeout默认 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 命令你可以在旁路看到一个线程加锁、续期、解锁的完整时间线排查问题效率比翻代码快得多。这套方法我到现在都在用实测下来很稳。
返回列表