Redis 实战:从零手写分布式锁(误删问题与 Lua 脚本优化)
在单体架构中,我们习惯使用 synchronized 或 Lock 来解决并发安全问题。但在分布式集群架构下,不同的服务运行在不同的 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;
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) { long threadId = Thread.currentThread().getId(); 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. 代码升级
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 {
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() { String threadId = ID_PREFIX + Thread.currentThread().getId(); String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name); if (threadId.equals(id)) { 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
|
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else 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; 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() { stringRedisTemplate.execute( UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + name), ID_PREFIX + Thread.currentThread().getId() ); } }
|
四、 总结
手写 Redis 分布式锁是一个非常好的学习过程,经历了三个阶段:
- 基础版:利用
SETNX 实现互斥,EX 防止死锁。
- 改进版:利用
UUID + ThreadID 防止锁超时后误删他人锁。
- 终极版:利用
Lua 脚本 解决“查询”与“删除”非原子性的问题。
注意:这只是一个入门级的分布式锁实现。在生产环境中,还要考虑锁续期(看门狗机制)、可重入性、主从一致性(Redlock)等问题。建议生产环境直接使用成熟的框架 Redisson。