Redis 分布式锁:一行命令抢锁,但你真的会释放吗?(附可运行源码)
作者:鱼宵 | 实战驱动系列 · 第 1 篇
完整可运行工程(pom.xml + 源码 + 运行说明)已开源在 Gitee:gitee.com/j67mk2/redi…
一、一个真实场景:3 台机器抢同一个库存
你的订单服务上了 3 台机器。用户下单,扣库存:
// 这段代码在 3 台机器上同时跑
if (stock > 0) {
stock = stock - 1;
// 写回数据库……
}
单机时代 synchronized 就够了,但它是 JVM 级的锁——3 台 Tomcat 就是 3 个 JVM,互相管不着。库存剩 1 件,可能卖出 3 单。
类比:多人共用一个公共卫生间,门后挂"有人/没人"的牌子。Redis 就是那块公共牌子。
锁要放到所有机器都能看到、都认账的地方 → Redis 分布式锁。
二、加锁:为什么必须是 SET key value NX EX 一条命令?
先看错误写法:
SETNX lockKey 1 # 第一步:抢占
EXPIRE lockKey 10 # 第二步:设过期
问题:两步之间进程一旦崩了(刚抢到锁、还没设过期就被 kill),这把锁永远没有过期时间 = 死锁。
正确写法——"不存在才设置"和"设过期"合成一条原子命令:
SET lock:product:1001 唯一标识 NX EX 10
| 参数 | 作用 | 大白话 |
|---|---|---|
NX | Not eXists,key 不存在才设置成功 | 牌子是"有人"就抢不到 |
EX 10 | 10 秒自动过期 | 就算持锁者崩了,锁 10 秒后自动释放,不死锁 |
| 合一条 | 抢占+过期无中间态 | 翻牌子 + 装定时器是同一个动作 |
Java 里核心就三行:
SetParams params = new SetParams().nx().ex(expireSeconds);
String result = jedis.set(lockKey, requestId, params);
return "OK".equals(result) ? requestId : null; // 抢到返回唯一标识,没抢到返回 null
三、value 为什么要存"唯一标识"?
防误删。场景:线程 A 拿锁,业务跑得太慢,10 秒到了锁自动过期;线程 B 抢到锁;A 终于跑完去解锁——如果它无脑 DEL,删掉的是 B 的锁!
所以解锁前必须确认:锁的 value 还是不是我当初拿到的 UUID?是我的我才删。
String requestId = UUID.randomUUID().toString(); // 每个持锁者一个专属编号
// 解锁时:get 出来的 value 等于我的 requestId 才允许 del
四、解锁:为什么必须用 Lua 脚本?
错误写法(先判断、再删除,两条命令):
if get(lockKey) == 我的UUID: # 第一步:判断
del(lockKey) # 第二步:删除
窗口期:判断完"是我的",正要 del 的瞬间,锁刚好到期自动释放、B 抢到了锁 → 你又把 B 的锁删了。
Lua 把"判断 + 删除"焊死成一个原子操作,Redis 执行整段脚本期间不插队任何命令:
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1]) -- 是我的才删
else
return 0 -- 不是我的,绝不动手
end
至此,三板斧齐了,完整工具类:
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
import java.util.UUID;
/**
* Redis 分布式锁(完整版,可直接运行)
* 三板斧:SET NX EX 原子加锁 + value 存唯一标识 + Lua 原子解锁
*/
public class DistributedLock {
private final Jedis jedis;
public DistributedLock(Jedis jedis) {
this.jedis = jedis;
}
/**
* 加锁。成功返回唯一标识 requestId(解锁时必须带上);失败返回 null
*/
public String lock(String lockKey, int expireSeconds) {
String requestId = UUID.randomUUID().toString();
// nx() = 不存在才设置;ex(秒) = 过期时间。合成一条原子命令
SetParams params = new SetParams().nx().ex(expireSeconds);
String result = jedis.set(lockKey, requestId, params);
return "OK".equals(result) ? requestId : null;
}
/** 解锁脚本:判断 + 删除,一个原子操作 */
private static final String UNLOCK_LUA =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
/**
* 释放锁。
* @return true=成功删除了自己的锁;false=锁不是自己的(或已过期),没动它
*/
public boolean unlock(String lockKey, String requestId) {
Object result = jedis.eval(UNLOCK_LUA, 1, lockKey, requestId);
return result != null && Long.parseLong(result.toString()) >= 1;
}
}
五、完整演示:两个线程抢同一把锁
import redis.clients.jedis.Jedis;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
public class Main {
private static final String HOST = "127.0.0.1";
private static final int PORT = 6383; // 本机 Docker 起的 redis:7 容器
private static final String LOCK_KEY = "lock:product:1001";
private static final int EXPIRE_SECONDS = 10;
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
public static void main(String[] args) throws InterruptedException {
try (Jedis check = new Jedis(HOST, PORT)) {
System.out.println("[" + now() + "] 连接 Redis 成功,ping = " + check.ping());
check.del(LOCK_KEY); // 清残留锁,保证结果干净
}
// ===== 线程 A:抢到锁,做 3 秒业务,再释放 =====
Thread threadA = new Thread(() -> {
Jedis jedis = new Jedis(HOST, PORT);
DistributedLock lock = new DistributedLock(jedis);
String requestId = lock.lock(LOCK_KEY, EXPIRE_SECONDS);
System.out.println("[" + now() + "] 线程A:抢到锁成功!requestId = " + requestId);
try {
System.out.println("[" + now() + "] 线程A:拿着锁做业务中(模拟 3 秒)...");
Thread.sleep(3000);
System.out.println("[" + now() + "] 线程A:业务做完了");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
boolean released = lock.unlock(LOCK_KEY, requestId);
System.out.println("[" + now() + "] 线程A:释放锁,结果 = " + released);
jedis.close();
}
}, "线程A");
// ===== 线程 B:反复重试,直到 A 释放 =====
Thread threadB = new Thread(() -> {
sleepQuietly(200); // 稍晚启动,保证 A 先拿到锁
Jedis jedis = new Jedis(HOST, PORT);
DistributedLock lock = new DistributedLock(jedis);
String requestId = null;
int tryCount = 0;
while (tryCount < 20) {
tryCount++;
requestId = lock.lock(LOCK_KEY, EXPIRE_SECONDS);
if (requestId != null) {
System.out.println("[" + now() + "] 线程B:第 " + tryCount + " 次尝试 → 抢到锁了!requestId = " + requestId);
break;
} else {
System.out.println("[" + now() + "] 线程B:第 " + tryCount + " 次尝试 → 抢锁失败,0.5s 后重试");
sleepQuietly(500);
}
}
if (requestId == null) {
System.out.println("[" + now() + "] 线程B:试了 20 次都没抢到,放弃");
return;
}
sleepQuietly(500);
boolean released = lock.unlock(LOCK_KEY, requestId);
System.out.println("[" + now() + "] 线程B:用完锁,释放锁,结果 = " + released);
jedis.close();
}, "线程B");
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println("[" + now() + "] 演示结束。");
}
private static String now() {
return LocalTime.now().format(FMT);
}
private static void sleepQuietly(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
怎么跑
# 1. 启动 Redis(任选一种)
docker run -d --name redis-lock -p 6383:6379 redis:7
# 2. 运行 Main(IDEA 直接右键运行,或命令行)
mvn compile exec:java -Dexec.mainClass=Main
实测输出(跑出来应该和这份一致)
[15:51:04.861] 连接 Redis 成功,ping = PONG
[15:51:04.924] 线程A:抢到锁成功!requestId = eae4577b-b541-48f0-b446-80fb57d4db0a
[15:51:04.924] 线程A:拿着锁做业务中(模拟 3 秒)...
[15:51:05.092] 线程B:第 1 次尝试 → 抢锁失败,0.5s 后重试
[15:51:05.595] 线程B:第 2 次尝试 → 抢锁失败,0.5s 后重试
[15:51:06.098] 线程B:第 3 次尝试 → 抢锁失败,0.5s 后重试
[15:51:06.599] 线程B:第 4 次尝试 → 抢锁失败,0.5s 后重试
[15:51:07.112] 线程B:第 5 次尝试 → 抢锁失败,0.5s 后重试
[15:51:07.615] 线程B:第 6 次尝试 → 抢锁失败,0.5s 后重试
[15:51:07.930] 线程A:业务做完了
[15:51:07.931] 线程A:释放锁,结果 = true
[15:51:08.121] 线程B:第 7 次尝试 → 抢到锁了!
[15:51:08.637] 线程B:用完锁,释放锁,结果 = true
[15:51:08.637] 演示结束。
请盯着这份输出问自己 3 个问题:
- 为什么 B 前面 6 次都失败、偏偏 第 7 次成功?(答案藏在时间线里)
- 如果我把
Thread.sleep(3000)改成5000,B 会提前成功还是更晚?为什么? - 如果我把
EX 10改成EX 0.5(过期 0.5 秒),会发生什么恐怖的事?(提示:误删!——亲手跑一次,你会永远记住 Lua 那 5 行代码为什么存在)
跑出来的输出和我这份不一样?把环境(JDK 版本 / Redis 版本 / Docker)发评论区,我们一起查。
六、跑完之后的加分题(生产环境三件事)
1. 锁的过期时间必须 > 业务最长耗时,否则业务没跑完锁先释放,等于没锁。
2. Redisson 看门狗(watchdog):生产环境别手写,直接用 Redisson。锁快到期自动帮你续租,直到你主动释放。一句话:防"业务还没跑完,锁先被别人抢走"。
3. 可重入:同一个线程已持锁,再进一个也要加锁的方法,value 里记计数器,进来 +1、出去 -1,减到 0 才真释放。
七、面试回答模板(背下来)
面试官:Redis 分布式锁怎么实现?
三板斧:① 加锁用
SET key 唯一标识 NX EX 秒数一条原子命令,NX 保证不存在才设置、EX 保证自动过期,杜绝死锁;② value 存持锁者唯一标识,防止业务超时锁自动过期后误删新持有者的锁;③ 解锁用 Lua 脚本把"判断 value + 删除"合成原子操作,消除判断到删除之间的竞态窗口。生产环境直接用 Redisson,看门狗自动续租、支持可重入。
八、总结
| 三板斧 | 解决什么问题 | 不这么做的后果 |
|---|---|---|
SET key value NX EX | 抢占 + 过期原子化 | 分两条命令 = 死锁 |
| value 存唯一标识 | 防误删别人的锁 | 无脑 DEL = 误删 |
| Lua 判断 + 删除 | 消除竞态窗口 | get 再 del = 又误删 |
九、关于这个系列
本文是「Java 后端实战精通营」系列第 1 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都来自真实运行,完整可运行工程在 Gitee:
👉 Redis 实战精通营(10 课):gitee.com/j67mk2/redi…
本课源码位置:lesson-05/(分布式锁 + 缓存一致性,跑完本文演示后可继续做缓存一致性实验,同样可运行)
后续系列(陆续发布):Dubbo / JVM / JUC / RocketMQ / MySQL / Elasticsearch,每门课配完整可运行工程 + 高频面试 30 问 + 简历包装话术。
下一篇:《缓存一致性:先更新数据库再删缓存,为什么?延迟双删又是干嘛的?》——同样有可运行源码,提前
git clone等着。