分布式锁解锁原子性问题
摘要本文深入分析分布式锁解锁中的阻塞问题及解决方案涵盖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/EXEC4.2 Redis 乐观锁WATCH解除分布式锁时虽然判断是同一个线程才解除锁但是可能会出现判断后的阻塞原因JVM STW 垃圾回收等所以需要使两者具备原子性。一、问题背景为什么解锁必须保证原子性1. 常规解锁的两步式逻辑判断归属 → 删除锁在分布式锁的解锁场景中即使代码逻辑正确如判断当前线程为锁持有者也可能在判断通过后、执行删除操作前发生阻塞。这种阻塞虽然短暂但只要超过锁的剩余存活时间TTL就会导致锁提前失效其他线程趁机获取锁最终引发数据不一致。2. 阻塞引发的安全风险锁过期误删与数据不一致阻塞不需要持续很久只要超过锁剩余存活时间就会触发锁失效。因此解锁操作判断 删除必须具备原子性确保中间不会被任何因素打断。二、解锁流程中阻塞的典型成因2.1 JVM STW 垃圾回收最典型场景Java 虚拟机JVM在进行垃圾回收GC时无论是 Young GC 还是 Full GC都可能触发Stop-The-WorldSTW事件。此时所有应用线程都会被暂停代码执行卡在当前位置直到 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 开始不是 03.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 DefaultRedisScriptLong UNLOCK_SCRIPT; static { UNLOCK_SCRIPT new DefaultRedisScript(); UNLOCK_SCRIPT.setLocation(new ClassPathResource(unlock.lua)); UNLOCK_SCRIPT.setResultType(Long.class); }一次初始化全局复用的设计优势避免每次解锁都重新加载脚本提升性能静态代码块在类加载时执行线程安全。3.4 封装代码逐行解析常量声明部分详解private static final DefaultRedisScriptLong UNLOCK_SCRIPT;声明一个静态常量类型为DefaultRedisScriptLong用于存储 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 和事务执行之间不发生阻塞。