一、前言:为什么 Redisson 的锁能"可重入"?
你是否好奇:
- 同一个线程多次调用
lock.lock(),为什么不会死锁? - 业务执行了 1 小时,锁为什么没有自动过期?
- 解锁时如何确保只删自己的锁?
这些问题的答案,都藏在 Redisson 可重入锁(Reentrant Lock) 的精巧设计中。
本文将带你深入源码级原理 ,拆解 Redisson 如何利用 Redis + Lua + Watchdog 实现一个安全、可重入、自动续期的分布式锁。
二、核心数据结构:Redis 中的锁长什么样?
当你执行:
java
RLock lock = redisson.getLock("myLock");
lock.lock();
Redisson 会在 Redis 中写入一条 Hash 结构 数据:
bash
> HGETALL myLock
1) "b8a7f5e3-1234-5678-9abc-def012345678:1" # field
2) "1" # value(重入次数)
🔑 关键设计:
- Key :用户指定的锁名(如
"myLock") - Field :
UUID + ":" + threadIdUUID:标识当前 Redisson 客户端实例(防跨 JVM 误删)threadId:标识当前线程(支持同 JVM 多线程隔离)
- Value :重入次数(int 类型)
✅ 这个 Hash 结构是实现可重入性 和安全性的核心!
三、加锁流程:Lua 脚本保证原子性
Redisson 加锁通过 Lua 脚本 原子执行,脚本逻辑如下(简化版):
Lua
-- KEYS[1] = 锁名(myLock)
-- ARGV[1] = 过期时间(毫秒)
-- ARGV[2] = UUID:threadId
if (redis.call('exists', KEYS[1]) == 0) then
-- 锁不存在 → 初始化
redis.call('hset', KEYS[1], ARGV[2], 1)
redis.call('pexpire', KEYS[1], ARGV[1])
return nil -- 成功
elseif (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 -- 成功
else
-- 锁被其他线程持有 → 返回剩余过期时间
return redis.call('pttl', KEYS[1])
end
🧠 流程图解:
┌───────────────┐
│ 加锁请求 │
└───────┬───────┘
▼
┌───────────────────────┐
│ Redis 执行 Lua 脚本 │
└───────────┬───────────┘
▼
┌───────────────────────┐
│ 锁不存在? → 初始化 Hash│
│ 是当前线程?→ 重入+1 │
│ 其他线程? → 返回等待 │
└───────────────────────┘
✅ 优势 :整个判断+设置过程原子执行,杜绝并发漏洞
四、自动续期:Watchdog 机制(防业务超时)
问题场景:
- 锁默认 TTL = 30 秒
- 业务执行耗时 50 秒
- 30 秒时锁自动过期 → 其他线程获得锁 → 并发安全破坏!
Redisson 解法:Watchdog(看门狗)
工作原理:
- 当调用
lock.lock()(无参)时,不设置 TTL - 同时启动后台线程(Watchdog)
- 每隔 10 秒 (
internalLockLeaseTime / 3 = 30000 / 3)检查:- 如果当前线程仍持有该锁 → 执行
PEXPIRE myLock 30000
- 如果当前线程仍持有该锁 → 执行
- 直到调用
unlock(),Watchdog 停止续期
💡 关键点 :只要业务没结束,锁就永远不会过期!
源码位置(Redisson 3.x):
org.redisson.RedissonLock#lock()org.redisson.LockPubSub#scheduleExpirationRenewal()
五、解锁流程:安全删除 + 通知等待线程
解锁同样通过 Lua 脚本 原子执行:
Lua
-- KEYS[1] = 锁名
-- ARGV[1] = UUID:threadId
-- ARGV[2] = 通道名(用于 pub/sub)
if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then
return nil -- 不是当前线程的锁
end
local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1)
if (counter > 0) then
-- 重入次数 > 0,只减计数,不删锁
redis.call('pexpire', KEYS[1], ARGV[3]) -- 续期
return 0
else
-- 重入次数归零,删除锁
redis.call('del', KEYS[1])
-- 发布解锁消息,唤醒等待线程
redis.call('publish', ARGV[2], ARGV[1])
return 1
end
📌 重点:
- 只允许持有者解锁
- 支持重入次数递减
- 解锁后通过 Pub/Sub 通知其他等待线程
六、可重入性演示
java
RLock lock = redisson.getLock("myLock");
lock.lock(); // 第1次:Hash 初始化 { "uuid:1": "1" }
lock.lock(); // 第2次:重入 → { "uuid:1": "2" }
// 业务逻辑...
lock.unlock(); // 计数减1 → { "uuid:1": "1" }
lock.unlock(); // 计数归零 → 删除 key
✅ 同一线程可无限次加锁,只需成对 unlock
七、与其他方案对比
| 特性 | 手写 Redis 锁 | ZooKeeper | Redisson 可重入锁 |
|---|---|---|---|
| 可重入 | ❌ 需自行实现 | ✅ 支持 | ✅ 原生支持 |
| 自动续期 | ❌ 需自己写线程 | ✅ 临时节点 | ✅ Watchdog |
| 性能 | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
| 实现复杂度 | 高 | 中 | 低(一行代码) |
八、注意事项与最佳实践
1. 必须配对使用 lock/unlock
java
// ✅ 正确
lock.lock();
try {
// 业务
} finally {
lock.unlock(); // 必须在 finally 中!
}
2. 避免在异步线程中解锁
- 子线程的
threadId与加锁线程不同 → 无法解锁 - 解决方案:传递
RLock对象,或使用CompletableFuture回调
3. 不要混用有参/无参 lock()
lock(10, SECONDS):不会启动 Watchdog,10 秒后强制过期lock():启动 Watchdog,永不超时(直到 unlock)
九、结语
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!