目录一、前言二、解决不可重入Hash 数据结构 Lua 原子脚本2.1 不可重入的问题在哪2.2 Redisson 的解决方案Redis Hash2.3 加锁 Lua 脚本详解2.4 解锁 Lua 脚本三、解决不可重试消息订阅 信号量等待3.1 简单方案的缺陷四、解决超时释放看门狗Watchdog自动续期4.1 为什么需要续期4.2 Watchdog 看门狗机制4.3 看门狗工作原理五、MultiLock 联锁跨 Redis 实例的锁聚合5.1 背景主从一致性问题的延伸5.2 RedissonMultiLock 的设计思想5.3 使用示例一、前言【Redis | 第五篇】分布式锁在上一篇Redis实现分布式锁我们是基于SET NX EX命令来实现的简单的分布式锁虽然上手容易但是在生产环境中还存在以下问题痛点问题描述不可重入同一个线程在持有锁的情况下再次获取同一把锁会死锁不可重试获取锁失败后立刻返回false无法自动重试调用方只能自旋超时释放业务还没执行完锁就过期了导致并发安全问题主从一致性Redis 主节点宕机从节点还没同步锁数据导致锁丢失Redisson作为 Java 生态中最强大的 Redis 客户端对分布式锁做了非常完善的封装。它不仅实现了java.util.concurrent.locks.Lock接口还通过精巧的设计把这四个问题一一化解。二、解决不可重入Hash 数据结构 Lua 原子脚本2.1 不可重入的问题在哪传统的SET NX EX方案中锁就是一个简单的 String key只有 存在/不存在 两种状态。同一个线程如果想再次获取同一把锁比如递归调用或嵌套方法会因为 key 已存在而直接失败——这就是死锁的根源。Java 中的ReentrantLock是通过state变量记录重入次数的state0表示无锁加锁时state1释放时state-1直到归零才真正释放。Redisson 借鉴了这个思想但在 Redis 中需要一个能同时存储谁持有了锁和重入了多少次的数据结构。2.2 Redisson 的解决方案Redis HashRedisson 使用Redis 的 Hash 结构来存储锁信息Key: 锁的名称 → myLock Field: 线程标识 → 连接ID:线程ID (如 uuid-123:thread-42) Value: 重入次数 → 1, 2, 3 ...示意图┌──────────────────────────────────┐ │ Key: myLock (Hash) │ │ ┌────────────────────────────┐ │ │ │ Field: uuid-xxx:thread-1 │ │ │ │ Value: 2 (重入了2次) │ │ │ └────────────────────────────┘ │ └──────────────────────────────────┘2.3 加锁 Lua 脚本详解Redisson 将加锁逻辑封装在一条Lua 脚本中利用 Redis 执行 Lua 脚本的原子性保证并发安全-- KEYS[1]: 锁的名称如 myLock -- ARGV[1]: 锁的过期时间默认 30000 毫秒 -- ARGV[2]: 线程标识格式为 连接ID:线程ID -- 情况1: 锁不存在 → 直接加锁 if (redis.call(exists, KEYS[1]) 0) then redis.call(hincrby, KEYS[1], ARGV[2], 1); -- 设置重入次数为1 redis.call(pexpire, KEYS[1], ARGV[1]); -- 设置过期时间 return nil; -- 返回nil表示加锁成功 end; -- 情况2: 锁存在且是当前线程持有 → 重入 if (redis.call(hexists, KEYS[1], ARGV[2]) 1) then redis.call(hincrby, KEYS[1], ARGV[2], 1); -- 重入次数1 redis.call(pexpire, KEYS[1], ARGV[1]); -- 刷新过期时间 return nil; -- 返回nil表示加锁成功 end; -- 情况3: 锁被别人持有 → 返回剩余TTL return redis.call(pttl, KEYS[1]);三种情况对应三条分支锁不存在→ 创建 Hashfield线程标识value1设置过期时间锁存在且 field 匹配→ 这是重入value1刷新过期时间锁存在但 field 不匹配→ 被别人持有返回剩余存活时间ms2.4 解锁 Lua 脚本释放锁也是通过 Lua 脚本原子执行-- 判断锁是否被当前线程持有 if (redis.call(hexists, KEYS[1], ARGV[3]) 0) then return nil; -- 不是你的锁不能释放 end; -- 重入次数-1 local counter redis.call(hincrby, KEYS[1], ARGV[3], -1); if (counter 0) then -- 还有重入层数只刷新过期时间不删除key redis.call(pexpire, KEYS[1], ARGV[2]); return 0; else -- 重入次数归零彻底删除锁 redis.call(del, KEYS[1]); -- 发布解锁消息通知等待队列中的线程 redis.call(publish, KEYS[2], ARGV[1]); return 1; end;解锁时 counter0 说明还有嵌套层没释放完只减计数不删 key——这就是可重入锁的释放逻辑。三、解决不可重试消息订阅 信号量等待3.1 简单方案的缺陷原生SET NX加锁失败后通常的做法是让线程sleep一段时间再重试。但这样有两个问题CPU 空转浪费资源sleep 时间不好把握太短则频繁重试太长则响应慢核心流程如下┌─────────────────────────────────────────────────────┐ │ tryLock(waitTime, leaseTime, unit) │ │ │ │ │ ▼ │ │ 尝试获取锁 (执行 Lua 脚本) │ │ │ │ │ ├── 成功 → 返回 true │ │ │ │ │ └── 失败拿到锁的剩余 TTL │ │ │ │ │ ▼ │ │ 计算剩余等待时间 waitTime - 已消耗时间 │ │ │ │ │ ├── 剩余时间 ≤ 0 → 返回 false (超时放弃) │ │ │ │ │ └── 剩余时间 0 → │ │ │ │ │ ▼ │ │ 订阅一个 Redis Channel (锁名对应的频道) │ │ │ │ │ ▼ │ │ 通过信号量(Semaphore)阻塞等待 │ │ await(time, TimeUnit) │ │ │ │ │ ├── 收到解锁消息 → 被唤醒回到「尝试获取锁」 │ │ │ │ │ └── 等待超时 → 返回 false │ └─────────────────────────────────────────────────────┘关键设计点Redis Pub/Sub当持有锁的线程释放锁时Lua 脚本中执行publish所有订阅该频道的等待线程会被唤醒避免了无意义的轮询。信号量SemaphoreRedisson 在 Java 侧使用Semaphore让线程阻塞等待。await(time, TimeUnit)方法支持超时唤醒与waitTime完美配合。剩余时间精确计算每次被唤醒后重新尝试加锁并重新计算waitTime - 已消耗时间确保总等待时间不超过用户指定的waitTime四、解决超时释放看门狗Watchdog自动续期4.1 为什么需要续期传统分布式锁一定会设置过期时间防止客户端宕机导致死锁。但问题在于你无法预知业务代码会执行多久。假设锁的过期时间 10秒 业务执行时间 12秒 时间线 0s ─── 加锁成功 ... 10s ─── 锁自动过期Redis 删除了 key 10s ─── 另一个线程拿到了锁 ... 12s ─── 第一个线程业务执行完毕但它持有的锁实际上已经失效 → 并发安全问题4.2 Watchdog 看门狗机制Redisson 的解决方案是Watchdog看门狗自动续期机制。核心规则只有不指定leaseTime或设为 -1时才会启动看门狗。// 不指定leaseTime → 触发看门狗默认30秒过期自动续期 lock.lock(); // 指定了leaseTime → 不会触发看门狗到期自动释放 lock.lock(10, TimeUnit.SECONDS);4.3 看门狗工作原理┌──────────────────────────────────────────────────┐ │ 1. 加锁成功leaseTime -1 │ │ │ │ │ ▼ │ │ 2. 设置默认过期时间 30秒 (lockWatchdogTimeout) │ │ │ │ │ ▼ │ │ 3. 启动定时任务 (Netty Timer / ScheduledExecutor) │ │ │ │ │ ▼ │ │ 4. 每 30/3 10秒 执行一次续期 │ │ ┌─────────────────────────────┐ │ │ │ Lua 脚本: │ │ │ │ if 锁存在且是当前线程持有 │ │ │ │ → pexpire KEY 30000 │ │ │ │ → 重置过期时间为30秒 │ │ │ └─────────────────────────────┘ │ │ │ │ │ ▼ │ │ 5. 循环执行直到客户端主动 unlock │ │ │ │ │ ▼ │ │ 6. unlock 时取消定时任务 删除锁 │ └──────────────────────────────────────────────────┘五、MultiLock 联锁跨 Redis 实例的锁聚合5.1 背景主从一致性问题的延伸即使有了看门狗单节点 Redis 仍然存在单点故障风险。如果使用 Redis 主从 Sentinel 哨兵模式客户端A 在主节点获取锁成功 ↓ 主节点宕机数据还没同步到从节点 ↓ 哨兵将从节点提升为新主节点 ↓ 客户端B 在新主节点获取同一把锁 → 成功 ↓ 客户端A 和 B 同时持有同一把锁 → 灾难5.2 RedissonMultiLock 的设计思想Redisson 提供了MultiLock联锁可以将多个独立的RLock合并成一个逻辑上的大锁。每个RLock可以来自不同的 Redis 节点只有当所有子锁都加锁成功时MultiLock 才算加锁成功。┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ Redis Node 1 │ │ Redis Node 2 │ │ Redis Node 3 │ │ lock1 ✓ │ │ lock2 ✓ │ │ lock3 ✓ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ └─────────────────┼─────────────────┘ │ ┌──────▼──────┐ │ MultiLock │ │ 全部成功 → │ │ 加锁成功 │ └─────────────┘5.3 使用示例// 三个不同的 Redis 实例 RLock lock1 redissonInstance1.getLock(myLock); RLock lock2 redissonInstance2.getLock(myLock); RLock lock3 redissonInstance3.getLock(myLock); // 合并为联锁 RLock multiLock redisson.getMultiLock(lock1, lock2, lock3); // 使用方式和普通锁完全一致 multiLock.lock(); try { // 业务逻辑 } finally { multiLock.unlock(); }
【Redis | 第六篇】Redisson
目录一、前言二、解决不可重入Hash 数据结构 Lua 原子脚本2.1 不可重入的问题在哪2.2 Redisson 的解决方案Redis Hash2.3 加锁 Lua 脚本详解2.4 解锁 Lua 脚本三、解决不可重试消息订阅 信号量等待3.1 简单方案的缺陷四、解决超时释放看门狗Watchdog自动续期4.1 为什么需要续期4.2 Watchdog 看门狗机制4.3 看门狗工作原理五、MultiLock 联锁跨 Redis 实例的锁聚合5.1 背景主从一致性问题的延伸5.2 RedissonMultiLock 的设计思想5.3 使用示例一、前言【Redis | 第五篇】分布式锁在上一篇Redis实现分布式锁我们是基于SET NX EX命令来实现的简单的分布式锁虽然上手容易但是在生产环境中还存在以下问题痛点问题描述不可重入同一个线程在持有锁的情况下再次获取同一把锁会死锁不可重试获取锁失败后立刻返回false无法自动重试调用方只能自旋超时释放业务还没执行完锁就过期了导致并发安全问题主从一致性Redis 主节点宕机从节点还没同步锁数据导致锁丢失Redisson作为 Java 生态中最强大的 Redis 客户端对分布式锁做了非常完善的封装。它不仅实现了java.util.concurrent.locks.Lock接口还通过精巧的设计把这四个问题一一化解。二、解决不可重入Hash 数据结构 Lua 原子脚本2.1 不可重入的问题在哪传统的SET NX EX方案中锁就是一个简单的 String key只有 存在/不存在 两种状态。同一个线程如果想再次获取同一把锁比如递归调用或嵌套方法会因为 key 已存在而直接失败——这就是死锁的根源。Java 中的ReentrantLock是通过state变量记录重入次数的state0表示无锁加锁时state1释放时state-1直到归零才真正释放。Redisson 借鉴了这个思想但在 Redis 中需要一个能同时存储谁持有了锁和重入了多少次的数据结构。2.2 Redisson 的解决方案Redis HashRedisson 使用Redis 的 Hash 结构来存储锁信息Key: 锁的名称 → myLock Field: 线程标识 → 连接ID:线程ID (如 uuid-123:thread-42) Value: 重入次数 → 1, 2, 3 ...示意图┌──────────────────────────────────┐ │ Key: myLock (Hash) │ │ ┌────────────────────────────┐ │ │ │ Field: uuid-xxx:thread-1 │ │ │ │ Value: 2 (重入了2次) │ │ │ └────────────────────────────┘ │ └──────────────────────────────────┘2.3 加锁 Lua 脚本详解Redisson 将加锁逻辑封装在一条Lua 脚本中利用 Redis 执行 Lua 脚本的原子性保证并发安全-- KEYS[1]: 锁的名称如 myLock -- ARGV[1]: 锁的过期时间默认 30000 毫秒 -- ARGV[2]: 线程标识格式为 连接ID:线程ID -- 情况1: 锁不存在 → 直接加锁 if (redis.call(exists, KEYS[1]) 0) then redis.call(hincrby, KEYS[1], ARGV[2], 1); -- 设置重入次数为1 redis.call(pexpire, KEYS[1], ARGV[1]); -- 设置过期时间 return nil; -- 返回nil表示加锁成功 end; -- 情况2: 锁存在且是当前线程持有 → 重入 if (redis.call(hexists, KEYS[1], ARGV[2]) 1) then redis.call(hincrby, KEYS[1], ARGV[2], 1); -- 重入次数1 redis.call(pexpire, KEYS[1], ARGV[1]); -- 刷新过期时间 return nil; -- 返回nil表示加锁成功 end; -- 情况3: 锁被别人持有 → 返回剩余TTL return redis.call(pttl, KEYS[1]);三种情况对应三条分支锁不存在→ 创建 Hashfield线程标识value1设置过期时间锁存在且 field 匹配→ 这是重入value1刷新过期时间锁存在但 field 不匹配→ 被别人持有返回剩余存活时间ms2.4 解锁 Lua 脚本释放锁也是通过 Lua 脚本原子执行-- 判断锁是否被当前线程持有 if (redis.call(hexists, KEYS[1], ARGV[3]) 0) then return nil; -- 不是你的锁不能释放 end; -- 重入次数-1 local counter redis.call(hincrby, KEYS[1], ARGV[3], -1); if (counter 0) then -- 还有重入层数只刷新过期时间不删除key redis.call(pexpire, KEYS[1], ARGV[2]); return 0; else -- 重入次数归零彻底删除锁 redis.call(del, KEYS[1]); -- 发布解锁消息通知等待队列中的线程 redis.call(publish, KEYS[2], ARGV[1]); return 1; end;解锁时 counter0 说明还有嵌套层没释放完只减计数不删 key——这就是可重入锁的释放逻辑。三、解决不可重试消息订阅 信号量等待3.1 简单方案的缺陷原生SET NX加锁失败后通常的做法是让线程sleep一段时间再重试。但这样有两个问题CPU 空转浪费资源sleep 时间不好把握太短则频繁重试太长则响应慢核心流程如下┌─────────────────────────────────────────────────────┐ │ tryLock(waitTime, leaseTime, unit) │ │ │ │ │ ▼ │ │ 尝试获取锁 (执行 Lua 脚本) │ │ │ │ │ ├── 成功 → 返回 true │ │ │ │ │ └── 失败拿到锁的剩余 TTL │ │ │ │ │ ▼ │ │ 计算剩余等待时间 waitTime - 已消耗时间 │ │ │ │ │ ├── 剩余时间 ≤ 0 → 返回 false (超时放弃) │ │ │ │ │ └── 剩余时间 0 → │ │ │ │ │ ▼ │ │ 订阅一个 Redis Channel (锁名对应的频道) │ │ │ │ │ ▼ │ │ 通过信号量(Semaphore)阻塞等待 │ │ await(time, TimeUnit) │ │ │ │ │ ├── 收到解锁消息 → 被唤醒回到「尝试获取锁」 │ │ │ │ │ └── 等待超时 → 返回 false │ └─────────────────────────────────────────────────────┘关键设计点Redis Pub/Sub当持有锁的线程释放锁时Lua 脚本中执行publish所有订阅该频道的等待线程会被唤醒避免了无意义的轮询。信号量SemaphoreRedisson 在 Java 侧使用Semaphore让线程阻塞等待。await(time, TimeUnit)方法支持超时唤醒与waitTime完美配合。剩余时间精确计算每次被唤醒后重新尝试加锁并重新计算waitTime - 已消耗时间确保总等待时间不超过用户指定的waitTime四、解决超时释放看门狗Watchdog自动续期4.1 为什么需要续期传统分布式锁一定会设置过期时间防止客户端宕机导致死锁。但问题在于你无法预知业务代码会执行多久。假设锁的过期时间 10秒 业务执行时间 12秒 时间线 0s ─── 加锁成功 ... 10s ─── 锁自动过期Redis 删除了 key 10s ─── 另一个线程拿到了锁 ... 12s ─── 第一个线程业务执行完毕但它持有的锁实际上已经失效 → 并发安全问题4.2 Watchdog 看门狗机制Redisson 的解决方案是Watchdog看门狗自动续期机制。核心规则只有不指定leaseTime或设为 -1时才会启动看门狗。// 不指定leaseTime → 触发看门狗默认30秒过期自动续期 lock.lock(); // 指定了leaseTime → 不会触发看门狗到期自动释放 lock.lock(10, TimeUnit.SECONDS);4.3 看门狗工作原理┌──────────────────────────────────────────────────┐ │ 1. 加锁成功leaseTime -1 │ │ │ │ │ ▼ │ │ 2. 设置默认过期时间 30秒 (lockWatchdogTimeout) │ │ │ │ │ ▼ │ │ 3. 启动定时任务 (Netty Timer / ScheduledExecutor) │ │ │ │ │ ▼ │ │ 4. 每 30/3 10秒 执行一次续期 │ │ ┌─────────────────────────────┐ │ │ │ Lua 脚本: │ │ │ │ if 锁存在且是当前线程持有 │ │ │ │ → pexpire KEY 30000 │ │ │ │ → 重置过期时间为30秒 │ │ │ └─────────────────────────────┘ │ │ │ │ │ ▼ │ │ 5. 循环执行直到客户端主动 unlock │ │ │ │ │ ▼ │ │ 6. unlock 时取消定时任务 删除锁 │ └──────────────────────────────────────────────────┘五、MultiLock 联锁跨 Redis 实例的锁聚合5.1 背景主从一致性问题的延伸即使有了看门狗单节点 Redis 仍然存在单点故障风险。如果使用 Redis 主从 Sentinel 哨兵模式客户端A 在主节点获取锁成功 ↓ 主节点宕机数据还没同步到从节点 ↓ 哨兵将从节点提升为新主节点 ↓ 客户端B 在新主节点获取同一把锁 → 成功 ↓ 客户端A 和 B 同时持有同一把锁 → 灾难5.2 RedissonMultiLock 的设计思想Redisson 提供了MultiLock联锁可以将多个独立的RLock合并成一个逻辑上的大锁。每个RLock可以来自不同的 Redis 节点只有当所有子锁都加锁成功时MultiLock 才算加锁成功。┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ Redis Node 1 │ │ Redis Node 2 │ │ Redis Node 3 │ │ lock1 ✓ │ │ lock2 ✓ │ │ lock3 ✓ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ └─────────────────┼─────────────────┘ │ ┌──────▼──────┐ │ MultiLock │ │ 全部成功 → │ │ 加锁成功 │ └─────────────┘5.3 使用示例// 三个不同的 Redis 实例 RLock lock1 redissonInstance1.getLock(myLock); RLock lock2 redissonInstance2.getLock(myLock); RLock lock3 redissonInstance3.getLock(myLock); // 合并为联锁 RLock multiLock redisson.getMultiLock(lock1, lock2, lock3); // 使用方式和普通锁完全一致 multiLock.lock(); try { // 业务逻辑 } finally { multiLock.unlock(); }