Redis 实战:从零手写分布式锁(误删问题与 Lua 脚本优化)
在单体架构中,我们习惯使用 synchronized 或 Lock 来解决并发安全问题。但在分布式集群架构下,不同的服务运行在不同的 JVM 中,本地锁也就失效了。
本文将复现如何基于 Redis 实现一个分布式锁,并一步步解决死锁 、误删 、原子性等经典问题。
一、 初级版本:利用 SETNX 实现互斥
Redis 的 SETNX (Set if Not Exists) 命令天生具备互斥性:只有 Key 不存在时才能设置成功。
为了防止获取锁的服务器宕机导致锁永远无法释放(死锁),我们需要在使用 SETNX 的同时设置过期时间(TTL)。
核心命令:
vbnet
SET lock:key threadId NX EX 10
注意:必须保证 SETNX 和 EXPIRE 是原子操作,不能分成两条命令执行。
Java 代码实现
定义一个 SimpleRedisLock 类,实现基础的加锁和解锁逻辑。
java
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:
- 线程 A 获取锁,开始执行业务。
- 10s 后,Redis 锁自动过期释放。
- 线程 B 尝试获取锁,成功拿到(因为 A 的锁没了)。
- 15s 后 ,线程 A 业务执行完毕,执行
unlock(),直接删除了 Key。 - 问题出现 :线程 A 删掉的其实是 线程 B 正在持有的锁!
- 此时 线程 C 进来,发现没锁,直接加锁。导致 B 和 C 并发执行,互斥失效。
2. 解决方案:给锁加上"身份证"
为了遵循"解铃还须系铃人"的原则,我们需要在解锁时判断:这把锁是不是我的?
- 改进 Value:单用线程 ID 在集群下可能重复,我们需要拼接一个 JVM 的唯一标识(UUID)。
- 改进 unlock:删除前先查询 Value,判断是否与自己一致。
3. 代码升级
java
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):
lua
-- 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 方法调用。
java
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 分布式锁是一个非常好的学习过程,经历了三个阶段:
- 基础版 :利用
SETNX实现互斥,EX防止死锁。 - 改进版 :利用
UUID + ThreadID防止锁超时后误删他人锁。 - 终极版 :利用
Lua 脚本解决"查询"与"删除"非原子性的问题。
注意 :这只是一个入门级的分布式锁实现。在生产环境中,还要考虑锁续期 (看门狗机制)、可重入性 、主从一致性 (Redlock)等问题。建议生产环境直接使用成熟的框架 Redisson。