摘要:本文深入分析分布式锁解锁中的阻塞问题及解决方案,涵盖JVM STW、线程调度、网络延迟等五种阻塞原因,并重点介绍Redis Lua脚本实现原子性解锁的行业标准方案。
目录
- 一、问题背景:为什么解锁必须保证原子性
- 1. 常规解锁的两步式逻辑(判断归属 → 删除锁)
- 2. 阻塞引发的安全风险:锁过期误删与数据不一致
- 二、解锁流程中阻塞的典型成因
- 2.1 JVM STW 垃圾回收(最典型场景)
- 2.2 操作系统线程调度
- 2.3 网络与 Redis 服务端延迟
- 2.4 业务代码阻塞
- 2.5 机器负载过高
- 三、核心解决方案:Lua 脚本实现原子解锁(行业标准方案)
- 3.1 Redis Lua 脚本基础规则
- 3.2 解锁 Lua 脚本实现
- 3.3 Spring Data Redis 脚本封装
- 3.4 封装代码逐行解析
- 四、其他备选方案(不推荐)
- 4.1 Redis 事务(MULTI/EXEC)
- 4.2 Redis 乐观锁(WATCH)
解除分布式锁时,虽然判断是同一个线程才解除锁,但是可能会出现判断后的阻塞(原因:JVM STW 垃圾回收等)所以需要使两者具备原子性。
一、问题背景:为什么解锁必须保证原子性
1. 常规解锁的两步式逻辑(判断归属 → 删除锁)
在分布式锁的解锁场景中,即使代码逻辑正确(如判断当前线程为锁持有者),也可能在判断通过后、执行删除操作前发生阻塞。这种阻塞虽然短暂,但只要超过锁的剩余存活时间(TTL),就会导致锁提前失效,其他线程趁机获取锁,最终引发数据不一致。
2. 阻塞引发的安全风险:锁过期误删与数据不一致
阻塞不需要持续很久,只要超过锁剩余存活时间,就会触发锁失效。因此,解锁操作(判断 + 删除)必须具备原子性,确保中间不会被任何因素打断。
二、解锁流程中阻塞的典型成因
2.1 JVM STW 垃圾回收(最典型场景)
Java 虚拟机(JVM)在进行垃圾回收(GC)时,无论是 Young GC 还是 Full GC,都可能触发Stop-The-World(STW)事件。此时,所有应用线程都会被暂停,代码执行卡在当前位置,直到 GC 完成。
- STW 触发机制与影响范围:STW 恰好发生在
if判断通过之后、执行delete命令之前。 - 具体问题场景与引发后果:线程被挂起数十毫秒甚至数秒,锁的 TTL 在此期间耗尽。当线程恢复并尝试删除锁时,锁已自动过期,可能已被其他线程获取。
GC 发生在【if 判断通过之后,delete 之前】,等待 GC 结束,锁早就过期了。
2.2 操作系统线程调度
现代操作系统采用时间片轮转等调度算法,CPU 会在多个线程/进程间快速切换。当前线程的时间片用完后,会被暂时挂起,让出 CPU 给其他就绪线程。
- 时间片轮转调度原理:持有锁的线程在通过判断后,时间片耗尽,被操作系统调度器挂起。
- 调度挂起引发的锁过期风险:虽然挂起时间通常很短(毫秒级),但如果锁的剩余 TTL 也很短,这次调度延迟就可能导致锁在删除前失效。
2.3 网络与 Redis 服务端延迟
分布式锁的解锁操作通常涉及网络通信。在判断线程标识后,需要向 Redis 发送删除命令。
- 延迟的常见来源:网络波动、Redis 服务器瞬时负载高或正在进行持久化(如 AOF rewrite),可能导致删除命令的网络传输或服务端处理延迟。
- 命令延迟到达的后果:命令延迟到达,锁在命令执行前已过期。
2.4 业务代码阻塞
在判断通过后、删除锁之前,如果执行了某些阻塞操作,也会引入延迟。
- 常见的阻塞操作类型:同步等待(如锁竞争)、慢速 I/O 操作(读写文件、数据库查询)、Thread.sleep() 调用、复杂计算等。
- 对解锁流程的影响:阻塞操作耗时超过锁的剩余 TTL。
2.5 机器负载过高
当宿主机器的 CPU 使用率持续过高,或系统负载(Load Average)很大时,操作系统调度器可能无法及时让目标线程获得执行时间。
- CPU 资源竞争的影响:机器上运行着多个高 CPU 应用,资源竞争激烈。
- 线程调度滞后引发的问题:持有锁的线程长时间处于就绪状态但得不到 CPU 时间片,无法继续执行删除操作,锁在此期间过期。
⚠️ 关键:阻塞不需要持续很久,只要超过锁剩余存活时间,就会触发锁失效!因此,解锁操作(判断 + 删除)必须具备原子性,确保中间不会被任何因素打断。
三、核心解决方案:Lua 脚本实现原子解锁(行业标准方案)
3.1 Redis Lua 脚本基础规则
Redis 通过EVAL命令执行 Lua 脚本,确保脚本内的所有 Redis 命令以原子方式执行,不会被其他命令打断。
EVAL "lua脚本内容" 键数量 key1 key2 key3 arg1 arg2 arg3执行规则:
- 第 1 个参数:Lua 脚本字符串
- 第 2 个参数:数字 N= 键的个数
- 后面紧跟 N 个 →KEYS 数组
- 剩下所有参数 →ARGV 数组
KEYS[1]、KEYS[2]:存放传入的redis 键名ARGV[1]、ARGV[2]:存放普通参数值
⚠️ Lua 数组下标从 1 开始,不是 0!
3.2 解锁 Lua 脚本实现
标准解锁 Lua 脚本示例(unlock.lua):
-- KEYS[1]:锁的key -- ARGV[1]:当前线程的锁标识,用于校验归属 if redis.call('get', KEYS[1]) == ARGV[1] then -- 校验通过,删除锁 return redis.call('del', KEYS[1]) end -- 校验不通过,返回0表示解锁失败 return 0返回值语义约定:返回 1 表示解锁成功,返回 0 表示解锁失败(锁不属于当前线程或锁已不存在)。
3.3 Spring Data Redis 脚本封装
在 Java 项目中,通常使用 Spring Data Redis 的DefaultRedisScript封装脚本,实现一次初始化、全局复用。
完整封装代码:
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT; static { UNLOCK_SCRIPT = new DefaultRedisScript<>(); UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua")); UNLOCK_SCRIPT.setResultType(Long.class); }一次初始化全局复用的设计优势:避免每次解锁都重新加载脚本,提升性能;静态代码块在类加载时执行,线程安全。
3.4 封装代码逐行解析
常量声明部分详解
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;声明一个静态常量,类型为DefaultRedisScript<Long>,用于存储 Lua 脚本对象,返回类型为Long。
静态代码块三步初始化逻辑详解
static { UNLOCK_SCRIPT = new DefaultRedisScript<>(); // 第一步:创建脚本对象 UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua")); // 第二步:指定脚本文件路径 UNLOCK_SCRIPT.setResultType(Long.class); // 第三步:设置返回类型 }第一步:创建DefaultRedisScript泛型实例。
第二步:通过ClassPathResource指定 Lua 脚本文件在 classpath 下的位置。
第三步:设置脚本执行结果的返回类型为Long.class,与 Lua 脚本中的return 1/return 0对应。
3.5 调用脚本
public void unLock(){ // 调用 Lua 脚本执行解锁 stringRedisTemplate.execute( UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + name), ID_PREFIX + Thread.currentThread().getId() ); }1. 执行入口:stringRedisT
四、其他备选方案(不推荐)
除 Lua 脚本外,还有两种理论上的方案,但实际项目中很少使用:
4.1 Redis 事务(MULTI/EXEC)
方案原理:Redis 事务通过MULTI开启事务,将多个命令打包后通过EXEC一次性执行,保证这些命令的原子性。
不适用解锁场景的原因:事务无法实现「先判断再执行」的条件逻辑。事务中的所有命令在EXEC之前只是入队,不会执行,因此无法在事务中先查询锁的归属再决定是否删除,不满足解锁场景的需求。
4.2 Redis 乐观锁(WATCH)
方案原理:通过WATCH命令监控锁 key,在EXEC执行事务前检查 key 是否被修改,若被修改则事务失败,需要重试。
实际劣势与不推荐理由:需要额外监控 key,实现复杂,且性能弱于 Lua 脚本。高并发场景下重试成本高,且无法保证在 WATCH 和事务执行之间不发生阻塞。