当前位置: 首页 > news >正文

【Redis实战篇 | Day04】Lua原子性优化Redis分布式锁:解决线程安全问题

🔥个人主页:北极的代码(欢迎来访)
🎬作者简介:java后端学习者
❄️个人专栏:苍穹外卖日记,SSM框架深入,JavaWeb
命运的结局尽可永在,不屈的挑战却不可须臾或缺!

前言:

在此之前我们实战实现类基于Redis的分布式锁,但这仅仅是最初级的方案,具体的业务中仅靠这个就不够的,这一章节主要是来分析一下这个初级方案存在的问题和缺陷,以及如何解决这些缺陷,进一步的进行优化。


摘要:

本文分析了基于Redis的分布式锁初级方案存在的线程安全问题,主要由于锁超时自动释放时未进行身份校验

当业务执行时间超过锁过期时间(如GC、慢SQL等原因),会导致其他线程误删当前锁。解决方案包括:1)加锁时设置唯一标识(UUID+线程ID);2)使用Lua脚本实现原子化的锁释放操作,确保只有持有者能删除锁。

文章详细介绍了Lua脚本的实现原理和代码示例,通过DefaultRedisScript封装脚本,实现高效安全的分布式锁管理。


问题分析:

  1. 线程A获取锁成功,开始执行业务。

  2. 业务堵塞(如GC、慢SQL、网络卡顿),导致线程A执行时间超过了锁的过期时间。

  3. 锁自动释放:Redis检测到锁到期,自动删除了线程A持有的锁(此时线程A还在傻傻地执行)。

  4. 线程B获取锁成功(因为线程A的锁已过期),开始执行自己的业务。

  5. 线程A 执行完毕,执行del lock_key释放锁。

  6. 灾难发生:此时lock_key里存的是线程B的锁标识,却被线程A删掉了

  7. 线程C发现锁被删了,又可以获取锁了,于是和线程B同时执行,数据全乱。

这时就出现了线程安全问题了。


核心问题:释放锁时没有校验身份

在“业务堵塞导致锁超时”这个场景里,锁超时是结果,业务堵塞是直接原因。而业务堵塞本身,通常是由以下5 类根本原因导致的:

1. 最典型的:依赖外部资源变慢(Infrastructure)

这是生产环境最常见的原因。业务代码在持有锁的过程中,调用了某个外部服务,该服务响应变慢甚至卡死。

  • 数据库慢查询:持有锁时执行了一个没有索引的update或复杂select,数据库 CPU 飙升,SQL 执行了 30 秒(锁只有 10 秒)。

  • 调用下游 RPC/HTTP 超时设置过长:调用的微服务或第三方 API 挂起,代码卡在read timeout阶段,长时间不返回。

  • Redis/网络本身抖动:虽然拿着 Redis 锁,但业务逻辑里又去请求同一个 Redis做其他操作,此时 Redis 网络故障导致操作卡住。

2. 代码逻辑问题(Logic / Concurrency)
  • 死锁/活锁:业务 A 持有锁 L1,去请求锁 L2;业务 B 持有锁 L2,去请求锁 L1。互相等待,无限期阻塞。

  • 无限循环/递归:业务逻辑里有 bug 导致while(true)无法退出,或者递归没有终止条件。

  • 同步非异步处理:消息队列消费、事件处理中,用了同步阻塞的方式处理大批量数据,导致单次处理时间远超预期。

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)
  • 数据库连接池满:业务逻辑里需要数据库连接,但连接池已满,getConnection()方法阻塞等待。

  • HTTP 连接池满:调用下游服务时,连接池无可用连接,线程阻塞在获取连接上。

总结:如何快速判断

你可以通过以下方式快速定位是哪种“堵塞”:

