资讯动态

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

发布时间:2026/10/13 18:21:34 来源:尧图企业网站定制
个人主页北极的代码欢迎来访作者简介java后端学习者❄️个人专栏苍穹外卖日记SSM框架深入JavaWeb✨命运的结局尽可永在不屈的挑战却不可须臾或缺前言在此之前我们实战实现类基于Redis的分布式锁但这仅仅是最初级的方案具体的业务中仅靠这个就不够的这一章节主要是来分析一下这个初级方案存在的问题和缺陷以及如何解决这些缺陷进一步的进行优化。摘要本文分析了基于Redis的分布式锁初级方案存在的线程安全问题主要由于锁超时自动释放时未进行身份校验。当业务执行时间超过锁过期时间如GC、慢SQL等原因会导致其他线程误删当前锁。解决方案包括1加锁时设置唯一标识UUID线程ID2使用Lua脚本实现原子化的锁释放操作确保只有持有者能删除锁。文章详细介绍了Lua脚本的实现原理和代码示例通过DefaultRedisScript封装脚本实现高效安全的分布式锁管理。问题分析线程A获取锁成功开始执行业务。业务堵塞如GC、慢SQL、网络卡顿导致线程A执行时间超过了锁的过期时间。锁自动释放Redis检测到锁到期自动删除了线程A持有的锁此时线程A还在傻傻地执行。线程B获取锁成功因为线程A的锁已过期开始执行自己的业务。线程A 执行完毕执行del lock_key释放锁。灾难发生此时lock_key里存的是线程B的锁标识却被线程A删掉了。线程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 ContentionFull 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()系统调用arthas的trace/monitor死锁多个相关锁同时超时线程堆栈出现BLOCKED状态jstack pid | grep BLOCKEDCPU/资源饥饿系统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_PREFIXThread.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_PREFIXThread.currentThread().getId(); //从 Redis 锁里读取出来的值当前锁持有者的身份标识 String id stringRedisTemplate.opsForValue().get(KEY_PREFIX name); //判断锁的持有者 if (threadId.equals(id)) { //释放锁 stringRedisTemplate.delete(KEY_PREFIXname); }这里还存在一个小问题其实跟我们上面说的阻塞是一个逻辑主要的是JVM的垃圾回收毕竟这中间没什么业务仅仅是释放锁因为被阻塞有可能就会超过锁设置的时间之后别的线程拿到锁后来阻塞消失一开始的线程释放锁但是释放的是别人的锁因此还是会有线程安全问题。Lua脚本原子操作lua if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endLua 脚本是 Redis 中实现原子操作的关键技术。它可以把多个 Redis 命令打包成一个脚本Redis 一次性执行整个脚本期间不会被其他命令打断。为什么需要 Lua 脚本以分布式锁释放为例需要GET检查锁的值是不是自己的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执行逻辑GET 获取 Redis 里锁的值和传入的 ARGV[1] 比较相等则 DEL 删除返回 1不相等则不删返回 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()方法每次解锁都要发DefaultRedisScript是Spring Data Redis提供的一个类用于封装和管理 Lua 脚本。简单理解可以把它想象成一个脚本容器javaDefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptText(return 1); // 把脚本装进去 script.setResultType(Long.class); // 告诉 Spring脚本返回的是 Long 类型这个容器做的事情装脚本存放 Lua 脚本的内容定类型声明脚本执行后的返回值类型供执行传给RedisTemplate.execute()方法为什么需要它Spring 需要一种方式把 Lua 脚本从 Java 代码传到 Redis因此我们先声明一个静态常量用来存放 Lua 脚本的封装对象。声明了一个叫UN_LOCK_SCRIPT的容器它将来会装一个返回Long类型的 Lua 脚本。这里不能直接初始化因为有final不能进行赋值操作然后我们在静态代码块里进行初始化静态代码块在类加载时自动执行一次按顺序执行里面的三行代码。private static final DefaultRedisScriptLong 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 DefaultRedisScriptLong 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_PREFIXname), ID_PREFIXThread.currentThread().getId()); }stringRedisTemplate.execute()的方法签名java public T T execute(RedisScriptT script, ListK keys, Object... args)第二个参数必须是ListK类型,不能直接传一个String第三个参数是Object...可变参数,可以直接传多个值设计者的意图KEYS 数组代表 Redis 的键,这些键在 Redis 集群模式下会被哈希到不同的槽位,所以需要明确告诉 Spring “这些都是键”ARGV 数组代表普通参数,不参与集群哈希计算结语如果对你有帮助请点赞关注收藏你的支持就是我最大的鼓励

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