在构建高并发系统时,分布式锁是保证数据一致性的关键工具。然而,如果锁的实现不够严谨,反而会引入线程安全问题。本文深入剖析Redis分布式锁的常见陷阱,并展示如何通过Lua脚本实现原子性操作,确保释放锁时的身份校验,从而构建一个可靠的锁机制。
问题溯源:锁超时引发的线程安全危机
分布式锁的核心思想是让多个服务节点互斥地访问共享资源。在Redis中,我们通常使用 SETNX 命令加锁,并设置过期时间防止死锁。然而,当业务逻辑执行时间超过锁的过期时间时,锁会被自动释放,此时其他线程就能获取到锁,导致并发访问。

这种情况下,第一个线程还在执行,但锁已经失效。第二个线程获取锁后开始操作,最终两个线程同时修改同一份数据,造成数据不一致。这就是典型的“锁超时”问题。
个人主页:北极的代码(欢迎来访)
作者简介:java后端学习者
❄️个人专栏:苍穹外卖日记,SSM框架深入,JavaWeb
✨命运的结局尽可永在,不屈的挑战却不可须臾或缺!
在此之前我们实战实现类基于Redis的分布式锁,但这仅仅是最初级的方案,具体的业务中仅靠这个就不够的,这一章节主要是来分析一下这个初级方案存在的问题和缺陷,以及如何解决这些缺陷,进一步的进行优化。
本文分析了基于Redis的分布式锁初级方案存在的线程安全问题,主要由于锁超时自动释放时未进行身份校验。
当业务执行时间超过锁过期时间(如GC、慢SQL等原因),会导致其他线程误删当前锁。解决方案包括:1)加锁时设置唯一标识(UUID+线程ID);2)使用Lua脚本实现原子化的锁释放操作,确保只有持有者能删除锁。
文章详细介绍了Lua脚本的实现原理和代码示例,通过DefaultRedisScript封装脚本,实现高效安全的分布式锁管理。
⚡ 核心问题:释放锁时未校验身份
很多开发者会在finally块中直接调用 del 命令释放锁,但这样做存在巨大隐患——如果锁已经超时并被其他线程获取,当前线程的 del 会错误地删除别人的锁。这就像你打开了自己的储物柜,却发现里面已经换成了别人的物品,而你却把别人的锁给拆了。

