ShaneD711's Blog.

Redis 实战:从零手写分布式锁(误删问题与 Lua 脚本优化)

2025/12/19
loading

Redis 实战:从零手写分布式锁(误删问题与 Lua 脚本优化)

在单体架构中,我们习惯使用 synchronizedLock 来解决并发安全问题。但在分布式集群架构下,不同的服务运行在不同的 JVM 中,本地锁也就失效了。

本文将复现如何基于 Redis 实现一个分布式锁,并一步步解决死锁误删原子性等经典问题。

一、 初级版本:利用 SETNX 实现互斥

Redis 的 SETNX (Set if Not Exists) 命令天生具备互斥性:只有 Key 不存在时才能设置成功。

为了防止获取锁的服务器宕机导致锁永远无法释放(死锁),我们需要在使用 SETNX 的同时设置过期时间(TTL)。

核心命令:

1
SET lock:key threadId NX EX 10

注意:必须保证 SETNX 和 EXPIRE 是原子操作,不能分成两条命令执行。

Java 代码实现

定义一个 SimpleRedisLock 类,实现基础的加锁和解锁逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class SimpleRedisLock implements ILock {

private String name; // 锁的业务名称
private StringRedisTemplate stringRedisTemplate; // Redis操作工具

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

private static final String KEY_PREFIX = "lock:";

@Override
public boolean tryLock(long timeoutSec) {
// 获取线程ID作为标识
long threadId = Thread.currentThread().getId();
// 执行 SET lock:name threadId NX EX timeout
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
// 防止自动拆箱空指针
return Boolean.TRUE.equals(success);
}

@Override
public void unlock() {
// 简单粗暴直接删
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}

二、 进阶版本:解决“误删”问题

初级版本存在一个严重的隐患:如果业务执行时间超过了锁的过期时间,会发生什么?

1. 事故场景还原

假设锁的有效期是 10s,但业务执行了 15s:

  1. 线程 A 获取锁,开始执行业务。
  2. 10s 后,Redis 锁自动过期释放。
  3. 线程 B 尝试获取锁,成功拿到(因为 A 的锁没了)。
  4. 15s 后,线程 A 业务执行完毕,执行 unlock(),直接删除了 Key。
  5. 问题出现:线程 A 删掉的其实是 线程 B 正在持有的锁!
  6. 此时 线程 C 进来,发现没锁,直接加锁。导致 B 和 C 并发执行,互斥失效。

2. 解决方案:给锁加上“身份证”

为了遵循“解铃还须系铃人”的原则,我们需要在解锁时判断:这把锁是不是我的?

  • 改进 Value:单用线程 ID 在集群下可能重复,我们需要拼接一个 JVM 的唯一标识(UUID)。
  • 改进 unlock:删除前先查询 Value,判断是否与自己一致。

3. 代码升级

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import cn.hutool.core.lang.UUID;

public class SimpleRedisLock implements ILock {
// ... 构造方法同上 ...

// 生成 JVM 唯一的 UUID 前缀
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

@Override
public boolean tryLock(long timeoutSec) {
// 拼接 UUID + 线程 ID
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 存入 Redis
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}

@Override
public void unlock() {
// 1. 获取当前线程的标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 2. 获取 Redis 中锁的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 3. 判断是否一致
if (threadId.equals(id)) {
// 4. 一致才删除
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}

三、 终极版本:Lua 脚本保证原子性

上面的 Java 代码解决了“误删”的大部分场景,但在极端并发下依然有漏洞。

1. 原子性漏洞

在 unlock 方法中,“判断锁标识” 和 “删除锁” 是两个动作。

如果线程 A 判断成功(是自己的锁),正准备删除时,系统发生了 GC 停顿(Stop The World)或者网络阻塞。

恰好在这段时间内,锁过期了,线程 B 抢到了锁。

等线程 A 恢复运行,它不会再次判断,而是直接执行 delete,结果还是把 B 的锁给删了。

2. 解决方案:Lua 脚本

Redis 提供了 Lua 脚本功能,可以将多条命令作为一个整体执行,中间不会被其他命令插入,从而保证了原子性。

编写 Lua 脚本 (unlock.lua):

1
2
3
4
5
6
7
8
9
-- KEYS[1] 是锁的 key
-- ARGV[1] 是当前线程的标识
if redis.call('get', KEYS[1]) == ARGV[1] then
-- 标识一致,执行删除
return redis.call('del', KEYS[1])
else
-- 不一致,返回 0
return 0
end

3. 代码最终形态

我们需要预加载 Lua 脚本,并使用 execute 方法调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class SimpleRedisLock implements ILock {

private String name;
private StringRedisTemplate stringRedisTemplate;
// 静态代码块预加载 Lua 脚本
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua")); // 脚本文件位置
UNLOCK_SCRIPT.setResultType(Long.class);
}

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

private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

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

@Override
public void unlock() {
// 调用 Lua 脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name), // KEYS[1]
ID_PREFIX + Thread.currentThread().getId() // ARGV[1]
);
}
}

四、 总结

手写 Redis 分布式锁是一个非常好的学习过程,经历了三个阶段:

  1. 基础版:利用 SETNX 实现互斥,EX 防止死锁。
  2. 改进版:利用 UUID + ThreadID 防止锁超时后误删他人锁。
  3. 终极版:利用 Lua 脚本 解决“查询”与“删除”非原子性的问题。

注意:这只是一个入门级的分布式锁实现。在生产环境中,还要考虑锁续期(看门狗机制)、可重入性主从一致性(Redlock)等问题。建议生产环境直接使用成熟的框架 Redisson

CATALOG
  1. 1. Redis 实战:从零手写分布式锁(误删问题与 Lua 脚本优化)
    1. 1.1. 一、 初级版本:利用 SETNX 实现互斥
      1. 1.1.1. Java 代码实现
    2. 1.2. 二、 进阶版本:解决“误删”问题
      1. 1.2.1. 1. 事故场景还原
      2. 1.2.2. 2. 解决方案:给锁加上“身份证”
      3. 1.2.3. 3. 代码升级
    3. 1.3. 三、 终极版本:Lua 脚本保证原子性
      1. 1.3.1. 1. 原子性漏洞
      2. 1.3.2. 2. 解决方案:Lua 脚本
      3. 1.3.3. 3. 代码最终形态
    4. 1.4. 四、 总结