【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>
CPU/资源饥饿 系统 load average 很高,但业务线程堆栈显示 RUNNABLE 极少 top -H -p <pid> 查看各线程 CPU

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


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

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

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

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

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

java 复制代码
  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脚本时:

java 复制代码
 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 复制代码
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 复制代码
bash

# 执行 Lua 脚本
EVAL "脚本内容" numkeys key1 key2 ... arg1 arg2 ...

# 或者先缓存脚本,得到 SHA1,然后用 EVALSHA 执行(更高效)
SCRIPT LOAD "脚本内容"
EVALSHA sha1 0 ...
分布式锁释放的经典脚本
Lua 复制代码
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不能进行赋值操作,然后我们在静态代码块里进行初始化,静态代码块在类加载时自动执行一次,按顺序执行里面的三行代码。

java 复制代码
  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:

java 复制代码
   /**
     * 释放锁(用lua实现原子性)
     */
    public void unlock() {
    stringRedisTemplate.execute(UN_LOCK_SCRIPT, 
            Collections.singletonList(KEY_PREFIX+name),
            ID_PREFIX+Thread.currentThread().getId());
        }

stringRedisTemplate.execute() 的方法签名:

java 复制代码
java

public <T> T execute(RedisScript<T> script, List<K> keys, Object... args)
  • 第二个参数必须是 List<K> 类型,不能直接传一个 String

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

设计者的意图:

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

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

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

相关推荐
恋奴娇2 小时前
ubuntu 25 Nautilus 文件管理器不能以ROOT运行突破
java·数据库·ubuntu
写了20年代码的老程序员2 小时前
微信支付回调里,为什么一行 data.order.amount 胜过五层判空
java
无所事事O_o2 小时前
内存化系统设计
java·架构
C语言小火车2 小时前
2026年C++后端开发面试题
java·开发语言·面试
希望永不加班2 小时前
SpringBoot 整合 RabbitMQ 入门
java·spring boot·后端·rabbitmq·java-rabbitmq
froginwe112 小时前
TCP/IP 协议:网络通信的基石
开发语言
小龙报2 小时前
【数据结构与算法】一文拿捏链式二叉树:遍历 + 统计 + 层序 + 完全树
java·c语言·开发语言·c++·人工智能·语言模型·visual studio
TE-茶叶蛋2 小时前
Spring 高级机制:循环依赖 + AOP + @Transactional 失效原理
java·后端·spring
juniperhan2 小时前
Flink 系列第18篇:Flink 动态表、连续查询与 Changelog 机制
java·大数据·数据仓库·分布式·flink