本章学习重点:加锁、解锁、可重入、Watch Dog 自动续期、Lua 原子性、Fencing Token。
一、Redisson 分布式锁概述
Redisson 是基于 Redis 实现的 Java 分布式基础组件,提供了 RLock 分布式锁。
与简单的 Redis SETNX 实现不同,Redisson 提供了:
- 可重入锁
- 自动过期机制
- Watch Dog 自动续期
- Pub/Sub 锁释放通知
- Lua 脚本保证操作原子性
- 公平锁、读写锁等扩展能力
基本使用:
RLock lock = redissonClient.getLock("order_lock");
try {
lock.lock();
// 执行业务逻辑
createOrder();
} finally {
lock.unlock();
}
二、加锁原理
2.1 Redis 数据结构
Redisson 默认使用 Redis Hash 保存锁信息。
假设 JVM-1 中的线程 A 获取了 order_lock。
Redis 中的数据可以理解为:
Key: order_lock
Type: Hash
Field: 客户端ID:线程ID
Value: 1
其中:
- 客户端 ID:区分不同 Redisson 客户端。
- 线程 ID:区分同一个 JVM 中的不同线程。
- Value:记录锁的重入次数。
为什么需要客户端 ID 和线程 ID?
因为分布式环境中,不仅要区分不同 JVM,还要区分同一个 JVM 内的不同线程。
2.2 加锁核心逻辑
Redisson 通过 Lua 脚本保证加锁过程的原子性。
简化后的 Lua 脚本:
-- 锁不存在
if (redis.call('exists', KEYS[1]) == 0) then
-- 创建锁,记录持有者,重入次数为 1
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
-- 重入次数 +1
redis.call('hincrby', KEYS[1], ARGV[2], 1)
-- 刷新过期时间
redis.call('pexpire', KEYS[1], ARGV[1])
return nil
end
-- 锁被其他线程持有,返回剩余过期时间
return redis.call('pttl', KEYS[1])
说明:
这是用于理解的简化版本,不是 Redisson 完整源码。
2.3 三种加锁情况
情况一:锁不存在
执行:
HINCRBY order_lock 客户端ID:线程ID 1
PEXPIRE order_lock 30000
当前线程成功获取锁。
情况二:锁已经存在,但持有者是当前线程
说明当前线程再次获取锁。
执行:
HINCRBY order_lock 客户端ID:线程ID 1
重入次数增加,实现可重入。
情况三:锁被其他线程持有
当前线程无法获取锁,返回锁剩余 TTL。
Redisson 可以通过 Pub/Sub 订阅锁释放通知,等待重新竞争,而不是一直无脑轮询 Redis。
2.4 为什么使用 Lua?
假设不使用 Lua,而是通过 Java 分别执行判断和创建:
if (!redis.exists(key)) {
redis.hset(key, field, 1);
}
可能出现:
- 线程 A 判断 Key 不存在。
- 线程 B 也判断 Key 不存在。
- A 写入锁。
- B 也写入锁。
最终两个线程都认为自己获取了锁。
Lua 脚本在 Redis 服务端原子执行,中间不会被其他客户端命令插入。
因此可以保证判断、创建、设置过期时间这一系列操作的原子性。
三、可重入锁原理
Redisson 的 RLock 支持可重入。
例如:
lock.lock();
lock.lock();
lock.lock();
同一个线程可以重复获取同一把锁。
Redis 中的重入次数变化:
| 操作 | 重入次数 |
|---|---|
| 第一次 lock() | 1 |
| 第二次 lock() | 2 |
| 第三次 lock() | 3 |
可重入的核心是:
通过客户端 ID + 线程 ID 判断是否为当前锁持有者。
如果是当前线程再次获取锁,则重入次数加一,而不是阻塞自己。
这与 Java ReentrantLock 的设计思想类似。
四、解锁原理
4.1 为什么只有持有者才能解锁?
假设:
- JVM-1 的线程 A 获取锁。
- JVM-2 的线程 B 尝试获取锁,但失败。
- 线程 B 不能直接删除 Redis 中的锁。
否则可能出现线程 A 还在执行业务,线程 B 却提前释放锁的问题。
因此 Redisson 解锁时会校验当前线程是否为锁持有者。
4.2 解锁核心 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)
-- 重入次数仍然大于 0
if (counter > 0) then
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
这是帮助理解的简化版本。
4.3 解锁过程
例如:
lock.lock(); // 1
lock.lock(); // 2
lock.lock(); // 3
lock.unlock(); // 2
lock.unlock(); // 1
lock.unlock(); // 0
只有重入次数归零时,才真正删除 Redis 中的锁。
4.4 Pub/Sub 通知机制
当锁真正释放时,Redisson 会通过 Redis Pub/Sub 发布解锁通知。
假设:
线程 A:持有锁
线程 B:等待锁
线程 C:等待锁
线程 A 解锁:
线程 A
|
| unlock()
v
Redis 删除锁
|
| PUBLISH
v
Redis 发布解锁通知
|
+------> 线程 B 收到通知
|
+------> 线程 C 收到通知
等待线程收到通知后重新尝试获取锁。
注意:
收到通知不代表一定能拿到锁。
线程 B 和线程 C 仍然需要竞争,最终只有一个线程能够成功获取锁。
五、Watch Dog 自动续期机制
这是 Redisson 最重要的特性之一,也是面试高频考点。
5.1 为什么需要自动续期?
假设:
lock.lock(30, TimeUnit.SECONDS);
业务执行需要 60 秒。
如果锁的有效期只有 30 秒:
0 秒:线程 A 获取锁
|
v
30 秒:锁过期
|
v
线程 B 获取锁
|
v
60 秒:线程 A 仍然在执行
这时候线程 A 和线程 B 可能同时执行临界区代码,造成并发安全问题。
为了解决这个问题,Redisson 提供了 Watch Dog 机制。
5.2 Watch Dog 是什么?
Watch Dog(看门狗)是一种自动续期机制。
当线程成功获取锁后,如果没有指定 leaseTime,Redisson 会启动定时续期任务。
定期检查锁是否仍然属于当前线程:
- 如果仍然持有锁,则延长过期时间。
- 如果已经不持有锁,则停止续期。
默认配置:
Config config = new Config();
// 默认 30 秒
config.setLockWatchdogTimeout(30000);
5.3 自动续期时间
默认锁过期时间为 30 秒。
续期周期通常为过期时间的 1/3,即 10 秒。
0 秒:获取锁,TTL = 30 秒
|
v
10 秒:第一次续期,TTL = 30 秒
|
v
20 秒:第二次续期,TTL = 30 秒
|
v
30 秒:第三次续期,TTL = 30 秒
|
v
持续续期,直到主动释放锁
注意:
每次续期都是重新设置 30 秒,而不是在原有剩余时间上增加 30 秒。
5.4 Watch Dog 续期原理
Redisson 内部通过定时任务和 Lua 脚本实现续期。
简化后的 Java 逻辑:
private void renewExpiration() {
// 定时执行续期任务
scheduler.schedule(() -> {
// 执行 Lua 脚本续期
renewExpirationAsync();
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
}
续期 Lua 脚本:
-- 判断当前线程是否仍然持有锁
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 重新设置过期时间
return redis.call('pexpire', KEYS[1], ARGV[1])
end
return 0
核心思想:
续期之前必须验证锁的持有者,不能无条件延长锁的过期时间。
续期成功后,Redisson 会继续安排下一次续期任务。
如果发现锁已经不属于当前线程,或者锁不存在,就停止续期。
六、lock() 与 lock(leaseTime) 的区别
这是非常容易混淆的知识点。
核心结论:是否指定 leaseTime,决定是否启用 Watch Dog。
6.1 两种方式
方式一:不指定租约时间。
lock.lock();
特点:
- 使用默认锁过期时间。
- 默认 30 秒。
- 启动 Watch Dog。
- 业务执行时间较长时可以自动续期。
- 主动 unlock 后释放锁并停止续期。
方式二:指定租约时间。
lock.lock(30, TimeUnit.SECONDS);
特点:
- 指定锁的租约时间为 30 秒。
- 不启动 Watch Dog。
- 30 秒后锁自动过期。
- 不会因为业务未完成而自动续期。
6.2 tryLock 的不同重载
// 立即尝试获取锁
lock.tryLock();
// 最多等待 10 秒,没有指定租约时间
lock.tryLock(10, TimeUnit.SECONDS);
// 最多等待 10 秒,获取后租约时间为 30 秒
lock.tryLock(10, 30, TimeUnit.SECONDS);
方法对比:
| 方法 | 等待获取锁 | 锁租约时间 | Watch Dog |
|---|---|---|---|
| lock() | 无限等待 | 默认 30 秒 | 启用 |
| lock(30, SECONDS) | 无限等待 | 30 秒 | 不启用 |
| tryLock() | 立即尝试 | 默认 30 秒 | 启用 |
| tryLock(10, SECONDS) | 最多等待 10 秒 | 默认 30 秒 | 启用 |
| tryLock(10, 30, SECONDS) | 最多等待 10 秒 | 30 秒 | 不启用 |
特别注意:
waitTime 和 leaseTime 是两个完全不同的概念。
- waitTime:等待获取锁的最长时间。
- leaseTime:成功获取锁之后,锁的租约时间。
6.3 如何选择?
如果业务执行时间不确定:
lock.lock();
让 Watch Dog 自动续期。
如果业务执行时间明确,并且希望锁到期自动释放:
lock.lock(30, TimeUnit.SECONDS);
指定租约时间。
实际项目中需要根据业务特点选择,不能盲目认为指定过期时间一定更安全。
七、JVM 宕机与锁失效风险
7.1 JVM 宕机
假设:
- JVM-1 获取锁。
- Watch Dog 定期续期。
- JVM-1 突然宕机。
- 续期任务停止。
- Redis 中的锁最终 TTL 到期。
- 其他线程可以重新获取锁。
因此 Watch Dog 也解决了 JVM 宕机后锁无法释放的问题。
7.2 STW 导致锁过期
还有一种特殊情况:
- JVM-1 获取锁。
- JVM-1 发生长时间 STW。
- Watch Dog 无法及时执行续期。
- Redis 中的锁过期。
- JVM-2 获取锁。
- JVM-1 恢复运行。
这时候 JVM-1 原来的线程可能继续执行已经失去锁保护的业务代码。
因此:
Watch Dog 不意味着锁绝对不会失效。
对于要求严格的分布式业务,还需要考虑 Fencing Token。
八、Fencing Token 栅栏令牌
8.1 为什么需要 Fencing Token?
分布式锁只能控制锁的获取,不能天然保证锁过期后的旧持有者无法继续操作业务资源。
例如:
| 线程 | 获取锁 | Token |
|---|---|---|
| A | 第一次获取 | 1 |
| B | A 的锁过期后获取 | 2 |
| A | STW 恢复继续执行 | 1 |
此时 A 虽然已经失去锁,但仍可能继续写入业务数据。
8.2 Fencing Token 如何解决?
每次获取锁时生成一个递增的令牌。
业务数据库或资源服务记录当前已经接受的最大 Token。
例如:
当前最大 Token = 2
线程 A 携带 Token = 1
数据库拒绝写入
线程 B 携带 Token = 2
数据库允许写入
这样就可以避免旧持有者继续修改业务资源。
Redisson 提供了 RFencedLock 支持 Fencing Token。
但需要注意:
资源服务必须主动校验 Token,否则令牌本身无法保护业务数据。
九、Redisson 分布式锁完整生命周期
lock()
|
v
执行 Lua 加锁
|
v
获取锁成功
|
v
记录持有者信息
|
v
启动 Watch Dog
|
v
执行业务
|
v
定时检查锁持有者
|
v
自动续期
|
v
unlock()
|
v
重入次数 -1
|
v
判断是否归零
|
+----+----+
| |
否 是
| |
v v
保留锁 删除 Redis 锁
|
v
发布通知
|
v
停止续期
|
v
等待线程重新竞争
十、核心知识点速记
加锁
- Redis Hash 保存锁信息。
- 客户端 ID + 线程 ID 标识持有者。
- 重入次数记录在 Hash 中。
- Lua 保证加锁操作原子性。
解锁
- 校验当前线程是否持有锁。
- 重入次数减一。
- 重入次数归零才删除锁。
- 删除锁后通过 Pub/Sub 发布通知。
自动续期
- 不指定 leaseTime 才启动 Watch Dog。
- 默认锁过期时间 30 秒。
- 通常每 10 秒执行一次续期。
- 续期前校验当前线程是否仍持有锁。
- 主动解锁后停止续期。
风险控制
- JVM 宕机:依靠 TTL 自动释放锁。
- STW:可能导致续期不及时。
- 锁过期:旧线程可能继续执行。
- Fencing Token:防止旧持有者写入过期数据。
最终记住一句话:
Redisson 通过 Redis Hash 实现可重入锁,通过 Lua 保证加锁和解锁的原子性,通过 Pub/Sub 通知等待线程,通过 Watch Dog 实现自动续期。
其中,是否指定 leaseTime,是决定是否启用 Watch Dog 的关键。
评论区