线程A 获取锁成功,开始执行业务。
业务堵塞(如GC、慢SQL、网络卡顿),导致线程A执行时间 超过 了锁的过期时间。
锁自动释放:Redis检测到锁到期,自动删除了线程A持有的锁(此时线程A还在傻傻地执行)。
线程B 获取锁成功(因为线程A的锁已过期),开始执行自己的业务。
线程A 执行完毕,执行 释放锁。
灾难发生:此时 里存的是线程B的锁标识,却被线程A删掉了。
线程C 发现锁被删了,又可以获取锁了,于是和线程B同时执行,数据全乱。
核心问题:释放锁时没有校验身份。在“业务堵塞导致锁超时”这个场景里,锁超时是结果,业务堵塞是直接原因。而业务堵塞本身,通常是由以下5类根本原因导致的:
1. 最典型的:依赖外部资源变慢(Infrastructure)
这是生产环境最常见的原因。业务代码在持有锁的过程中,调用了某个外部服务,该服务响应变慢甚至卡死。
数据库慢查询:持有锁时执行了一个没有索引的 或复杂 ,数据库 CPU 飙升,SQL 执行了 30 秒(锁只有 10 秒)。
调用下游 RPC/HTTP 超时设置过长:调用的微服务或第三方 API 挂起,代码卡在 阶段,长时间不返回。
Redis/网络本身抖动:虽然拿着 Redis 锁,但业务逻辑里又去请求同一个 Redis做其他操作,此时 Redis 网络故障导致操作卡住。
2. 代码逻辑问题(Logic / Concurrency)
死锁/活锁:业务 A 持有锁 L1,去请求锁 L2;业务 B 持有锁 L2,去请求锁 L1。互相等待,无限期阻塞。
无限循环/递归:业务逻辑里有 bug 导致 无法退出,或者递归没有终止条件。
同步非异步处理:消息队列消费、事件处理中,用了同步阻塞的方式处理大批量数据,导致单次处理时间远超预期。
3. JVM/操作系统层面(Resource Contention)
Full GC (Stop-The-World):JVM 垃圾回收时,所有应用线程暂停。如果业务线程正持有 Redis 锁,GC 持续 15 秒,锁过期了但业务还没执行完。
CPU 饥饿:其他疯狂运行的线程占满了 CPU 时间片,导致持有锁的线程长时间得不到调度(虽然代码就几行,但一直没机会执行)。
内存 Swap:物理内存不足,JVM 进程被操作系统换出到磁盘,换入换出极慢。
4. 存储/IO 操作过重
文件/网络 IO 阻塞:持有锁时写本地大文件、上传大附件到 OBS,磁盘 IO 队列满了,write 操作长时间阻塞。
批量操作未分批:拿着锁一次性处理数据库里 10 万条数据(没有分页循环提交),导致单次持有锁时间分钟级。
5. 资源池耗尽(Pool Exhaustion)
数据库连接池满:业务逻辑里需要数据库连接,但连接池已满, 方法阻塞等待。
HTTP 连接池满:调用下游服务时,连接池无可用连接,线程阻塞在获取连接上。
如何快速判断业务堵塞类型
你可以通过以下方式快速定位是哪种“堵塞”:
| 场景 | 典型现象 | 排查命令 |
|---|---|---|
| GC 导致 | 锁超时时间规律(每次间隔 JVM GC 时间), 显示 FGC 频繁 | |
| 慢 SQL 导致 | 数据库监控有长时间未结束的查询,MySQL 进程列表有 状态 | |
| 下游 API 卡死 | 网络抓包看到 重传或 ,或 卡在 系统调用 | 的 / |
| 死锁 | 多个相关锁同时超时,线程堆栈出现 状态 | |
| CPU/资源饥饿 | 系统 很高,但业务线程堆栈显示 极少 | 查看各线程 CPU |
一句话总结:业务堵塞的本质,不是锁代码写错了,而是加锁范围内的“其他操作”出现了不可预料的延迟(GC、慢SQL、下游挂起等)。锁只是一个放大镜,把原本微小的延迟放大成了系统级故障。
✅ 解决方案:释放锁前必须验明正身
删除锁时,不能简单地 del key,必须确保当前线程持有的值和Redis里存储的值一致。
DEL key正确做法(加唯一标识):
加锁时:
设置一个只有当前线程知道的唯一值 (例如 )。
之前我们用的是仅仅是线程ID,这个线程id是JVM内部自己维护的递增值,在集群下每个JVM都会维护一个递增线程ID,这样就很容易重复 。出现线程id冲突的情况。
解锁时:
先用Lua脚本检查 是否匹配,匹配才删除。
初步没有用lua脚本时:
这里还存在一个小问题:
其实跟我们上面说的阻塞是一个逻辑,主要的是JVM的垃圾回收,毕竟这中间没什么业务,仅仅是释放锁,因为被阻塞,有可能就会超过锁设置的时间,之后别的线程拿到锁,后来阻塞消失,一开始的线程释放锁,但是释放的是别人的锁,因此还是会有线程安全问题。

