news 2026/6/2 14:49:01

黑马点评-分布式锁-03_lua_atomic_unlock

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
黑马点评-分布式锁-03_lua_atomic_unlock

黑马点评分布式锁三:为什么判断了锁归属,还要用 Lua 解锁?

本文继续整理黑马点评 Redis 实战篇第 4 章「分布式锁」。

上一篇讲了 Redis 分布式锁的基础版本:用setIfAbsent抢锁,给锁设置过期时间,并在 value 中保存线程标识。

这一篇讲4.44.8:为什么直接删除锁会误删别人?为什么“先判断是不是自己的锁,再删除”仍然不够?Lua 脚本到底解决了什么问题?


1. 这篇文章解决什么问题

在 Redis 分布式锁版本一中,解锁可能会写成:

publicvoidunlock(){stringRedisTemplate.delete(KEY_PREFIX+name);}

这看起来很自然:

我业务执行完了,我把锁删掉。

但这里藏着一个很危险的问题:

你删掉的锁,可能已经不是你自己的锁。

于是课程继续演进:

第一步:直接 delete 解锁 ↓ 问题:可能误删别人的锁 ↓ 第二步:value 存线程标识,删除前先判断 ↓ 问题:GET 判断和 DEL 删除不是原子操作 ↓ 第三步:用 Lua 把判断和删除合成 Redis 端一次原子操作

本文就把这条演进链讲透。


2. 为什么会误删别人的锁

先看最朴素的解锁:

publicvoidunlock(){stringRedisTemplate.delete(KEY_PREFIX+name);}

它的问题是:

不管当前 Redis 里的锁是谁加的,我都直接删除。

如果业务一切正常,没问题。

但分布式锁一定要考虑异常时序。


3. 误删场景一:锁过期后被别人重新拿到

假设锁过期时间是 10 秒。

线程 A 先拿到锁:

lock:order:10 -> A TTL = 10s

然后线程 A 执行业务时卡住了。

可能原因很多:

1. 业务代码执行慢。 2. 数据库调用卡住。 3. 网络抖动。 4. JVM 发生较长停顿。 5. 线程被调度挂起。

重点不是具体原因,而是:

A 没死,但超过了锁的过期时间。

10 秒后,Redis 自动删除锁。

这时线程 B 也来处理同一个用户的下单请求。

B 发现:

lock:order:10 不存在

于是 B 成功拿到同名新锁:

lock:order:10 -> B

此时 A 恢复执行,终于走到unlock()

如果 A 直接执行:

delete("lock:order:10")

那它删掉的就是 B 的锁。


4. 为什么 B 会拿到同名新锁

这里有一个初学者常见疑惑:

B 为什么拿到的是同名新锁?

因为 A 和 B 操作的是同一个业务对象。

比如它们都是:

用户 10 的下单请求

所以它们本来就应该竞争同一把业务锁:

lock:order:10

锁过期后,这个 key 被 Redis 删除。

B 再来执行SET key value NX EX timeout时,发现 key 不存在,于是就能重新创建这个 key。

所以叫:

同名的新锁

key 名字一样,但 value 已经从 A 的线程标识变成 B 的线程标识。


5. 误删流程图

线程BRedis线程A线程BRedis线程ASET lock:order:10 = A NX EX 10加锁成功执行业务,发生阻塞10秒后锁自动过期SET lock:order:10 = B NX EX 10加锁成功恢复执行DEL lock:order:10删除了B的锁

这就是误删别人的锁。

本质是:

旧线程恢复执行时,锁的归属已经变了。


6. 第一层修复:删除前先判断锁归属

为了解决直接删除的问题,讲义提出:

加锁时把线程标识存入 value。 解锁时先读取 value。 如果 value 等于当前线程标识,才删除。

加锁代码:

privatestaticfinalStringID_PREFIX=UUID.randomUUID().toString(true)+"-";@OverridepublicbooleantryLock(longtimeoutSec){StringthreadId=ID_PREFIX+Thread.currentThread().getId();Booleansuccess=stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX+name,threadId,timeoutSec,TimeUnit.SECONDS);returnBoolean.TRUE.equals(success);}

解锁代码:

publicvoidunlock(){StringthreadId=ID_PREFIX+Thread.currentThread().getId();Stringid=stringRedisTemplate.opsForValue().get(KEY_PREFIX+name);if(threadId.equals(id)){stringRedisTemplate.delete(KEY_PREFIX+name);}}

这段代码的业务含义是:

