黑马点评-Redisson-02_reentrant_lock

黑马点评 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 锁更成熟的分布式锁。

相关推荐
葡萄成熟时 !3 小时前
JAVA 常用API学习笔记
java·笔记·学习
Lyyaoo.3 小时前
Spring Boot Validation 声明式参数校验
spring boot·后端·mysql
Rain的Java大神之路4 小时前
介绍一下分布式事务
java·分布式·后端·spring·spring cloud·架构·springcloud
吴声子夜歌4 小时前
Java面试题——基础(一)
java·开发语言
南城以南溫暖如初1475 小时前
外卖CPS实战指南:从技术选型到运营落地全流程解析
java·spring boot·mysql·架构·vue
傻啦嘿哟5 小时前
英雄联盟皮肤爬虫:爬取全皮肤价格与特效,做比价工具
java·c++·爬虫
码匠许师傅6 小时前
【C++ 面试真题】24. 聊聊 C++ 的时间日期处理
java·c++·面试
mqiqe7 小时前
AgentScope Java 2.0 Agent 状态存储(AgentStateStore)深度解析:构建可恢复、可扩展的智能体运行时
java·运维·网络
zhifou1234567 小时前
java 17升级安装
java·开发语言
用户3721574261357 小时前
如何使用 Java 将 Markdown 转换为 PDF(含自定义设置)
java