day38 Redis分布式锁实现、误删问题与Lua脚本原子性

day38 Redis分布式锁实现、误删问题与Lua脚本原子性

一、分布式锁概述

1.1 为什么需要分布式锁

之前学习秒杀超卖和一人一单问题时,使用 synchronized 或 Lock 解决了单机环境下的并发安全。但 synchronized 依赖 JVM 内部的锁监视器,只在单个 JVM 进程内有效。集群部署时每个 Tomcat 拥有独立的 JVM,相同代码中的锁对象在不同 JVM 中不是同一个,无法实现跨进程互斥。

分布式锁的核心思想:让所有进程使用同一把锁,多进程可见且互斥,从而保证集群环境下的串行执行。

1.2 分布式锁的核心条件

条件 说明
多进程可见 不同 JVM 中的线程都能看到同一把锁的状态
互斥 基本条件,保证同一时刻只有一个进程获取锁
高可用 进程不易崩溃,时刻保证锁服务可用
高性能 加锁和释放锁本身不能成为性能瓶颈
安全性 避免死锁等安全问题

1.3 三种实现方式对比

实现方式 互斥原理 性能 可靠性
MySQL 利用数据库自带的锁机制 一般 较好
Redis 利用 setnx,插入 key 成功即获取锁 高 高
Zookeeper 利用节点唯一性和临时节点 较高 高

本次使用 Redis 实现,互斥原理是 setnx(即 SET key value NX EX),与之前缓存击穿时使用的互斥锁方案类似,都是利用 Redis 的 setnx 实现互斥。Redis 高性能、高可用,配合过期时间可以避免死锁。

二、Redis分布式锁基本实现

2.1 实现思路

获取锁使用 setIfAbsent(对应 Redis 的 SET NX EX),一次调用同时完成设置 key-value 和过期时间,保证加锁和设置过期时间的原子性。释放锁使用 DEL 删除 key。

项目中定义了锁接口 ILock,由 SimpleRedisLock 实现具体逻辑。

2.2 锁接口

1
2
3
4
public interface ILock {
boolean tryLock(Long timeoutSeconds);
void unlock();
}

tryLock 为非阻塞式获取锁:尝试一次,成功返回 true,失败返回 false,不等待重试。

2.3 加锁实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class SimpleRedisLock implements ILock {

private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}

@Override
public boolean tryLock(Long timeoutSeconds) {
String threadId = ID_PREFIX + Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSeconds, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
}

关键点:

  • setIfAbsent 对应 SET key value NX EX seconds,key 不存在才设置,同时指定过期时间,保证加锁和设过期时间原子性
  • key 格式为 lock:业务名,如 lock:order:1
  • value 是线程标识,用于后续判断锁归属(见第三节)
  • 过期时间防止死锁,但如果业务执行时间超过过期时间,锁会提前释放

2.4 业务接入

VoucherOrderServiceImpl 中使用分布式锁解决一人一单问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
public Result seckillVoucher(Long voucherId) {
// 查询秒杀券、判断时间、判断库存 ...
Long userId = UserHolder.getUser().getId();

// 创建锁对象
SimpleRedisLock simpleRedisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);

// 获取锁
boolean success = simpleRedisLock.tryLock(1200L);
if (!success) {
return Result.fail("不允许重复下单!");
}

try {
IVoucherOrderService iVoucherOrderService = (IVoucherOrderService) AopContext.currentProxy();
return iVoucherOrderService.createVoucherOrder(voucherId);
} catch (IllegalStateException e) {
throw new RuntimeException(e);
} finally {
simpleRedisLock.unlock();
}
}

注意:锁的粒度是 order:userId,按用户维度加锁,不同用户可以并行下单。使用 AopContext.currentProxy() 获取代理对象调用事务方法,确保事务在锁内部生效。

三、误删问题与线程标识

3.1 问题描述

基本实现中释放锁只是简单 DEL,不判断锁的归属,可能导致误删别人锁:

1
2
线程1 获取锁 → 阻塞 → 锁超时释放
线程2 获取锁成功 → 线程1 恢复 → 线程1 执行 DEL 删除了线程2 的锁

以此类推,线程2 也可能删除其他线程的锁,互斥性被破坏。

3.2 解决方案

在锁的 value 中存入线程标识,释放锁前先比较:只有锁的 value 与当前线程标识一致时才删除。