在分布式系统中,除了Redis,MongoDB 也提供了类似的事务机制和乐观锁来控制并发。但Redis以其高性能和原子性操作,成为实现分布式锁的首选。对于需要强一致性的场景,PostgreSQL 和 MySQL 的行级锁也是可选方案,不过它们更适用于单库环境。
Lua脚本:实现原子性校验与删除
private static final String KEY_PREFIX = "lock:";
//锁的key,UUID避免线程安全
private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";
public boolean tryLock(Long timeoutSec) {
//设置value时,我们要知道是哪个线程的值
String threadId = ID_PREFIX+Thread.currentThread().getId();
//获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
public void unlock() {
//要设置到锁里的值(自己线程的身份标识)
String threadId = ID_PREFIX+Thread.currentThread().getId();
//从 Redis 锁里读取出来的值(当前锁持有者的身份标识)
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
//判断锁的持有者
if (threadId.equals(id)) {
//释放锁
stringRedisTemplate.delete(KEY_PREFIX+name);
}
lua
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
Lua脚本是Redis中实现原子操作的关键技术。它可以把多个Redis命令打包成一个脚本,Redis一次性执行整个脚本,期间不会被其他命令打断。
为什么需要Lua脚本
以分布式锁释放为例,需要:
检查锁的值是不是自己的
删除锁
如果不用 Lua,这两步分开执行,在 GET 和 DEL 之间,锁可能过期被别的线程抢走,然后你 DEL 就把别人的锁删了。
Lua 脚本保证了:GET 和 DEL 之间,不会有任何其他命令插进来。
核心命令
分布式锁释放的经典脚本
执行逻辑:
GET 获取 Redis 里锁的值
和传入的 ARGV[1] 比较
相等则 DEL 删除,返回 1
不相等则不删,返回 0
关键: 整个 GET + 判断 + DEL 是一个原子操作,中间不会被打断。
如果使用Java代码先 get 判断值,再 del 删除,这两步操作之间存在时间窗口,并发情况下仍然可能出错。Lua脚本保证 get 和 del 是原子执行的。
具体代码实现
我们先在resource资源目录创建一个lua为后缀的文件,写好lua脚本。IDE会提醒安装lua相关插件,按提示操作即可。

核心目的:
| 没有独立文件 | 有独立文件 |
|---|---|
| 脚本写在 Java 字符串里 | 脚本写在 文件里 |
| 直接写 Lua 语法,无需转义和拼接 | |
| 难读、难改、难维护 | 清晰、可读、易维护 |
bash
# 执行 Lua 脚本
EVAL "脚本内容" numkeys key1 key2 ... arg1 arg2 ...
# 或者先缓存脚本,得到 SHA1,然后用 EVALSHA 执行(更高效)
SCRIPT LOAD "脚本内容"
EVALSHA sha1 0 ...
lua
-- KEYS[1] = 锁的 key
-- ARGV[1] = 当前线程的唯一标识
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
1. 创建文件(你手动做)
src/main/resources/lua/unlock.lua
┌─────────────────────────────┐
│ if redis.call('get', KEYS[1]) == ARGV[1] then
│ return redis.call('del', KEYS[1])
│ else
│ return 0
│ end
└─────────────────────────────┘
2. 静态代码块中加载(类加载时执行)
┌─────────────────────────────────────────┐
│ static { │
│ UNLOCK_SCRIPT = new DefaultRedisScript<>(); │
│ UNLOCK_SCRIPT.setLocation( │
│ new ClassPathResource("lua/unlock.lua") │ ← 指向第1步的文件
│ ); │
│ UNLOCK_SCRIPT.setResultType(Long.class); │
│ } │
└─────────────────────────────────────────┘
3. 实现类中调用(业务方法执行时)
┌─────────────────────────────────────────┐
│ public void unlock(String name) { │
│ stringRedisTemplate.execute( │
│ UNLOCK_SCRIPT, │ ← 执行第2步加载的脚本
│ Collections.singletonList(key), │
│ threadId │
│ ); │
│ } │
└─────────────────────────────────────────┘
然后在分布式锁类中具体实现:我们想调用 execute 方法,但每次调用都从文件读取Lua脚本,重复执行IO流操作,效率很低。所以采用初始化方法,只执行一次。
DefaultRedisScript对象把脚本内容存下来了,后续 execute 时直接复用,不需要重新读文件。
execute| 操作 | 做什么 | 何时发生 | 频率 |
|---|---|---|---|
| 读文件 () | 从 文件读取内容到 Java 内存 | 类加载时(静态代码块执行) | 只一次 |
| 发脚本 () | 把脚本内容发送给 Redis 服务器 | 每次调用 方法 | 每次解锁都要发 |
DefaultRedisScript script = new DefaultRedisScript<>();
script.setScriptText("return 1"); // 把脚本装进去
script.setResultType(Long.class); // 告诉 Spring:脚本返回的是 Long 类型
DefaultRedisScript是Spring Data Redis提供的一个类,用于封装和管理Lua脚本。
简单理解
可以把它想象成一个脚本容器:
java
这个容器做的事情:
装脚本:存放 Lua 脚本的内容
定类型:声明脚本执行后的返回值类型
供执行:传给 方法
为什么需要它
Spring 需要一种方式把 Lua 脚本从 Java 代码传到 Redis
因此先声明一个静态常量,用来存放Lua脚本的封装对象(声明了一个叫 UNLOCK_SCRIPT 的容器,它将来会装一个返回 Long 类型的Lua脚本)。这里不能直接初始化,因为有 final 不能进行赋值操作,我们在静态代码块里进行初始化,静态代码块在类加载时自动执行一次,按顺序执行里面的三行代码。
UN_LOCK_SCRIPTLong
private static final DefaultRedisScript UN_LOCK_SCRIPT ;
//静态代码块进行初始化
static {
UN_LOCK_SCRIPT = new DefaultRedisScript<>();
UN_LOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UN_LOCK_SCRIPT.setResultType(Long.class);
}
| 代码 | 干了什么 |
|---|---|
| 声明一个容器变量(先贴标签,容器还是空的) | |
| 类加载时执行,给容器填内容 | |
| 创建真正的容器对象 | |
| 从文件读取 Lua 脚本,存入容器 | |
| 告诉 Spring 返回类型是 Long,方便自动转换 |
然后就是调用 execute:
/**
* 释放锁(用lua实现原子性)
*/
public void unlock() {
stringRedisTemplate.execute(UN_LOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX+name),
ID_PREFIX+Thread.currentThread().getId());
}
stringRedisTemplate.execute()的方法签名:
java
public T execute(RedisScript script, List keys, Object... args)
第二个参数必须是 List 类型,不能直接传一个 String;第三个参数是 Object 可变参数,可以直接传多个值。
List<K>StringObject...设计者意图:
- KEYS数组:代表Redis的键,这些键在集群模式下会被哈希到不同槽位,所以需要明确告诉Spring“这些都是键”。
- ARGV数组:代表普通参数,不参与集群哈希计算。
在实际项目中,除了Redis,MongoDB 的分布式事务和 PostgreSQL 的咨询锁也能实现类似效果。但Redis+Lua脚本的方案在性能和原子性上达到了最佳平衡。如果你正在使用 MySQL 的悲观锁,可以考虑迁移到Redis来获得更高的吞吐量。
结语
通过引入唯一标识和Lua脚本,我们彻底解决了分布式锁释放时的线程安全问题。核心要点是:释放锁前必须校验身份,且校验和删除操作必须原子执行。这种模式不仅适用于Redis,也能推广到其他分布式协调场景中。掌握Lua脚本的原子性特性,是深入使用Redis的必经之路。
[AFFILIATE_SLOT_2]如果对你有帮助,请点赞、关注、收藏,你的支持就是我最大的鼓励!
del lock_keylock_keyupdateselectread timeoutwhile(true)getConnection()jstatjstat -gcutil <pid> 1000Sending datashow processlist;SYNTCP ZeroWindowstraceread()arthastracemonitorBLOCKEDjstack <pid> | grep BLOCKEDload averageRUNNABLEtop -H -p <pid>valueUUID + threadId
private static final String KEY_PREFIX = "lock:";
//锁的key,UUID避免线程安全
private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";
public boolean tryLock(Long timeoutSec) {
//设置value时,我们要知道是哪个线程的值
String threadId = ID_PREFIX+Thread.currentThread().getId();
//获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
value
public void unlock() {
//要设置到锁里的值(自己线程的身份标识)
String threadId = ID_PREFIX+Thread.currentThread().getId();
//从 Redis 锁里读取出来的值(当前锁持有者的身份标识)
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
//判断锁的持有者
if (threadId.equals(id)) {
//释放锁
stringRedisTemplate.delete(KEY_PREFIX+name);
}
GETDEL
bash
# 执行 Lua 脚本
EVAL "脚本内容" numkeys key1 key2 ... arg1 arg2 ...
# 或者先缓存脚本,得到 SHA1,然后用 EVALSHA 执行(更高效)
SCRIPT LOAD "脚本内容"
EVALSHA sha1 0 ...
lua
-- KEYS[1] = 锁的 key
-- ARGV[1] = 当前线程的唯一标识
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
.lua"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"setLocation.luaexecuteunlock()
DefaultRedisScript script = new DefaultRedisScript<>();
script.setScriptText("return 1"); // 把脚本装进去
script.setResultType(Long.class); // 告诉 Spring:脚本返回的是 Long 类型
RedisTemplate.execute()private static final DefaultRedisScript<Long> UN_LOCK_SCRIPT;static { ... }new DefaultRedisScript<>()setLocation(...)setResultType(Long.class)
浙公网安备 33010602011771号