场景典型现象排查命令
GC 导致锁超时时间规律(每次间隔 JVM GC 时间),jstat显示 FGC 频繁jstat -gcutil <pid> 1000
慢 SQL 导致数据库监控有长时间未结束的查询,MySQL 进程列表有Sending data状态show processlist;
下游 API 卡死网络抓包看到SYN重传或TCP ZeroWindow,或strace卡在read()系统调用arthastrace/monitor
死锁多个相关锁同时超时,线程堆栈出现BLOCKED状态jstack <pid> | grep BLOCKED
CPU/资源饥饿系统load average很高,但业务线程堆栈显示RUNNABLE极少top -H -p <pid>查看各线程 CPU

一句话总结:业务堵塞的本质,不是锁代码写错了,而是加锁范围内的“其他操作”出现了不可预料的延迟(GC、慢SQL、下游挂起等)。锁只是一个放大镜,把原本微小的延迟放大成了系统级故障。


解决方案:释放锁前必须验明正身

删除锁时,不能简单的DEL key必须确保当前线程持有的值和Redis里存储的值一致

正确做法(加唯一标识):

  • 加锁时
  • 设置一个只有当前线程知道的唯一值value例如UUID + threadId)。

之前我们用的是仅仅是线程ID,这个线程id是JVM内部自己维护的递增值,在集群下每个JVM都会维护一个递增线程ID,这样就很容易重复。出现线程id冲突的情况

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); }
  • 解锁时
  • 先用Lua脚本检查value是否匹配,匹配才删除。

初步没有用lua脚本时:

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); }

这里还存在一个小问题:

其实跟我们上面说的阻塞是一个逻辑,主要的是JVM的垃圾回收,毕竟这中间没什么业务,仅仅是释放锁,因为被阻塞,有可能就会超过锁设置的时间,之后别的线程拿到锁,后来阻塞消失,一开始的线程释放锁,但是释放的是别人的锁,因此还是会有线程安全问题

Lua脚本(原子操作):

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 脚本

以分布式锁释放为例,需要:

  1. GET检查锁的值是不是自己的

  2. DEL删除锁

如果不用 Lua,这两步分开执行,在GET 和 DEL 之间,锁可能过期被别的线程抢走,然后你 DEL 就把别人的锁删了。

Lua 脚本保证了:GET 和 DEL 之间,不会有任何其他命令插进来。

核心命令
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. GET 获取 Redis 里锁的值

  2. 和传入的 ARGV[1] 比较

  3. 相等则 DEL 删除,返回 1

  4. 不相等则不删,返回 0

关键:整个 GET + 判断 + DEL 是一个原子操作,中间不会被打断。


具体代码实现:

我们先在我们的resource资源目录先创建一个lua为后缀的文件,在里面写好lua脚本。idea会提醒我们下一个lua相关的插件,照着点击即可。

核心目的

没有独立文件有独立文件
脚本写在 Java 字符串里脚本写在.lua文件里
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"直接写 Lua 语法,无需转义和拼接
难读、难改、难维护清晰、可读、易维护
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时直接复用,不需要重新读文件。

操作做什么何时发生频率
读文件(setLocation).lua文件读取内容到 Java 内存类加载时(静态代码块执行)只一次
发脚本(execute)把脚本内容发送给 Redis 服务器每次调用unlock()方法每次解锁都要发

DefaultRedisScriptSpring Data Redis提供的一个类,用于封装和管理 Lua 脚本

简单理解

可以把它想象成一个脚本容器

java

DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setScriptText("return 1"); // 把脚本装进去 script.setResultType(Long.class); // 告诉 Spring:脚本返回的是 Long 类型

这个容器做的事情:

  1. 装脚本:存放 Lua 脚本的内容

  2. 定类型:声明脚本执行后的返回值类型

  3. 供执行:传给RedisTemplate.execute()方法

为什么需要它

Spring 需要一种方式把 Lua 脚本从 Java 代码传到 Redis

因此我们先声明一个静态常量,用来存放 Lua 脚本的封装对象。(声明了一个叫UN_LOCK_SCRIPT的容器,它将来会装一个返回Long类型的 Lua 脚本。)

