【黑马点评】- Redis 分布式锁进化史:从 SETNX 到 Lua 脚本
在单体应用时代,我们使用 synchronized 或 ReentrantLock 就能轻松解决线程安全问题。然而,随着架构演进到分布式集群(如 Tomcat 集群部署),JVM 内部的锁机制只能管住当前的 JVM 进程,无法跨 JVM 互斥。这就引出了分布式锁的需求。
本文将复盘《黑马点评》项目中分布式锁的演进之路,探讨如何一步步用 Redis 实现一个生产可用的分布式锁。
一、 分布式锁的基本原理
1.1 什么是分布式锁?
分布式锁是满足分布式系统或集群模式下多进程可见并且互斥的锁。核心思想是让所有服务节点都去抢占同一个“共享资源”,抢到的人才有资格执行业务。
1.2 分布式锁的核心要素
一个成熟的分布式锁应满足以下条件:
- 可见性:多个进程都能感知到锁的状态变化。
- 互斥:最基本条件,同一时刻只能有一个线程持有锁。
- 高可用:锁服务本身不能轻易崩溃。
- 高性能:加锁和释放锁的速度要快。
- 安全性:避免死锁(持有锁的进程挂掉后锁能自动释放)。
1.3 技术选型对比
常见的实现方式有三种:
- MySQL:利用数据库锁机制,但在高并发下性能一般,较少使用。
- Redis:利用
setnx互斥特性。性能强,实现相对简单,是企业级开发的主流选择。 - Zookeeper:可靠性高,但实现原理复杂,不在本文讨论范围。
二、 阶段一:基于 SETNX 的基础实现
2.1 核心思路
利用 Redis 的 SETNX (SET if Not eXists) 命令。
- 获取锁:执行
SETNX key value。如果返回 1,说明拿到锁;返回 0,说明锁已存在,需要等待。 - 释放锁:业务执行完毕后,执行
DEL key。 - 防死锁:在获取锁时必须设置超时时间(TTL),防止服务宕机导致锁无法释放。
2.2 代码实现
Java
// SimpleRedisLock.java
private static final String KEY_PREFIX = "lock:";
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标识
String threadId = Thread.currentThread().getId() + "";
// 获取锁:利用 setIfAbsent (即 SETNX)
// 关键点:操作和设置过期时间必须原子性,防止设置过期时间前宕机
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
// 避免空指针,使用 Boolean.TRUE.equals
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
三、 阶段二:解决“误删锁”问题
3.1 事故场景:锁被别人删了
在极端并发场景下,阶段一的代码存在严重 Bug:
- 线程 1 获取锁成功,TTL 设置为 10秒。
- 线程 1 业务执行阻塞(比如 Full GC),耗时超过 10秒,锁自动过期释放。
- 线程 2 此时尝试获取锁,成功拿到锁。
- 线程 1 业务终于跑完,执行
unlock(),直接删除了对应的 Key。 - 问题出现:线程 1 删掉的其实是 线程 2 的锁!导致线程 2 的业务裸奔,不再互斥。
3.2 解决方案:身份标识
在释放锁时,必须**“先判断,再删除”。 我们需要在加锁时存入当前线程的唯一标识**(如 UUID + 线程ID),删除前先获取 Redis 中的值,判断是否和自己的一致。
3.3 代码改进
加锁逻辑改造:存入 UUID。
Java
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 存入 UUID + ThreadID
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
释放锁逻辑改造:先查后删。
Java
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 脚本)
4.1 潜在隐患:原子性缺失
虽然阶段二解决了大部分误删问题,但仍有极端情况:
- 线程 1 判断
if(threadId.equals(id))成功,确认锁是自己的。 - 就在这一瞬间,发生 Full GC 导致 JVM 暂停,或者网络阻塞。
- 在此期间,Redis 中的锁过期自动释放。
- 线程 2 获取到了新锁。
- 线程 1 恢复运行,因为它已经通过了判断,直接执行
delete。 - 结果:线程 1 还是误删了线程 2 的锁。
究其原因,是 “判断” 和 “删除” 这两个动作在 Java 层面是分开的,不具备原子性。
4.2 终极方案:Lua 脚本
Redis 提供了 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])
end
-- 不一致,则直接返回
return 0
4.3 Java 调用 Lua
修改 unlock 方法,使用 RedisTemplate 调用脚本:
Java
// 初始化脚本
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 void unlock() {
// 执行 Lua 脚本,保证原子性
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name), // KEYS[1]
ID_PREFIX + Thread.currentThread().getId()); // ARGV[1]
}
五、 总结与展望
通过三次迭代,我们实现了一个相对可靠的 Redis 分布式锁:
- 基础:
SET key value NX EX time保证互斥和防死锁。 - 改进:Value 存入
UUID+ThreadID防止误删。 - 完善:利用
Lua 脚本保证“判断+删除”的原子性。
遗留问题: 虽然解决了原子性和误删,但我们还面临一个核心痛点:“锁不住”。 如果业务执行时间超长(例如 30s),超过了锁的 TTL,锁还是会过期释放。我们不能简单地把 TTL 设置得非常大。 这就需要一种**“续期机制”**(类似网吧上网续费),在业务执行期间自动延长锁的时间。这正是下一章 Redisson 框架要解决的核心问题(看门狗机制)。
更多推荐




所有评论(0)