一、前言:你是否遇到过"业务还没执行完,锁却消失了"?
在使用 Redis 实现分布式锁时,很多开发者会写出如下代码:
java
// 加锁
redisTemplate.opsForValue().set("lock:order", "locked", Duration.ofSeconds(10));
// 执行业务逻辑(可能耗时 15 秒)
doBusiness();
// 解锁
redisTemplate.delete("lock:order");
看似没问题,但在高并发或网络延迟场景下,极有可能导致"锁被误删"------即 A 线程删除了 B 线程持有的锁!
本文将深入剖析误删的根本原因 ,并给出生产级安全解决方案。
二、什么是"锁误删"?为什么它很危险?
锁误删:线程 A 删除了本应由线程 B 持有的锁。
🧨 后果:
- 锁提前释放 → 其他线程提前获取锁 → 并发安全失效
- 可能引发超卖、重复下单、数据错乱等严重资损问题
三、误删场景还原:一个真实案例
假设优惠券秒杀中,两个请求同时处理同一用户:
| 时间 | 线程 A(请求1) | 线程 B(请求2) |
|---|---|---|
| T1 | 获取锁成功(value=A) | 尝试获取锁,失败(等待) |
| T2 | 开始执行业务(预计 15s) | --- |
| T3 | 锁自动过期(10s 到期) | --- |
| T4 | --- | 获取锁成功(value=B) |
| T5 | 业务执行完毕,调用 DEL lock |
--- |
| T6 | 删除了线程 B 的锁! | --- |
| T7 | --- | 线程 C 立即获取锁,与线程 B 并发执行 → 超卖! |
💥 关键问题 :线程 A 在 T5 时不知道锁已不属于它,却直接删除了 key!
四、根本原因分析
❌ 错误 1:解锁时不校验持有者身份
java
redis.delete("lock"); // 谁都能删!
❌ 错误 2:锁过期时间 < 业务执行时间
- 导致锁提前释放,其他线程趁机获取
- 原持有者仍以为自己持有锁
❌ 错误 3:未使用唯一标识作为 value
- 若所有线程都用
"locked"作为 value,无法区分持有者
五、正确做法:用唯一 ID + Lua 脚本安全解锁
✅ 核心原则:
只有加锁的线程,才能删除自己的锁!
步骤 1:加锁时使用唯一 value(如 UUID)
java
String lockValue = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent("lock:coupon:1001", lockValue, Duration.ofSeconds(30));
步骤 2:解锁时通过 Lua 脚本校验 value
Lua
-- unlock.lua
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
步骤 3:Java 调用 Lua 解锁
java
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setScriptText(
"if redis.call('GET', KEYS[1]) == ARGV[1] then " +
"return redis.call('DEL', KEYS[1]) " +
"else return 0 end"
);
UNLOCK_SCRIPT.setResultType(Long.class);
}
public void unlock(String lockKey, String lockValue) {
redisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(lockKey),
lockValue);
}
✅ 优势:
GET + DEL原子执行,避免中间状态- 只有 value 匹配的线程才能删除锁
- 彻底杜绝误删
六、进阶问题:业务执行时间 > 锁过期时间?
即使解决了误删,若业务耗时超过锁 TTL,仍会提前释放锁。
解决方案:锁自动续期(Watchdog 机制)
类似 Redisson 的
watchdog功能:只要线程还活着,就自动延长锁有效期。
简易实现思路:
java
// 加锁后启动后台线程
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
// 如果业务未完成,且仍持有锁,则续期
if (businessNotDone && isLockOwner()) {
redisTemplate.expire("lock:xxx", Duration.ofSeconds(30));
}
}, 10, 10, TimeUnit.SECONDS);
⚠️ 注意:需配合 volatile 标志位控制生命周期,避免内存泄漏。
七、其他注意事项
1. 不要使用固定字符串作为 value
java
// 危险!所有实例 value 相同
set("lock", "1", EX 10)
✅ 正确:每个请求生成唯一 ID(UUID / ThreadID + IP)
2. 避免 Redlock 过度设计
- Redis 官方已不推荐 Redlock(复杂且性能低)
- 对于大多数场景,单 Redis 实例 + 唯一 value + Lua 解锁足够安全
3. 监控锁持有时间
- 记录加锁/解锁时间,告警异常长持有
- 避免死锁或慢 SQL 拖垮系统
八、总结
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!