目录
[二、解决不可重入:Hash 数据结构 + Lua 原子脚本](#二、解决不可重入:Hash 数据结构 + Lua 原子脚本)
[2.1 不可重入的问题在哪?](#2.1 不可重入的问题在哪?)
[2.2 Redisson 的解决方案:Redis Hash](#2.2 Redisson 的解决方案:Redis Hash)
[2.3 加锁 Lua 脚本详解](#2.3 加锁 Lua 脚本详解)
[2.4 解锁 Lua 脚本](#2.4 解锁 Lua 脚本)
[三、解决不可重试:消息订阅 + 信号量等待](#三、解决不可重试:消息订阅 + 信号量等待)
[3.1 简单方案的缺陷](#3.1 简单方案的缺陷)
[4.1 为什么需要续期?](#4.1 为什么需要续期?)
[4.2 Watchdog 看门狗机制](#4.2 Watchdog 看门狗机制)
[4.3 看门狗工作原理](#4.3 看门狗工作原理)
[五、MultiLock 联锁:跨 Redis 实例的锁聚合](#五、MultiLock 联锁:跨 Redis 实例的锁聚合)
[5.1 背景:主从一致性问题的延伸](#5.1 背景:主从一致性问题的延伸)
[5.2 RedissonMultiLock 的设计思想](#5.2 RedissonMultiLock 的设计思想)
[5.3 使用示例](#5.3 使用示例)
一、前言
在上一篇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 变量记录重入次数的:state=0 表示无锁,加锁时 state+1,释放时 state-1,直到归零才真正释放。
Redisson 借鉴了这个思想,但在 Redis 中需要一个能同时存储 "谁持有了锁" 和 "重入了多少次" 的数据结构。
2.2 Redisson 的解决方案:Redis Hash
Redisson 使用 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 脚本的原子性保证并发安全:
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]);
三种情况对应三条分支:
- 锁不存在 → 创建 Hash,field=线程标识,value=1,设置过期时间
- 锁存在且 field 匹配 → 这是重入!value+1,刷新过期时间
- 锁存在但 field 不匹配 → 被别人持有,返回剩余存活时间(ms)
2.4 解锁 Lua 脚本
释放锁也是通过 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;
解锁时 counter>0 说明还有嵌套层没释放完,只减计数不删 key------这就是可重入锁的释放逻辑。
三、解决不可重试:消息订阅 + 信号量等待
3.1 简单方案的缺陷
原生 SET NX 加锁失败后,通常的做法是让线程 sleep 一段时间再重试。但这样有两个问题:
- CPU 空转,浪费资源
- sleep 时间不好把握:太短则频繁重试,太长则响应慢
核心流程如下:
Lua
┌─────────────────────────────────────────────────────┐
│ tryLock(waitTime, leaseTime, unit) │
│ │ │
│ ▼ │
│ 尝试获取锁 (执行 Lua 脚本) │
│ │ │
│ ├── 成功 → 返回 true │
│ │ │
│ └── 失败,拿到锁的剩余 TTL │
│ │ │
│ ▼ │
│ 计算剩余等待时间 = waitTime - 已消耗时间 │
│ │ │
│ ├── 剩余时间 ≤ 0 → 返回 false (超时放弃) │
│ │ │
│ └── 剩余时间 > 0 → │
│ │ │
│ ▼ │
│ 订阅一个 Redis Channel (锁名对应的频道) │
│ │ │
│ ▼ │
│ 通过信号量(Semaphore)阻塞等待 │
│ await(time, TimeUnit) │
│ │ │
│ ├── 收到解锁消息 → 被唤醒,回到「尝试获取锁」 │
│ │ │
│ └── 等待超时 → 返回 false │
└─────────────────────────────────────────────────────┘
关键设计点:
Redis Pub/Sub :当持有锁的线程释放锁时(Lua 脚本中执行
publish),所有订阅该频道的等待线程会被唤醒,避免了无意义的轮询。信号量(Semaphore) :Redisson 在 Java 侧使用
Semaphore让线程阻塞等待。await(time, TimeUnit)方法支持超时唤醒,与waitTime完美配合。剩余时间精确计算 :每次被唤醒后重新尝试加锁,并重新计算
waitTime - 已消耗时间,确保总等待时间不超过用户指定的waitTime
四、解决超时释放:看门狗(Watchdog)自动续期
4.1 为什么需要续期?
传统分布式锁一定会设置过期时间,防止客户端宕机导致死锁。但问题在于:你无法预知业务代码会执行多久。
Lua
假设锁的过期时间 = 10秒
业务执行时间 = 12秒
时间线:
0s ─── 加锁成功
...
10s ─── 锁自动过期!(Redis 删除了 key)
10s ─── 另一个线程拿到了锁
...
12s ─── 第一个线程业务执行完毕,但它持有的"锁"实际上已经失效
→ 并发安全问题!
4.2 Watchdog 看门狗机制
Redisson 的解决方案是 Watchdog(看门狗)自动续期机制。
核心规则 :只有不指定 leaseTime(或设为 -1)时,才会启动看门狗。
java
// 不指定leaseTime → 触发看门狗,默认30秒过期,自动续期
lock.lock();
// 指定了leaseTime → 不会触发看门狗,到期自动释放
lock.lock(10, TimeUnit.SECONDS);
4.3 看门狗工作原理
java
┌──────────────────────────────────────────────────┐
│ 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 哨兵模式:
java
客户端A 在主节点获取锁成功
↓
主节点宕机,数据还没同步到从节点
↓
哨兵将从节点提升为新主节点
↓
客户端B 在新主节点获取同一把锁 → 成功!
↓
客户端A 和 B 同时持有同一把锁 → 灾难!
5.2 RedissonMultiLock 的设计思想
Redisson 提供了 MultiLock(联锁) ,可以将多个独立的 RLock 合并成一个逻辑上的"大锁"。每个 RLock 可以来自不同的 Redis 节点 ,只有当所有子锁都加锁成功时,MultiLock 才算加锁成功。
java
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Redis Node 1 │ │ Redis Node 2 │ │ Redis Node 3 │
│ lock1 ✓ │ │ lock2 ✓ │ │ lock3 ✓ │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
└─────────────────┼─────────────────┘
│
┌──────▼──────┐
│ MultiLock │
│ 全部成功 → │
│ 加锁成功 │
└─────────────┘
5.3 使用示例
java
// 三个不同的 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();
}