📚 实战篇 19. 分布式锁 - Redisson 可重入锁原理学习文档
一、 核心痛点:我们手写的 V3 版本为什么“不可重入”?
什么是可重入?
简单来说,就是一个线程拿到锁之后,如果它内部调用的其他方法也需要这把锁,它可以无需等待,直接再次获取这把锁。这就叫“可重入”。Java 原生的 ReentrantLock 和 synchronized 都是可重入锁。
V3 版本的死穴:
我们之前手写的锁,底层使用的是 Redis 的 String 数据结构(SETNX)。
- 当线程 A 第一次执行
tryLock()时,SETNX成功,写入了UUID-ThreadA。 - 当线程 A 内部的子方法再次执行
tryLock()尝试重入时,它会再次去执行SETNX。 - 因为 Key 已经存在了,
SETNX会直接返回失败!线程 A 就这样被自己加的锁挡在了门外,引发死锁或者业务报错。
二、 破局核心:数据结构的降维打击 (String -> Hash)
为了解决重入问题,Redisson 抛弃了简单的 String 结构,转而使用了 Redis 的 Hash(哈希)结构。
在 Redisson 的设计中,一个可重入锁的组成部分如下:
- KEY(大键): 锁的名称(例如
lock:order)。 - FIELD(小键): 当前占用这把锁的线程唯一标识(即
UUID + ThreadId)。 - VALUE(值): 重入的次数(计数器) 。
通过引入“计数器”,Redisson 完美化解了重入难题。
三、 核心源码剖析:加锁的 Lua 脚本逻辑
当你调用 Redisson 的 lock.tryLock() 时,它的底层实际上是向 Redis 发送了一长串极其严谨的 Lua 脚本。
这段加锁脚本的核心逻辑分为三个分支:
-
分支 1:锁压根不存在(没人占用)
- 判断:
exists KEY == 0 - 动作:使用
hset KEY FIELD 1创建 Hash 结构,并把计数器设为 1。 - 动作:使用
pexpire KEY TTL设置整体的超时时间。 - 返回:
nil(代表加锁成功)。
- 判断:
-
分支 2:锁存在,且持有者正是当前线程(触发重入)
- 判断:
hexists KEY FIELD == 1 - 动作:使用
hincrby KEY FIELD 1将重入次数加 1(比如从 1 变成 2)。 - 动作:使用
pexpire KEY TTL重新刷新这把锁的超时时间。 - 返回:
nil(代表重入成功)。
- 判断:
-
分支 3:锁存在,但持有者是别人(加锁失败)
- 判断:上述条件都不满足。
- 返回:使用
pttl KEY返回当前锁还剩多少毫秒过期。外层 Java 代码拿到这个剩余时间后,就会进入等待或重试逻辑。
四、 核心源码剖析:解锁的 Lua 脚本逻辑
“解铃还须系铃人”,既然加锁是通过计数器累加的,那么解锁自然也就是计数器递减的过程。
释放锁的 Lua 脚本逻辑如下:
-
分支 1:锁的持有者不是自己(防误删)
- 判断:
hexists KEY FIELD == 0 - 动作:直接返回
nil,什么都不做。
- 判断:
-
分支 2:是自己的锁,执行计数器减 1
-
动作:使用
hincrby KEY FIELD -1,让重入次数减 1。 -
子判断 A:如果减完之后,计数器仍然 > 0
- 说明外层还有方法在使用这把锁,此时绝对不能删除锁。
- 动作:使用
pexpire KEY TTL重新刷新过期时间。 - 返回:0。
-
子判断 B:如果减完之后,计数器 == 0
- 说明当前线程的所有重入层级都已经彻底执行完毕了。
- 动作:使用
del KEY真正从 Redis 中删除这把锁。 - 动作:使用
publish KEY redisson_lock__channel发送一条广播消息,通知其他正在排队等待的线程:“我释放锁了,你们快来抢!” - 返回:1。
-
五、 学习总结表
| 对比维度 | 我们手写的 V3 版本 | Redisson 企业级版本 |
|---|---|---|
| 底层数据结构 | String 字符串 | Hash 哈希表 |
| 防误删标识 | 存在 Value 里 | 存在 Field 里 |
| 重入机制 | 不支持(SETNX 互斥) | 支持(Value 作为计数器累加) |
| 原子性保障 | 自定义 Lua 脚本 | 内置精细化 Lua 脚本 |
| 唤醒等待线程 | 只能靠客户端死循环重试 | 基于 Redis 的 Pub/Sub 发布订阅机制 |