黑马点评 Redisson 二:可重入锁到底解决了什么问题?
本文整理自我学习黑马点评 Redis 实战篇第 5 章「分布式锁 Redisson」的 5.3 小节。
学到这一节时,我一开始最困惑的地方是:讲义前面明明在讲黑马点评优惠券秒杀,怎么突然贴了一段 Lua?这段 Lua 是我们项目自己写的吗?黑马点评业务真的用到了它吗?Redisson 为什么要用 Redis Hash 存锁,而不是像我们手写锁那样存一个字符串?
这篇文章就围绕这些困惑展开,重点讲清楚 Redisson 可重入锁到底解决什么问题,以及它底层为什么要记录"重入次数"。
1. 先把最大的疑惑说清楚:这段 Lua 不是业务代码
讲义 5.3 里出现了一段 Lua:
lua
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;
end;
if (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;
end;
return redis.call('pttl', KEYS[1]);
刚看到这里时,很容易懵:
text
这段是我项目里写的吗?
它是服务给哪个业务接口的?
为什么突然开始讲 Redis Hash 和 Lua?
和优惠券秒杀有什么关系?
先给结论:
这段 Lua 是 Redisson 框架内部实现
RLock加锁逻辑的一部分,不是黑马点评项目自己写的业务 Lua。
也就是说,我们业务代码里写的是:
java
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock();
而 tryLock() 底层为了完成加锁,会在 Redis 中执行类似讲义展示的 Lua。
调用链可以理解成:
text
黑马点评业务代码
↓ 调用 lock.tryLock()
Redisson 的 RLock 实现
↓ 执行内部 Lua
Redis 中写入 / 判断 / 更新锁数据
所以 5.3 不是新增了一个业务流程,而是在解释:
Redisson 的
RLock为什么比我们手写的简单 Redis 锁更成熟,它到底是怎么支持"可重入"的。
2. 什么叫可重入锁
先不看 Redis,先看一个普通 Java 场景。
假设有两个方法:
java
public void methodA() {
lock.lock();
try {
methodB();
} finally {
lock.unlock();
}
}
public void methodB() {
lock.lock();
try {
// 执行业务
} finally {
lock.unlock();
}
}
如果同一个线程进入 methodA(),它先拿到了锁。然后 methodA() 内部又调用 methodB(),methodB() 又尝试拿同一把锁。
如果这把锁不可重入,就会出现很奇怪的问题:
text
当前线程已经持有锁。
当前线程再次申请同一把锁。
锁发现"已经有人持有了",于是拒绝。
当前线程被自己挡住。
这就是"自己把自己锁死"。
所以可重入锁的意思是:
同一个线程已经持有某把锁时,可以再次获取这把锁,不会被自己阻塞。
注意这里有一个关键限定:
text
必须是同一个线程。
可重入不是说任何线程都能进。其他线程依然要被挡在外面。
3. 为什么可重入需要"计数"
可重入锁不能只是简单地说"同一个线程可以再次进入"。它还必须知道:
这个线程到底重复拿了几次锁。
比如同一个线程拿了两次锁:
text
第一次 lock:重入次数 = 1
第二次 lock:重入次数 = 2
那释放时也必须对应释放两次:
text
第一次 unlock:重入次数从 2 减到 1,锁还不能删
第二次 unlock:重入次数从 1 减到 0,这时才能真正释放锁
如果第一次 unlock() 就直接删除锁,会发生什么?
text
外层方法还没执行完。
内层方法 unlock 时直接删了锁。
其他线程就能进来。
临界区被破坏。
所以可重入锁的核心不是"允许重复拿锁"这么简单,而是:
允许同一线程重复拿锁,并且必须记录重复次数;释放时次数递减,减到 0 才真正释放。
4. Java 里的可重入思想:state / count
讲义里提到,Java 的 Lock 底层会借助类似 state 的变量记录重入状态。
可以这样理解:
text
state = 0:没有线程持有锁
state = 1:某个线程第一次持有锁
state = 2:同一个线程重入了一次
state = 3:同一个线程又重入了一次
释放时则反过来:
text
unlock 一次:state - 1
直到 state = 0,锁才真正释放
synchronized 也有类似思想:同一个线程重复进入同一把锁保护的代码块时,会有重入计数;退出一次计数减一,完全退出后锁才释放。
Redisson 要在 Redis 里实现可重入,也必须设计一套类似的计数机制。
问题是:Redis 里应该怎么存这个计数?
5. 为什么 Redisson 不用简单 String 存锁
我们前面手写 SimpleRedisLock 时,Redis 里的锁大概长这样:
text
lock:order:10 = uuid-17
这个结构可以表达:
text
这把锁属于 uuid-17 这个线程。
但它不好表达:
text
uuid-17 这个线程已经重入了几次。
如果要支持可重入,Redisson 需要同时保存两类信息:
text
1. 这把锁是谁持有的。
2. 这个持有者已经重入了几次。
所以 Redisson 使用 Redis Hash 结构。
大概长这样:
text
lock:order:10 {
uuid:17 : 1
}
这里分三层看:
text
大 key:lock:order:10
表示这把锁本身。
小 key / field:uuid:17
表示当前持锁线程。
value:1
表示当前线程重入次数。
如果同一个线程再次获取这把锁,就变成:
text
lock:order:10 {
uuid:17 : 2
}
这就是 Redisson 可重入锁最关键的存储设计:
用 Redis Hash 的 value 保存重入次数。
6. 讲义 Lua 的三个参数是什么意思
讲义中说这段 Lua 有三个关键参数:
text
KEYS[1]:锁名称
ARGV[1]:锁失效时间
ARGV[2]:id + ":" + threadId,也就是锁的小 key
放到例子里看,假设当前锁是用户 10 的下单锁:
text
KEYS[1] = lock:order:10
ARGV[1] = 30000
ARGV[2] = uuid:17
它们分别表示:
text
lock:order:10:我要操作哪一把锁
30000:这把锁的过期时间,单位毫秒
uuid:17:当前尝试拿锁的线程是谁
这三个参数合起来,Redisson 就能判断:
text
这把锁是否存在?
如果存在,是不是我这个线程持有?
如果是我持有,应该把重入次数加几?
7. 逐行看懂 Redisson 可重入锁 Lua
7.1 第一种情况:锁不存在
lua
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;
end;
exists 是什么?
它是 Redis 命令,用来判断 key 是否存在。
输入是什么:锁名称,比如 lock:order:10。
输出是什么:不存在返回 0,存在返回 1。
为什么用:先判断这把锁有没有人持有。
例子:
redis
EXISTS lock:order:10
如果返回 0,说明当前锁不存在,可以加锁。
接着执行:
lua
redis.call('hset', KEYS[1], ARGV[2], 1);
意思是写入 Hash:
redis
HSET lock:order:10 uuid:17 1
表示:
当前线程第一次拿到这把锁,重入次数为 1。
然后执行:
lua
redis.call('pexpire', KEYS[1], ARGV[1]);
pexpire 是什么?
它是 Redis 的毫秒级过期时间命令。
输入是什么:key 和毫秒数。
输出是什么:设置结果。
为什么用:防止持锁线程异常退出后锁永远不释放。
例子:
redis
PEXPIRE lock:order:10 30000
表示这把锁 30 秒后自动过期。
最后:
lua
return nil;
在 Redisson 这里,nil 不是失败,而是表示加锁成功或重入成功。
7.2 第二种情况:锁存在,但属于当前线程
lua
if (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;
end;
hexists 是什么?
它是 Redis Hash 命令,用来判断某个 field 是否存在。
输入是什么:Hash 大 key 和 field。
输出是什么:存在返回 1,不存在返回 0。
为什么用:判断这把锁是不是当前线程已经持有。
例子:
redis
HEXISTS lock:order:10 uuid:17
如果返回 1,说明:
text
lock:order:10 这把锁已经由 uuid:17 这个线程持有。
这时候 Redisson 允许当前线程重入。
于是执行:
lua
redis.call('hincrby', KEYS[1], ARGV[2], 1);
hincrby 是什么?
它是 Redis Hash 命令,用来让某个 field 的值增加指定数值。
输入是什么:Hash 大 key、field、增加值。
输出是什么:增加后的值。
为什么用:同一个线程重入一次,重入计数就要 +1。
例子:
redis
HINCRBY lock:order:10 uuid:17 1
如果原来是:
text
lock:order:10 {
uuid:17 : 1
}
执行后变成:
text
lock:order:10 {
uuid:17 : 2
}
然后再次 pexpire,刷新锁过期时间。
这一步可以理解为:
当前线程既然还在使用这把锁,就重新设置一下过期时间。
7.3 第三种情况:锁存在,但属于别人
lua
return redis.call('pttl', KEYS[1]);
如果前两个条件都不满足,就说明:
text
锁存在。
但 Hash 里没有当前线程的 field。
所以锁不是当前线程持有的。
这时当前线程不能进入临界区。
pttl 是什么?
它是 Redis 命令,用来获取 key 剩余过期时间,单位是毫秒。
输入是什么:锁名称。
输出是什么:剩余 TTL。
为什么用:告诉 Redisson 这把锁大概还有多久过期,后续可用于等待和重试。
例子:
redis
PTTL lock:order:10
返回 25000,表示这把锁大约还有 25 秒过期。
8. 可重入加锁流程图
这段 Lua 的逻辑可以画成这样:
#mermaid-svg-Uz80ORUXptVvGWfK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Uz80ORUXptVvGWfK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Uz80ORUXptVvGWfK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Uz80ORUXptVvGWfK .error-icon{fill:#552222;}#mermaid-svg-Uz80ORUXptVvGWfK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Uz80ORUXptVvGWfK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Uz80ORUXptVvGWfK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Uz80ORUXptVvGWfK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Uz80ORUXptVvGWfK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Uz80ORUXptVvGWfK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Uz80ORUXptVvGWfK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Uz80ORUXptVvGWfK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Uz80ORUXptVvGWfK .marker.cross{stroke:#333333;}#mermaid-svg-Uz80ORUXptVvGWfK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Uz80ORUXptVvGWfK p{margin:0;}#mermaid-svg-Uz80ORUXptVvGWfK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Uz80ORUXptVvGWfK .cluster-label text{fill:#333;}#mermaid-svg-Uz80ORUXptVvGWfK .cluster-label span{color:#333;}#mermaid-svg-Uz80ORUXptVvGWfK .cluster-label span p{background-color:transparent;}#mermaid-svg-Uz80ORUXptVvGWfK .label text,#mermaid-svg-Uz80ORUXptVvGWfK span{fill:#333;color:#333;}#mermaid-svg-Uz80ORUXptVvGWfK .node rect,#mermaid-svg-Uz80ORUXptVvGWfK .node circle,#mermaid-svg-Uz80ORUXptVvGWfK .node ellipse,#mermaid-svg-Uz80ORUXptVvGWfK .node polygon,#mermaid-svg-Uz80ORUXptVvGWfK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Uz80ORUXptVvGWfK .rough-node .label text,#mermaid-svg-Uz80ORUXptVvGWfK .node .label text,#mermaid-svg-Uz80ORUXptVvGWfK .image-shape .label,#mermaid-svg-Uz80ORUXptVvGWfK .icon-shape .label{text-anchor:middle;}#mermaid-svg-Uz80ORUXptVvGWfK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Uz80ORUXptVvGWfK .rough-node .label,#mermaid-svg-Uz80ORUXptVvGWfK .node .label,#mermaid-svg-Uz80ORUXptVvGWfK .image-shape .label,#mermaid-svg-Uz80ORUXptVvGWfK .icon-shape .label{text-align:center;}#mermaid-svg-Uz80ORUXptVvGWfK .node.clickable{cursor:pointer;}#mermaid-svg-Uz80ORUXptVvGWfK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Uz80ORUXptVvGWfK .arrowheadPath{fill:#333333;}#mermaid-svg-Uz80ORUXptVvGWfK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Uz80ORUXptVvGWfK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Uz80ORUXptVvGWfK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Uz80ORUXptVvGWfK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Uz80ORUXptVvGWfK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Uz80ORUXptVvGWfK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Uz80ORUXptVvGWfK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Uz80ORUXptVvGWfK .cluster text{fill:#333;}#mermaid-svg-Uz80ORUXptVvGWfK .cluster span{color:#333;}#mermaid-svg-Uz80ORUXptVvGWfK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Uz80ORUXptVvGWfK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Uz80ORUXptVvGWfK rect.text{fill:none;stroke-width:0;}#mermaid-svg-Uz80ORUXptVvGWfK .icon-shape,#mermaid-svg-Uz80ORUXptVvGWfK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Uz80ORUXptVvGWfK .icon-shape p,#mermaid-svg-Uz80ORUXptVvGWfK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Uz80ORUXptVvGWfK .icon-shape .label rect,#mermaid-svg-Uz80ORUXptVvGWfK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Uz80ORUXptVvGWfK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Uz80ORUXptVvGWfK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Uz80ORUXptVvGWfK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 不存在
存在
有
没有
线程尝试获取锁
锁 key 是否存在?
HSET 锁名 当前线程 1
PEXPIRE 设置过期时间
返回 nil:加锁成功
Hash 中是否有当前线程 field?
HINCRBY 重入次数 +1
PEXPIRE 刷新过期时间
返回 nil:重入成功
PTTL 返回锁剩余时间
当前线程获取锁失败
如果压成一句话:
锁不存在就创建锁;锁存在但属于自己就重入计数 +1;锁存在但属于别人就返回剩余过期时间。
9. 黑马点评业务中真的用到了可重入吗
这个问题很重要。
在黑马点评当前讲义的秒杀下单代码里,常见写法是:
java
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock();
try {
return proxy.createVoucherOrder(voucherId);
} finally {
lock.unlock();
}
这段业务代码表面上没有明显出现"同一个线程重复获取同一把锁"的场景。
所以更准确的说法是:
黑马点评这段秒杀业务不一定强依赖可重入,但 Redisson 提供的是通用成熟锁,所以默认支持可重入。
这就像 synchronized 也是可重入的。你不是每次业务都一定用到它的可重入特性,但它作为一把通用锁,必须具备这个能力。
所以 5.3 的意义不是说:
text
黑马点评这个业务必须可重入,否则跑不了。
而是说:
text
Redisson 的 RLock 是成熟分布式锁,它要支持更通用的嵌套加锁场景。
10. 如果没有可重入,会怎样
假设有这样一个业务结构:
java
public void createOrder() {
lock.lock();
try {
deductStock();
} finally {
lock.unlock();
}
}
public void deductStock() {
lock.lock();
try {
// 扣库存
} finally {
lock.unlock();
}
}
如果锁不可重入,那么同一个线程执行流程会变成:
text
1. createOrder 拿到锁。
2. createOrder 调用 deductStock。
3. deductStock 再次尝试拿同一把锁。
4. 锁发现已经存在,于是拒绝。
5. 当前线程等待自己释放锁。
6. 但自己正在等待,无法继续执行到 unlock。
7. 死锁。
这就是可重入锁要解决的问题。
11. 易错点
易错点一:把 5.3 的 Lua 当成业务 Lua
这段 Lua 不是我们项目自己写在 resources 下的脚本,而是 Redisson 框架内部用于实现 RLock 的逻辑。
易错点二:以为可重入是"所有线程都能重复进"
可重入只允许同一个线程重复获取同一把锁。其他线程仍然不能进入。
易错点三:只记住 hincrby +1,忘了解锁也要 -1
可重入一定是成对的。加锁几次,解锁几次。只有计数减到 0,锁才真正释放。
易错点四:混淆大 key 和小 key
text
大 key:锁名,比如 lock:order:10
小 key:线程标识,比如 uuid:17
value:重入次数,比如 1、2、3
易错点五:以为 return nil 是失败
在 Redisson 的这段加锁 Lua 中,返回 nil 通常表示加锁成功或重入成功。返回剩余 TTL 才表示锁被别人持有,当前线程没拿到锁。
12. 面试回答
问:什么是可重入锁?
可以这样回答:
可重入锁是指同一个线程已经持有某把锁时,可以再次获取这把锁而不会被自己阻塞。它通常通过重入计数实现,线程每获取一次锁计数加一,每释放一次锁计数减一,直到计数为零才真正释放锁。
问:Redisson 的可重入锁是怎么实现的?
可以这样回答:
Redisson 使用 Redis Hash 结构保存锁信息。大 key 表示锁名称,Hash 的 field 表示持锁线程标识,value 表示该线程的重入次数。加锁时,如果锁不存在就
hset当前线程并设置次数为 1;如果锁已经存在且 field 是当前线程,就通过hincrby将重入次数加 1;如果锁属于其他线程,则返回锁的剩余过期时间。
问:为什么 Redisson 不用简单 String 存锁?
可以这样回答:
简单 String 只能方便地表示"锁属于谁",不方便记录同一个线程的重入次数。可重入锁需要同时记录持锁线程和重入计数,所以 Redisson 使用 Hash 结构更合适。
13. 总结
5.3 这一节看起来突然讲了一段 Lua,但它不是黑马点评项目自己写的业务 Lua,而是 Redisson 内部实现 RLock 可重入能力的核心逻辑。
Redisson 可重入锁的本质是:
text
用 Redis Hash 表示锁。
大 key 表示锁名。
小 key 表示当前持锁线程。
value 表示重入次数。
当锁不存在时,当前线程可以第一次加锁;当锁存在且属于当前线程时,说明这是重入,计数加一;当锁存在但属于其他线程时,当前线程不能进入,只能等待或失败。
真正理解这一节后,再看后面的 WatchDog 和锁重试就不会那么突兀了。因为第 5 章不是在新增黑马点评业务,而是在逐步解释:Redisson 为什么是一把比手写 Redis 锁更成熟的分布式锁。