这里不能直接初始化,因为有final不能进行赋值操作,然后我们在静态代码块里进行初始化,静态代码块在类加载时自动执行一次,按顺序执行里面的三行代码。

private static final DefaultRedisScript<Long> UN_LOCK_SCRIPT ; //静态代码块进行初始化 static { UN_LOCK_SCRIPT = new DefaultRedisScript<>(); UN_LOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua")); UN_LOCK_SCRIPT.setResultType(Long.class); }
代码干了什么
private static final DefaultRedisScript<Long> UN_LOCK_SCRIPT;声明一个容器变量(先贴标签,容器还是空的)
static { ... }类加载时执行,给容器填内容
new DefaultRedisScript<>()创建真正的容器对象
setLocation(...)从文件读取 Lua 脚本,存入容器
setResultType(Long.class)告诉 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> T execute(RedisScript<T> script, List<K> keys, Object... args)
  • 第二个参数必须是List<K>类型,不能直接传一个String

  • 第三个参数是Object...可变参数,可以直接传多个值

设计者的意图:

  • KEYS 数组:代表 Redis 的键,这些键在 Redis 集群模式下会被哈希到不同的槽位,所以需要明确告诉 Spring “这些都是键”

  • ARGV 数组:代表普通参数,不参与集群哈希计算

结语:如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励!

http://www.cnnetsun.cn/news/2122874.html

相关文章:

  • 保姆级教程:用DriveAct数据集复现自动驾驶行为识别实验(附代码与避坑指南)
  • Windows 11系统优化终极指南:用Win11Debloat告别臃肿与隐私泄露
  • 解码式回归与强化学习在数值预测中的创新应用
  • 解码式回归与强化学习融合的数值预测方法
  • AI 术语通俗词典:对数损失
  • DAIL方法:提升大型语言模型推理能力的新途径
  • 如何通过数字孪生技术实现生产流程的智能优化与高效协同?
  • 反向海淘爆发期,taocarts如何用技术破解代购供应链对接难题
  • 2026年,宸合健康为高净值家庭提供专属肝胆排毒与代谢调理高端健康管理方案
  • 【语音信号传输】基于matlab音频信号加密解密以实现安全数据传输【含Matlab源码 15383期】
  • 终极惠普OMEN游戏本性能优化指南:OmenSuperHub开源工具完全解析
  • 程序员的逆向思维
  • GodotPckTool终极指南:5分钟掌握Godot游戏资源包管理技巧
  • 数值优化算法:从基础理论到工程实践
  • 大语言模型微调中的突发错位现象与防御策略
  • 2026GEO 优化机构价值榜单:前沿技术与实战落地成果多维度综合评估
  • 抖音视频批量下载终极指南:5分钟掌握免费无水印下载技巧
  • 视觉语言动作模型与DiG-Flow几何正则化技术解析
  • html标签如何防止XSS攻击_特殊字符转义必要性【技巧】
  • SAP STO跨公司交易流程
  • 基于OpenLCA、GREET、R语言的生命周期评价方法、模型构建及典型案例
  • 基于Simulink的光伏电池仿真模型搭建——从四参数工程数学模型到S-Function实现与子系统封装
  • 华为校招 C++ 考试题到底怎么考?很多人不是挂在题上,是一开始就准备错了
  • 第86篇:开源vs闭源大模型生态之争——开发者与企业的机会在哪里?(概念入门)
  • 一键部署Phi-3.5-mini-instruct:支持中英双语的代码辅助助手
  • Optuna与Claude Code在Hugging Face上的超参数优化实践
  • 从开机到满格信号:你的手机是如何“认路”和“选家”的?深入浅出解析PLMN选择全流程
  • IT故障速查手册:从诊断到解决
  • 突发!爱荷华州将禁止聘用中国等7国H-1B?多州或将跟风!
  • 部署与可视化系统:零基础到精通:Vue3 + TypeScript + FastAPI 前后端分离打造企业级云端目标检测 SaaS 平台