1. 我先查 Redis 里的锁 value。 2. 看看这个 value 是不是我的线程标识。 3. 如果是我的,说明锁还属于我,可以删。 4. 如果不是我的,说明锁已经属于别人,不能删。

这个思路是正确的。

它解决了“裸删锁”的大问题。


7. 为什么只用线程 id 不够

线程标识通常写成:

ID_PREFIX+Thread.currentThread().getId()

其中:

privatestaticfinalStringID_PREFIX=UUID.randomUUID().toString(true)+"-";

ID_PREFIX可以理解成当前 JVM 实例的随机标识。

Thread.currentThread().getId()是当前线程 id。

为什么不只用线程 id?

因为不同 JVM 中可能都有线程 17。

如果只存:

17

跨 JVM 可能撞。

所以更稳的写法是:

JVM随机前缀 + 线程id

比如:

a8f3c91b-17

8. 新问题:判断了为什么还不够

到这里可能会产生新的疑惑:

我都已经判断锁是不是自己的了,为什么还要 Lua?

问题在于:

GET 判断和 DEL 删除是两条 Redis 命令。

也就是说,这段 Java 代码:

Stringid=stringRedisTemplate.opsForValue().get(KEY_PREFIX+name);if(threadId.equals(id)){stringRedisTemplate.delete(KEY_PREFIX+name);}

在 Redis 层面不是一个整体。

它拆成了:

1. GET lock:order:10 2. Java 判断 value 是否相等 3. DEL lock:order:10

中间可能插入其他事件。

这就是原子性问题。


9. 什么叫原子性

原子性可以简单理解为:

一组操作要么完整执行完,中间不能被别人插进来;要么就不执行。

在这里,我们希望:

判断锁归属 删除锁

这两件事是一个不可拆分的整体。

但 Java 中先GETDEL不是整体。

中间可能发生:

锁过期 其他线程重新加锁

10. 极端误删时序:判断正确,删除时已经不正确

来看一个更极端的场景。

线程 A 拿到了锁:

lock:order:10 -> A

然后 A 准备解锁。

它先执行:

Stringid=redis.get("lock:order:10");

此时 Redis 返回:

A

A 判断:

锁是我的,可以删。

但就在它执行delete之前,锁过期了。

线程 B 立刻拿到了新锁:

lock:order:10 -> B

然后 A 继续执行:

redis.delete("lock:order:10");

结果又把 B 的锁删了。

流程图:

线程BRedis线程A线程BRedis线程AGET lock:order:10返回 A判断成功,锁属于自己锁过期,自动删除SET lock:order:10 = B NX EX 10B 加锁成功DEL lock:order:10删除了 B 的锁

这说明:

判断时正确,不代表删除时仍然正确。

根因就是:

GET + 判断 + DEL 不是原子操作。

11. Lua 为什么能解决这个问题

既然问题出在多条 Redis 命令分开执行,那解决思路就是:

把“判断锁归属 + 删除锁”放到 Redis 服务器内部一次执行完。

Redis 提供了 Lua 脚本功能。

我们可以在 Lua 脚本里写多条 Redis 命令。

Redis 执行脚本时,会把脚本作为一个整体执行。

在脚本执行过程中,不会有其他 Redis 命令插进来。

所以 Lua 在这里解决的是:

多条 Redis 操作的原子性问题。

它不是为了炫技。

它就是为了让:

GET 判断 + DEL 删除

变成一个不可插队的整体。


12. unlock.lua 逐行解释

项目中的 Lua 脚本是:

if(redis.call('GET',KEYS[1])==ARGV[1])thenreturnredis.call('DEL',KEYS[1])endreturn0

这段脚本很短,但非常关键。


13. KEYS[1] 是什么

KEYS[1]表示 Java 调用 Lua 时传入的第一个 key 参数。

在这里,它就是锁 key。

比如:

lock:order:10

所以:

redis.call('GET',KEYS[1])

就相当于:

GET lock:order:10

14. ARGV[1] 是什么

ARGV[1]表示 Java 调用 Lua 时传入的第一个普通参数。

在这里,它是当前线程标识。

比如:

a8f3c91b-17

所以第一行:

if(redis.call('GET',KEYS[1])==ARGV[1])then

意思是:

如果 Redis 中锁的 value 等于当前线程标识

也就是:

如果这把锁确实属于我

15. DEL 做了什么

如果判断成立:

returnredis.call('DEL',KEYS[1])

就删除锁 key。

如果判断不成立:

return0

什么都不删。

所以整段 Lua 可以翻译成一句话:

如果锁是我的,就删;如果锁不是我的,就不动。

最关键的是:

这句判断和删除在 Redis 内部一次性执行完成。


16. Java 怎么加载 Lua 脚本

项目中用DefaultRedisScript表示 Lua 脚本:

privatestaticfinalDefaultRedisScript<Long>UNLOCK_SCRIPT;static{UNLOCK_SCRIPT=newDefaultRedisScript<>();UNLOCK_SCRIPT.setLocation(newClassPathResource("unlock.lua"));UNLOCK_SCRIPT.setResultType(Long.class);}

逐行看。

DefaultRedisScript 是什么

DefaultRedisScript是 Spring Data Redis 提供的脚本封装对象。

它用来告诉 RedisTemplate:

我要执行一段 Redis Lua 脚本。

ClassPathResource 是什么

newClassPathResource("unlock.lua")

表示从类路径下加载unlock.lua

在 Spring Boot 项目里,src/main/resources下的文件会进入类路径。

所以这里能找到:

unlock.lua

setResultType 是什么

UNLOCK_SCRIPT.setResultType(Long.class);

表示这个 Lua 脚本返回值类型是Long

因为:

returnredis.call('DEL',KEYS[1])

或者:

return0

最终都是数字。


17. Java 怎么执行 Lua 脚本

最终版unlock()是:

@Overridepublicvoidunlock(){stringRedisTemplate.execute(UNLOCK_SCRIPT,Collections.singletonList(KEY_PREFIX+name),ID_PREFIX+Thread.currentThread().getId());}

这个execute有三个关键部分。

第一个参数:UNLOCK_SCRIPT

UNLOCK_SCRIPT

表示要执行哪个 Lua 脚本。

也就是刚才加载的unlock.lua

第二个参数:keys

Collections.singletonList(KEY_PREFIX+name)

这是传给 Lua 的KEYS数组。

这里只有一个 key。

比如:

lock:order:10

在 Lua 中对应:

KEYS[1]

第三个参数:args

ID_PREFIX+Thread.currentThread().getId()

这是传给 Lua 的ARGV数组。

这里只有一个参数,也就是当前线程标识。

在 Lua 中对应:

ARGV[1]

所以整段 Java 调用可以翻译成:

请 Redis 执行 unlock.lua。 KEYS[1] = 当前锁 key。 ARGV[1] = 当前线程标识。

18. Lua 解锁完整流程图

业务执行完,调用 unlock

Java 计算锁 key

Java 计算当前线程标识

execute 执行 unlock.lua

Redis 内部 GET KEYS[1]

锁 value 是否等于 ARGV[1]?

DEL KEYS[1]

return 0,不删除

这张图的重点是:

判断和删除都在 Redis 内部完成。

19. 手写 Redis 锁最终版本总结

把第四章手写 Redis 锁完整串起来:

1. 本地锁只能锁当前 JVM,所以需要分布式锁。 2. Redis 是共享中间件,可以让多个服务实例竞争同一个锁 key。 3. 获取锁用 SET key value NX EX timeout。 4. NX 保证互斥。 5. EX 保证异常时锁能自动释放。 6. value 保存线程标识,便于判断锁归属。 7. 直接 delete 可能误删别人的锁。 8. 先 GET 判断再 DEL 仍然不是原子操作。 9. Lua 把判断和删除合成 Redis 端原子操作。

20. 但手写锁还有一个问题

第四章末尾会提到:

如果业务执行时间超过锁过期时间,锁还是会提前释放。

Lua 可以解决:

不要误删别人的锁。

但它不能解决:

业务没执行完,锁已经过期。

也就是说,Lua 只是保证解锁安全。

它不能自动给锁续期。

这个问题后面会引出 Redisson 的看门狗机制。

不过这是下一章内容。

本文只关注第四章的手写 Redis 分布式锁。


21. 本篇易错点

1. 加过期时间是为了避免死锁,但也会引出锁提前释放问题

没有过期时间,服务挂了可能死锁。

有过期时间,业务超时可能导致锁被别人重新拿到。

2. 线程 A 不是从线程 B 手里抢回运行权

线程 A 可能只是之前阻塞了,后来恢复继续执行。

它恢复时,锁可能已经过期并被 B 重新持有。

3. 判断锁归属只能解决一部分误删问题

如果GETDEL分开执行,判断之后锁状态仍可能变化。

4. Lua 解决的是原子性

