分布式锁解锁原子性问题

摘要:本文深入分析分布式锁解锁中的阻塞问题及解决方案,涵盖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. 第 1 个参数:Lua 脚本字符串
  2. 第 2 个参数:数字 N= 键的个数
  3. 后面紧跟 N 个 →KEYS 数组
  4. 剩下所有参数 →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 和事务执行之间不发生阻塞。