“Redis 分布式锁踩坑:SET NX EX 还不够?我用 Lua + token 才真正稳住“
搞分布式锁这事,我以前一直挺自信的:SET key value NX EX 30,不就完了?
结果上个月一个“看起来不可能”的线上问题把我教育得很彻底:
- 业务偶发出现“重复扣减/重复发放”
- 日志里明明看到锁加成功了
- 但同一个资源在很短时间内被两个实例都处理了
我最开始怀疑是代码逻辑写炸了,后来一路排查到 Redis 锁的实现细节:加锁只是第一步,解锁的正确姿势更重要;另外续期、重入、异常时序都能让你翻车。
这篇我不讲大而全的“分布式锁原理”,就围绕一个很小但很致命的点:为什么 SET NX EX 还会踩坑,以及怎么用 token + Lua 把“解锁误删”彻底堵上。顺便把“需要续期时怎么做”也给一个可落地的实现。
1)问题是什么:我明明加锁了,为什么还是会重复执行?
现象:
- 服务 A 获取到锁,开始执行。
- 执行过程中发生超时/卡顿(比如下游慢、GC、网络抖动)。
- 锁过期后,服务 B 又获取到了锁并执行。
- 服务 A 终于执行完,准备释放锁——它把服务 B 的锁删了。
这就是经典的“锁过期 + 误删他人锁”场景。
很多人(包括我当时)写的解锁代码是这样的:
import redis.clients.jedis.Jedis;
public class BadUnlockDemo {
public static void main(String[] args) {
try (Jedis jedis = new Jedis("127.0.0.1", 6379)) {
String lockKey = "lock:order:1001";
// 加锁
String ok = jedis.set(lockKey, "any", "NX", "EX", 30);
if (!"OK".equals(ok)) {
System.out.println("lock failed");
return;
}
// 业务逻辑...
// 错误解锁:直接 del
jedis.del(lockKey);
}
}
}
问题描述:del(lockKey) 没有校验“锁是不是我加的”。如果锁过期后被别人抢走,你这一下 del 就是在帮别人开门。
原因分析:Redis 锁本质上只是一个 KV,DEL 没有所有权概念。你不把“所有者身份”放进 value 里,并且在删除时校验,就一定有概率误删。
解决方案:加锁时写入一个唯一 token(比如 UUID),解锁时必须先判断 value==token,再删除,而且要原子化。
2)为什么“先 GET 再 DEL”也不行:并发窗口会让你继续翻车
很多人会把解锁改成这样:
import redis.clients.jedis.Jedis;
import java.util.UUID;
public class GetThenDelStillBad {
public static void main(String[] args) {
try (Jedis jedis = new Jedis("127.0.0.1", 6379)) {
String lockKey = "lock:order:1001";
String token = UUID.randomUUID().toString();
String ok = jedis.set(lockKey, token, "NX", "EX", 30);
if (!"OK".equals(ok)) return;
// ...业务执行
// 看起来“更安全”的解锁
String val = jedis.get(lockKey);
if (token.equals(val)) {
jedis.del(lockKey);
}
}
}
}
问题描述:这段逻辑依然会误删。
原因分析:GET 和 DEL 不是原子操作,中间存在并发窗口:
- 线程 A
GET到 token 相等 - 锁刚好过期
- 线程 B 抢到新锁(value=tokenB)
- 线程 A 执行
DEL,把 B 的锁删掉
所以正确做法必须是:比较 + 删除一次性完成。
解决方案:用 Lua 脚本把“校验 value + 删除 key”合并成 Redis 内部原子执行。
3)正确解锁:Lua 原子校验 + 删除(最关键的一段)
下面这段 Lua 是我现在的固定模板:
-- KEYS[1] = lockKey
-- ARGV[1] = token
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
Java(Jedis)调用示例:
import redis.clients.jedis.Jedis;
import java.util.Collections;
import java.util.UUID;
public class RedisLockWithLua {
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";
public static void main(String[] args) {
try (Jedis jedis = new Jedis("127.0.0.1", 6379)) {
String lockKey = "lock:order:1001";
String token = UUID.randomUUID().toString();
String ok = jedis.set(lockKey, token, "NX", "EX", 30);
if (!"OK".equals(ok)) {
System.out.println("lock failed");
return;
}
try {
// 业务逻辑
System.out.println("do business...");
} finally {
Object res = jedis.eval(
UNLOCK_LUA,
Collections.singletonList(lockKey),
Collections.singletonList(token)
);
System.out.println("unlock result = " + res);
}
}
}
}
问题描述:解锁必须保证“只有持有 token 的线程能删”。
原因分析:Lua 脚本在 Redis 内部执行,期间不会被其他命令打断,因此不会出现 GET/DEL 并发窗口。
解决方案:token 放 value,Lua 原子解锁。
到这里,你已经把最常见、最隐蔽的坑堵死了。
4)锁超时怎么办:要不要续期?怎么续期才不把别人锁续了?
上面说的“锁过期导致误删”,有一半原因是:业务执行时间不可控。如果你的业务可能超过锁 TTL(比如 30s),那光靠 token 解锁还不够。
你有两个选择:
1)把 TTL 设置得足够大:简单粗暴,但会降低并发度,且遇到死锁风险(进程挂了等 TTL)。
2)续期(watch dog):执行中定时延长 TTL。
续期也有坑:你必须保证“只给自己的锁续期”。否则锁过期后被别人拿走,你还在续期,相当于把别人锁保活了。
所以续期也要用 Lua:校验 token 一致才 PEXPIRE。
Lua 续期脚本:
-- KEYS[1] = lockKey
-- ARGV[1] = token
-- ARGV[2] = ttlMillis
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('pexpire', KEYS[1], ARGV[2])
else
return 0
end
Java 续期线程示例(可运行,注意 finally 里关闭线程):
import redis.clients.jedis.Jedis;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.*;
public class RedisLockRenewDemo {
private static final String RENEW_LUA =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('pexpire', KEYS[1], ARGV[2]) " +
"else " +
" return 0 " +
"end";
public static void main(String[] args) throws Exception {
try (Jedis jedis = new Jedis("127.0.0.1", 6379)) {
String lockKey = "lock:order:1001";
String token = UUID.randomUUID().toString();
// 这里用 PX 10s,示例更直观
String ok = jedis.set(lockKey, token, "NX", "PX", 10_000);
if (!"OK".equals(ok)) {
System.out.println("lock failed");
return;
}
ScheduledExecutorService renewPool = Executors.newSingleThreadScheduledExecutor();
ScheduledFuture<?> future = renewPool.scheduleAtFixedRate(() -> {
try (Jedis j = new Jedis("127.0.0.1", 6379)) {
Object r = j.eval(
RENEW_LUA,
Collections.singletonList(lockKey),
java.util.Arrays.asList(token, String.valueOf(10_000))
);
// r==1 表示续期成功,0 表示锁已不属于你
System.out.println("renew=" + r);
}
}, 3, 3, TimeUnit.SECONDS);
try {
// 模拟长业务
Thread.sleep(20_000);
System.out.println("business done");
} finally {
future.cancel(true);
renewPool.shutdownNow();
// 解锁也要 Lua 校验
String unlockLua =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end";
jedis.eval(unlockLua, Collections.singletonList(lockKey), Collections.singletonList(token));
}
}
}
}
问题描述:业务可能超过 TTL,锁会过期被抢。
原因分析:TTL 不是“锁一定会持有这么久”,而是“最多活这么久”。遇到 GC、慢 SQL、外部接口抖动都可能撑爆 TTL。
解决方案:要么 TTL 足够大(且可接受),要么续期;续期必须校验 token。
5)我会再加一层保险:业务幂等 + 锁只负责“减少并发”
坦白讲,分布式锁在我心里定位一直是:降低并发冲突概率,不是“终极正确性来源”。
因为你再怎么写锁,还是可能遇到:
- Redis 故障 / 网络分区
- 进程 pause(STW)
- 锁过期后被抢的极端时序
所以我现在做法是:锁之外再做一道幂等(比如订单状态机、唯一索引、去重表)。
下面给一个非常小但有效的幂等模板:用 MySQL 唯一键防重复(锁只是锦上添花)。
CREATE TABLE t_order_process_guard (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_key VARCHAR(64) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_biz_key (biz_key)
);
Java 侧:插入成功才继续;冲突说明已经处理过。
import java.sql.*;
public class IdempotentWithUniqueKey {
public static void main(String[] args) throws Exception {
String url = "jdbc:mysql://127.0.0.1:3306/test?useSSL=false&serverTimezone=UTC";
String user = "root";
String pass = "root";
String bizKey = "order:1001:deduct";
try (Connection conn = DriverManager.getConnection(url, user, pass)) {
conn.setAutoCommit(true);
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO t_order_process_guard(biz_key) VALUES (?)")) {
ps.setString(1, bizKey);
ps.executeUpdate();
System.out.println("first time, continue business");
} catch (SQLIntegrityConstraintViolationException dup) {
System.out.println("duplicate bizKey, ignore");
}
}
}
}
问题描述:锁不是 100% 可靠,极端情况下还是会重复执行。
原因分析:分布式系统里没有“绝对时序”。锁能降低概率,但挡不住所有故障模型。
解决方案:业务层幂等兜底(唯一键/状态机/去重表)。
写在最后:我现在怎么写 Redis 锁(以及我不再迷信它)
这次踩坑之后,我把“Redis 分布式锁”这件事的底线拉得很清楚:
- 加锁:
SET key token NX EX/PX ttl - 解锁:必须 Lua 原子校验 token
- 续期:只有在“业务可能超过 TTL 且并发敏感”时做;续期也必须校验 token
- 终极保障:幂等一定要有,锁只负责减少冲突
如果你现在项目里还在用“直接 DEL 解锁”,真的建议抽 10 分钟改掉。
下一篇我可能会写:Redisson 的 watchdog 到底做了什么、它为什么比我手写线程池更稳(以及它也不是万能的)。你要是也踩过类似坑,评论区互相取暖一下。
更多推荐




所有评论(0)