Lua 的重点不是语法,而是:

让多条 Redis 操作一次性执行。

5. KEYS 和 ARGV 不要混

KEYS[1]是锁 key。

ARGV[1]是当前线程标识。


22. 面试怎么回答

如果面试官问:Redis 分布式锁为什么不能直接 delete 解锁?

可以这样回答:

因为持锁线程的业务执行时间可能超过锁过期时间,锁过期后可能已经被其他线程重新获取。如果旧线程恢复后直接删除同名 key,就可能把别人的新锁删掉,所以不能直接 delete。

如果面试官问:为什么要在锁 value 中保存线程标识?

可以这样回答:

保存线程标识是为了释放锁时判断锁归属。解锁前先比较 Redis 中的 value 是否等于当前线程标识,只有锁属于自己时才允许删除,避免误删其他线程持有的锁。

如果面试官问:既然判断了锁归属,为什么还要 Lua?

可以这样回答:

因为如果在 Java 中先 GET 判断,再 DEL 删除,这是两条 Redis 命令,中间可能发生锁过期并被其他线程重新获取。这样判断时锁属于自己,但删除时锁已经属于别人。Lua 可以把判断和删除放到 Redis 内部一次执行,保证原子性。

如果面试官问:Lua 脚本里的KEYS[1]ARGV[1]分别是什么?

可以这样回答:

KEYS[1]是 Java 调用脚本时传入的锁 key,例如lock:order:10ARGV[1]是当前线程的唯一标识。脚本会判断GET KEYS[1]是否等于ARGV[1],相等才删除锁。


23. 总结

第四章手写 Redis 分布式锁的演进很清晰:

本地锁只能锁当前 JVM ↓ 用 Redis key 实现跨 JVM 共享锁 ↓ setIfAbsent 保证互斥 ↓ 过期时间避免死锁 ↓ 线程标识避免裸删别人的锁 ↓ Lua 保证判断和删除的原子性

最重要的是不要只背代码,而要理解每一步解决的问题:

setIfAbsent:解决谁先拿到锁的问题。 过期时间:解决持锁线程异常导致死锁的问题。 线程标识:解决锁归属判断的问题。 Lua:解决判断和删除不是原子操作的问题。

到这里,手写 Redis 分布式锁已经形成了一个比较完整的版本。

但它仍然不是工业级最终答案。

因为如果业务执行时间超过锁的 TTL,锁还是会提前释放。

这个问题会在后续 Redisson 中继续解决。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/2 14:46:56

如何永久保存微信聊天记录:WeChatMsg完全免费终极指南

如何永久保存微信聊天记录&#xff1a;WeChatMsg完全免费终极指南 【免费下载链接】WeChatMsg 提取微信聊天记录&#xff0c;将其导出成HTML、Word、CSV文档永久保存&#xff0c;对聊天记录进行分析生成年度聊天报告 项目地址: https://gitcode.com/GitHub_Trending/we/WeCha…

作者头像 李华
网站建设 2026/6/2 14:44:29

5个常见岛屿设计难题?Happy Island Designer完整解决方案

5个常见岛屿设计难题&#xff1f;Happy Island Designer完整解决方案 【免费下载链接】HappyIslandDesigner "Happy Island Designer (Alpha)"&#xff0c;是一个在线工具&#xff0c;它允许用户设计和定制自己的岛屿。这个工具是受游戏《动物森友会》(Animal Crossi…

作者头像 李华
网站建设 2026/6/2 14:41:13

容联云为头部汽金公司,打造了个“会追问”的信审专家Agent

车贷欺诈&#xff0c;正经历一场危险的进化。当前团伙欺诈的典型模式是&#xff1a;雇佣征信“白户”&#xff0c;伪造工作和收入材料&#xff0c;完成真实车辆交易后迅速转卖获利。客户身份真实、车辆真实、交易真实——唯一虚假的是“购车自用”的真实意图。传统规则系统能验…

作者头像 李华
网站建设 2026/6/2 14:40:34

解锁Windows 11 LTSC隐藏宝藏:一键恢复微软商店完整指南

解锁Windows 11 LTSC隐藏宝藏&#xff1a;一键恢复微软商店完整指南 【免费下载链接】LTSC-Add-MicrosoftStore Add Windows Store to Windows 11 24H2 LTSC 项目地址: https://gitcode.com/gh_mirrors/ltscad/LTSC-Add-MicrosoftStore 你是否正在使用Windows 11 LTSC版…

作者头像 李华