不同 JVM 下线程 id 可能重复,所以使用 UUID + 线程 id 组合作为线程标识:

  • UUID 使用 static final 静态属性,每个 JVM 唯一,不同 JVM 下 UUID 不同
  • 同一 JVM 内线程 id 不同
1
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

UUID.randomUUID().toString(true) 中的 true 表示去掉横杠,生成紧凑格式的 UUID。

3.3 存在原子性隐患的释放逻辑

最初改进后的释放锁逻辑如下(已被项目注释掉):

1
2
3
4
5
6
7
public void unlock() {
String threadId = ID_PREFIX + Thread.currentThread().getId();
String value = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if (threadId.equals(value)) {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}

这段代码解决了误删问题,但引入了新的隐患——原子性问题。

四、原子性问题与Lua脚本

4.1 原子性问题

上述释放锁逻辑中,获取锁的线程标识、比较、删除锁是分三次 Redis 操作完成的:

1
2
线程1 GET 锁 → 判断一致 → 阻塞 → 锁超时过期
线程2 获取锁 → 线程1 恢复 → 线程1 用旧值 DEL 删除了线程2 的锁

线程1 在判断通过后、执行删除前发生了阻塞,锁在此期间超时过期,线程2 拿到锁。线程1 恢复后直接删除,删的是线程2 的锁。原因是判断和删除不是原子操作。

4.2 Lua脚本操作Redis

Redis 提供 Lua 脚本功能,在一个脚本中编写多条 Redis 命令,确保多条命令执行时的原子性。

基本调用语法:

1
redis.call('命令名称', 'key', '其它参数', ...)

通过 EVAL 命令执行脚本:

1
EVAL script numkeys key [key ...] arg [arg ...]
  • numkeys:key 参数的个数
  • 前 numkeys 个参数放入 KEYS 数组,脚本中用 KEYS[1]、KEYS[2] 访问
  • 其余参数放入 ARGV 数组,脚本中用 ARGV[1]、ARGV[2] 访问
  • 下标从 1 开始

4.3 项目中的unlock.lua

将释放锁的判断+删除逻辑封装为一个 Lua 脚本,一次提交给 Redis 执行,保证原子性。脚本位于 src/main/resources/unlock.lua:

1
2
3
4
if ARGV[1]==redis.call("get",KEYS[1]) then
return redis.call("del",KEYS[1])
end
return 0

逻辑:获取锁的 value,判断是否与当前线程标识(ARGV[1])一致,一致则删除锁(返回删除结果),不一致则返回 0。

4.4 Java调用Lua脚本

SimpleRedisLock 使用 Spring 提供的 DefaultRedisScript 加载并执行 Lua 脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}

@Override
public void unlock() {
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId()
);
}

关键点:

  • DefaultRedisScript 在静态代码块中初始化,只加载一次,避免重复 IO
  • setLocation 从 classpath 加载 unlock.lua 文件
  • setResultType(Long.class) 指定返回值类型
  • execute 的参数:第一个是脚本对象,第二个是 KEYS 列表(Collections.singletonList 传入一个 key),第三个是 ARGV 参数(线程标识)
  • 整个释放锁过程只需一次 Redis 调用,判断和删除原子完成

五、演进总结

整个 Redis 分布式锁的改进过程是逐步发现和解决问题的过程:

版本 问题 解决方式
基本实现 无判断直接 DEL,可能误删 -
改进一 误删别人锁 存入 UUID+线程ID,释放前比较标识
改进二 判断和删除非原子 Lua 脚本封装判断+删除,一次执行

注意:当前实现仍有不足:锁不可重入、获取失败不能重试、过期时间固定无法续期、主从同步延迟可能丢锁。这些问题需要 Redisson 解决,本次未涉及。

今日总结

模块 核心要点
分布式锁概念 集群环境下多进程可见且互斥的锁,synchronized 只在单 JVM 有效
Redis 实现原理 SET NX EX 保证加锁互斥 + 防死锁,非阻塞返回
线程标识 UUID + 线程ID 组合,跨 JVM 唯一标识锁归属
误删问题 阻塞导致锁超时释放,释放时误删别人的锁
原子性问题 判断标识和删除锁分多步,中间阻塞会导致误删
Lua 脚本 将多条 Redis 命令封装为一次原子执行,EVAL 调用
项目实现 SimpleRedisLock 实现接口,unlock.lua 保证释放原子性