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

相关推荐
瀚高PG实验室9 分钟前
java中间件无法连接数据库
java·数据库·中间件·瀚高数据库
东南门吹雪12 分钟前
JAVA TCP socket编程框架
java·高并发·socket·tcp·nio
xingyuzhisuan13 分钟前
缓存命中率提升方案:从 30% 优化至 82% 全流程优化记录
java·开发语言·缓存·ai
Konwledging21 分钟前
Cache Incoherent(缓存不一致)
缓存
一条泥憨鱼23 分钟前
Java开发效率神器:Lombok从入门到精通!
java·后端·学习·开发·lombok
Jinkxs25 分钟前
Python基础 - 初识内置函数 Python自带的便捷工具
android·java·python
熠熠仔25 分钟前
Spring Boot 与 MyBatis-Plus 空间几何数据集成指南
spring boot·后端·mybatis
AI 小老六32 分钟前
Google AX 控制面拆解:分布式 Agent 如何把断点恢复、审计策略和执行调度收进同一条链路
人工智能·分布式·后端·ai·架构·ai编程
奥利奥夹心脆芙34 分钟前
零基础调试 Java 代码:Gemini 报错排查完整实操指南
java
天青色等烟雨..1 小时前
智慧农林核心遥感技术99个案例实践
运维·人工智能·spring boot·后端·自动化