一个 Redis 分布式锁的过期时间没扛住业务耗时,锁提前自动释放让两个线程同时进了临界区、还互相误删了对方的锁:一次分布式锁失效的深度复盘
那是一次对账系统的诡异事故:我们用 Redis 分布式锁保证"同一笔账同一时刻只有一个节点在处理",代码看起来稳如老狗,跑了大半年都没事。可那天对账量特别大,突然就冒出了一批"重复处理"的账目——同一笔账被处理了两次,金额对不上。我盯着这把"明明加了锁"的锁看了整整一下午,百思不得其解:锁加了、过期时间也设了(防死锁),逻辑上滴水不漏,怎么还会并发?直到我把锁的过期时间(30 秒)和那天业务的实际耗时(对账量大时能跑 40 多秒)一对比,才猛然惊醒:业务执行的时间,超过了锁的过期时间。于是锁在业务还没干完时就自动过期释放了,第二个线程趁机拿到锁、进了临界区——两个线程同时在处理同一笔账。更要命的是,第一个线程终于干完活,去 DEL 删锁时,删掉的已经不是自己的锁了,而是第二个线程刚加上的锁——于是第三个线程又能进来……锁,彻底形同虚设。这篇就把这次"分布式锁因过期时间不当而失效"的坑,从头到尾复盘一遍。
故障现场:一把"看起来没问题"的 Redis 分布式锁
问题代码是一个很多人都会这么写的 Redis 分布式锁,它埋了两颗雷:
// ✗ 出问题的分布式锁实现
public class RedisLock {
private final Jedis jedis;
public boolean lock(String key) {
// 加锁: SET key "1" NX EX 30 —— 设了30秒过期, 防止死锁
String r = jedis.set(key, "1", SetParams.setParams().nx().ex(30));
return "OK".equals(r);
// ✗ 雷一: 过期时间写死30秒; 如果业务跑了40秒, 锁会在第30秒【自动过期】,
// 此时业务还没干完, 别的线程就能拿到锁 → 两个线程同时进临界区!
}
public void unlock(String key) {
jedis.del(key);
// ✗ 雷二: 直接 DEL, 不管这把锁【是不是自己加的】!
// 如果自己的锁已过期、被别人重新加了, 这个 del 删的是【别人的锁】!
}
}
// 业务调用:
public void reconcile(String accountId) {
String key = "lock:reconcile:" + accountId;
if (lock.lock(key)) { // 加锁
try {
doReconcile(accountId); // ★ 对账, 量大时可能跑40+秒 > 锁的30秒过期!
} finally {
lock.unlock(key); // 解锁(可能删掉别人的锁)
}
}
}
// 灾难链:
// T0: 线程A 加锁成功(30秒过期), 开始对账(要跑40秒);
// T30: 锁自动过期消失(A还在干, 没干完);
// T31: 线程B 加锁成功(因为锁没了), 也开始对账同一笔 → A、B同时处理! 重复!
// T40: 线程A 干完, unlock → del 删掉了【B的锁】!
// T41: 线程C 又能加锁成功 → 和B同时处理 → 雪上加霜……
第一次理清这条灾难链时,我惊出一身冷汗:"我加了锁、还贴心地设了过期时间防死锁,结果这个'防死锁'的过期时间,反而成了'锁失效'的根源?"这个坑最隐蔽的地方在于:它平时完全正常——只要业务耗时没超过锁的过期时间,这把锁就工作得好好的;只有当业务偶尔变慢、超过了过期时间的那一刻(对账量大、GC 停顿、下游变慢),锁才会失效。而这种"偶发"恰恰最难排查:它和并发、和耗时、和时序强相关,平时测不出来,只在生产高负载下偶现;且现象(重复处理)离根因(锁过期)很远。下面就来拆解,这两颗雷背后的机理与正解。
第一件事:搞懂分布式锁的两个核心难题——过期与误删
我顺着这次事故,把分布式锁"为什么这么难做对"彻底捋清了,核心是两个相互纠缠的难题。
分布式锁的两个核心难题: "过期时间" 和 "误删"
【难题一: 过期时间(TTL)的两难】
锁必须设过期时间, 否则: 持锁的节点宕机了, 锁永远不释放 → 死锁。
但过期时间多长? 这是个两难:
- 设太短: 业务还没干完, 锁就过期了 → 别人能进来 → 锁失效(本文);
- 设太长: 万一持锁节点真宕机了, 别人要等很久才能拿到锁 → 可用性差。
根本矛盾: 你【无法预知】业务到底要跑多久(对账量、GC、网络都会影响),
所以任何一个【写死】的过期时间, 都可能在某种情况下"不够长"。
【难题二: 误删别人的锁】
unlock 直接 del key, 没校验"这把锁是不是我加的":
- 如果我的锁已过期、又被别人加了同名锁;
- 我业务干完去 del, 删的就是【别人的】锁 → 别人的临界区也失去保护。
根本问题: 锁没有"归属标识", del 时分不清"是我的锁"还是"别人的锁"。
【两个难题如何叠加成灾难(本文)】:
过期(难题一)导致"我的锁提前没了、别人进来了";
误删(难题二)导致"我干完又删了别人的锁";
两者叠加 → 锁彻底失效, 多个线程接力进入临界区。
【解决方向】:
- 难题二(误删): 给锁加【唯一归属标识】(如UUID), del前先校验是不是自己的,
且"校验+删除"必须【原子】(用Lua脚本), 否则校验和删除之间又有竞态。
- 难题一(过期): 用"看门狗(watchdog)"机制——后台线程定期给锁【续期】,
只要业务没干完就一直续, 业务干完才真正释放; 这样既防死锁、又不会提前过期。
一句话: 分布式锁难在"过期时间两难"和"误删", 正解是"唯一标识+Lua原子校验删除"防误删、
"看门狗自动续期"解决过期两难; 自己手写很难全对, 生产建议用成熟方案(如Redisson)。
这两个难题,是整个坑的根。难题一,过期时间的两难:锁必须设过期时间防死锁(持锁节点宕机锁永不释放),但设太短业务没干完锁就过期(本文)、设太长节点真宕机时别人等太久;根本矛盾是你无法预知业务要跑多久,任何写死的过期时间都可能不够长。难题二,误删别人的锁:unlock 直接 del、没校验锁是不是自己加的,自己的锁过期被别人加了同名锁后,自己 del 删的是别人的锁;根本问题是锁没有归属标识。两者叠加成灾:过期导致"我的锁提前没了别人进来",误删导致"我干完又删了别人的锁",锁彻底失效。解决方向:误删用唯一标识 + Lua 原子校验删除(del 前校验是自己的,且校验+删除原子);过期用看门狗自动续期(业务没干完就一直续,干完才释放)。
第二件事:正解——唯一标识 + Lua 原子删除防误删,看门狗续期解决过期
搞懂了原理,正解就清晰了:锁的 value 设唯一标识(只删自己的锁)、用 Lua 脚本保证"校验+删除"原子、用看门狗自动续期让锁扛住业务耗时;生产环境直接用成熟的 Redisson。
// ====== 正解一: 唯一标识 + Lua原子校验删除(防误删) ======
public class RedisLock {
private final Jedis jedis;
// Lua脚本: 先比对value是不是自己的, 是才删 —— 整个操作【原子】执行
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 String lock(String key, int ttlSec) {
String token = UUID.randomUUID().toString(); // ★ 每次加锁生成唯一标识
String r = jedis.set(key, token, SetParams.setParams().nx().ex(ttlSec));
return "OK".equals(r) ? token : null; // 返回token, 解锁时要用
}
public boolean unlock(String key, String token) {
// ★ 用Lua: "判断是不是自己的锁 + 删除" 一次原子完成, 不会误删别人的
Object r = jedis.eval(UNLOCK_LUA,
Collections.singletonList(key), Collections.singletonList(token));
return Long.valueOf(1).equals(r);
}
}
// → 为什么必须用Lua: 如果分两步("先get判断" 再 "del"), 在两步之间锁可能过期被别人加,
// 你的del还是会误删。Lua让"判断+删除"原子执行, 杜绝这个竞态。
// ====== 正解二: 用看门狗(watchdog)自动续期, 解决"过期两难" ======
// 思路: 加锁后, 起一个后台线程, 每隔 ttl/3 检查业务是否还在跑,
// 还在跑就给锁【续期】(重置过期时间); 业务结束才停止续期并释放锁。
// → 锁不会在业务执行中途过期(一直被续), 又因为持锁节点宕机后续期停止、锁最终过期(防死锁)。
// 这套逻辑自己写容易出错, 生产强烈建议用 Redisson(它内置了看门狗):
RLock lock = redisson.getLock("lock:reconcile:" + accountId);
try {
lock.lock(); // ★ 默认开启看门狗: 锁默认30秒, 业务没完每10秒自动续到30秒
doReconcile(accountId); // 哪怕跑40秒、60秒, 锁也一直有效(被续期), 不会提前释放
} finally {
lock.unlock(); // 释放(内部已做唯一标识校验, 只释放自己的)
}
// → Redisson的看门狗: 只要持锁线程活着、没主动unlock, 就每隔10秒把锁续到30秒;
// 线程一旦宕机(续期停止), 锁最多30秒后过期 → 既不提前过期、又能防死锁。
// ====== 正解三: 若不用看门狗, 过期时间要】留足余量 + 业务尽量短 ======
// - 过期时间设成"业务P99耗时的2~3倍", 留足缓冲;
// - 临界区里只放"必须互斥"的最小逻辑, 别把耗时操作(IO/远程调用)都塞进锁里。
// 核心: 锁加唯一标识(UUID)、解锁用Lua原子校验删除(防误删); 用看门狗自动续期解决过期两难;
// 生产用Redisson等成熟方案, 别自己手写(太容易出错); 临界区尽量短。
修复的核心,是"让锁只被自己释放、且能扛住业务耗时"。正解一:唯一标识 + Lua 原子删除(防误删)——加锁时 value 设 UUID 唯一标识,解锁用 Lua 脚本把"校验是不是自己的 + 删除"原子完成;必须用 Lua,否则"先 get 判断再 del"两步之间锁可能过期被别人加、还是会误删。正解二:看门狗自动续期(解决过期两难)——后台线程定期给锁续期(业务没干完一直续、干完才释放),持锁节点宕机则续期停止、锁最终过期防死锁;生产用 Redisson(内置看门狗)。正解三:不用看门狗就过期时间留足余量(P99 的 2-3 倍)+ 临界区尽量短。归根结底:锁加唯一标识、解锁用 Lua 原子校验删除、看门狗自动续期、生产用 Redisson、临界区尽量短。
第三件事:分布式锁的其他常见坑
排查后我把分布式锁相关的其他常见坑也系统梳理了一遍。
分布式锁的其他常见坑
# 1. 过期时间短于业务耗时(本文): 锁提前过期, 别人进临界区。→ 看门狗续期/留足余量。
# 2. 解锁直接del不校验归属(本文): 误删别人的锁。→ 唯一标识+Lua原子校验删除。
# 3. 校验和删除非原子: "先get判断再del"两步之间有竞态。→ 用Lua脚本原子执行。
# 4. 加锁和设过期非原子: 老写法 setnx + 单独 expire, 中间宕机会导致锁永不过期。
# → 用 SET key val NX EX 一条命令原子完成。
# 5. 主从切换丢锁: master加锁后还没同步到slave就挂了, slave上位锁丢了。
# → 对极高要求用RedLock(多节点), 或用更强一致的方案(但有争议/复杂)。
# 6. 没处理加锁失败: 加锁失败应"等待重试"或"快速失败返回", 别当成功继续执行。
# 7. 锁粒度太粗: 给整个大流程加一把大锁, 并发度极差。→ 锁的粒度尽量细(锁到具体资源)。
# 8. 可重入问题: 同一线程重复加同一把锁会自己锁死自己。→ 用可重入锁(Redisson支持)。
# 共同根源: 分布式锁要在"不可靠的网络、会宕机的节点、并发的线程"下保证互斥,
# 涉及原子性、过期、归属、续期、容错等一堆细节, 任一处考虑不周就会失效。
# 核心: 分布式锁细节极多、极易写错; 理解过期与误删两大难题; 用唯一标识+Lua+看门狗;
# 生产优先用Redisson等成熟库; 想清楚锁失效时的后果(最好业务再加一层幂等兜底)。
排查让我把分布式锁的其他坑也梳理清了。一、过期时间短于业务耗时(本文)。二、解锁直接 del 不校验归属(本文)。三、校验和删除非原子(用 Lua)。四、加锁和设过期非原子(用 SET NX EX 一条命令)。五、主从切换丢锁(RedLock/更强方案)。六、没处理加锁失败。七、锁粒度太粗(锁到具体资源)。八、可重入问题。它们的共同根源是:分布式锁要在"不可靠的网络、会宕机的节点、并发的线程"下保证互斥,涉及原子性、过期、归属、续期、容错一堆细节,任一处不周就失效。核心是:分布式锁细节极多极易写错;理解过期与误删两大难题;用唯一标识+Lua+看门狗;生产优先用 Redisson;想清楚锁失效的后果(最好业务再加一层幂等兜底)。下面这张图,是这次分布式锁失效的成因与解法:
第四件事:分布式锁要素检查速查表
这次踩坑后,我把"一把合格的分布式锁该具备哪些要素"整理成一张表,逐条对照。
| 要素 | 不做会怎样 | 正解 |
|---|---|---|
| 加锁原子(SET NX EX) | setnx+expire中间宕机锁永存 | 一条SET命令搞定 |
| 有过期时间 | 持锁节点宕机则死锁 | 必须设TTL |
| 过期扛得住业务耗时 | 提前过期, 并发进入(本文) | 看门狗续期/留足余量 |
| 唯一归属标识 | 误删别人的锁(本文) | value设UUID |
| 解锁原子校验删除 | 校验删除间有竞态 | Lua脚本 |
| 可重入 | 同线程重入自锁死 | 记重入次数 |
| 业务幂等兜底 | 锁万一失效就出脏数据 | 临界区操作做幂等 |
这张表把分布式锁的要素钉清了。核心是:一把"合格的"分布式锁,要同时满足加锁原子、有过期、过期扛得住业务、唯一标识、解锁原子校验删除、可重入、业务幂等兜底这一长串要素——少做任何一条,锁就可能在某种边界情况下失效;这也正是"分布式锁看起来简单、实则极难写对"的原因。它给我的最大启发是:分布式环境下,那些在单机里"理所当然"的东西(加锁、释放锁),都会变得异常复杂——单机锁(synchronized/ReentrantLock)由 JVM 在同一进程、可靠内存里保证,你不用操心宕机、网络、归属、续期;但分布式锁要在多进程、不可靠网络、会宕机的节点之间协调,于是冒出过期、误删、原子性、容错一大堆问题。这揭示了分布式系统的本质困难:"分布式"的复杂,根源在于"没有一个可靠的、全局共享的状态"——单机里有可靠的共享内存做"真相之源",而分布式里每个节点只有自己的局部视图,节点会挂、消息会丢/乱序/延迟,于是"就一件事达成一致"(谁持有锁)都变得无比困难;理解了这一点,你就能理解为什么分布式锁、分布式事务、共识算法都这么复杂——它们都在和"不可靠环境下的一致性"这个根本难题搏斗。认清分布式锁要素之多、理解分布式复杂性的根源(无可靠全局共享状态)——是这个坑带给我的架构认知。
第五件事:不要轻易自己造分布式基础设施的轮子
这次最大的教训之一,是"别自己手写分布式锁"。我把"自己写"和"用成熟库"做了个对比。
| 维度 | 自己手写 | 用 Redisson 等成熟库 |
|---|---|---|
| 正确性 | 极易漏掉边界(本文) | 经大量生产验证 |
| 看门狗续期 | 要自己实现, 难 | 内置 |
| 原子/可重入/容错 | 都要自己抠 | 都已处理 |
| 维护成本 | 自己背所有锅 | 社区维护 |
| 适用场景 | 学习原理 | 生产 |
这张表道出了一个朴素而重要的工程选择。核心是:对分布式锁这种"原理看着简单、细节多如牛毛、且一旦出错后果严重(数据错乱)"的基础设施,自己手写极易漏掉边界情况(本文就是活例),而成熟库(Redisson 等)已经把看门狗、原子、可重入、容错这些坑都踩平了、经过了大量生产验证;生产环境应优先用成熟库,而不是自己造轮子。它给我的深刻启发是:要清醒地分辨"哪些轮子值得自己造、哪些不该造"——对那些"看似简单、实则暗坑密布、且属于通用基础能力"的东西(分布式锁、连接池、加密、日期时间库、JSON 解析、并发容器),自己造轮子的"隐性成本"(漏掉的边界、踩不完的坑、长期维护)远超想象,几乎总是不划算;"不要重新发明轮子",尤其不要重新发明那些"看起来圆、其实有无数棱角"的轮子。这不是说不能造轮子,而是造轮子前先掂量:这个领域是不是已有经过千锤百炼的成熟方案?我自己造,真能比它做得更对、更稳吗?如果不能,就站在巨人的肩膀上,把精力放在自己的业务价值上。不轻易自造分布式基础设施的轮子、善用成熟方案——是这个分布式锁坑,带给我的关于工程决策的成熟认知。
第六件事:要用分布式锁前,我现在的思考习惯
现在每当我准备上分布式锁,我都会按这张图先想一遍:
这张图的精髓,是"能不用就不用,要用就用成熟库并加幂等兜底"。先问是否真需要分布式锁(能用幂等/乐观锁/唯一约束就别引入);要用就生产用 Redisson 别手写;临界区可能长就靠看门狗续期别写死短 TTL;最后再加一层业务幂等兜底。这套习惯,让我从"遇到并发就上分布式锁"变成了"先想能不能不用、要用就用对并兜底"——核心始终是:分布式锁是把双刃剑,能用更简单的方案就别上;要上就用成熟库、解决过期与误删、并用业务幂等做最后兜底。
我立下的几条规矩
这场"锁过期失效 + 误删"的事故,换来了我做分布式互斥时,刻进骨子里的几条铁律:
- 锁必须设过期时间,但过期要扛得住业务耗时。用看门狗续期,别写死短 TTL。
- 锁的 value 设唯一标识(UUID)。解锁前校验是不是自己的,防误删。
- 解锁用 Lua 脚本原子完成"校验+删除"。分两步之间有竞态。
- 加锁用 SET NX EX 一条命令。别用 setnx + expire 两步(中间宕机锁永存)。
- 生产用 Redisson 等成熟库,别自己手写。分布式锁暗坑太多。
- 临界区尽量短,别把耗时 IO 都塞进锁里。缩短持锁时间。
- 业务再加一层幂等兜底。锁万一失效,幂等保证不出脏数据。
写在最后
回头看,这场由"锁的过期时间没扛住业务耗时"引发的、对账重复处理的事故,真正教给我的,远不止"分布式锁要用看门狗续期、要唯一标识防误删"这几个技巧。它让我对"分布式系统里,一切都是不可靠的,你不能假设任何事情会'恰好如你所愿'地发生",有了一次刻骨的体会。我栽跟头,根源在于一连串"想当然的假设":我假设业务会在 30 秒内跑完(实际跑了 40 秒);我假设"我加的锁,解锁时还是我的锁"(实际它过期被别人占了);我假设"del 删的是我加的那把锁"(实际删了别人的)。这些假设在"一切顺利"时都成立,但分布式环境恰恰充满了"不顺利"——业务会突然变慢、锁会到点过期、节点会宕机、操作之间会插入别的操作;我的每一个"想当然",都是一个没有被守护的假设,而分布式环境会精准地、在你最意想不到的时候,击穿每一个这样的假设。这让我领悟到分布式编程一条根本的思维方式:写分布式代码,要从"乐观地假设一切正常"转向"悲观地审视每一个假设"——每写一步,都问自己:"如果这一步和下一步之间,时间过去了很久怎么办?如果这中间节点宕机了怎么办?如果这中间别的线程插进来了怎么办?如果这个操作没有原子性怎么办?";把每一个"我以为会这样"的隐含假设挖出来、并主动地去守护它(用原子操作、用唯一标识、用续期、用幂等),才能写出在不可靠环境下依然正确的代码。从"乐观假设"转向"悲观地审视并守护每一个假设"——这,是我用一次分布式锁失效的事故,换来的、关于架构、也关于如何在不可靠的分布式世界里写出可靠代码的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写下一个分布式锁时,先问一句"它的过期时间扛得住业务吗?它会不会误删别人的锁?",转而用上 Redisson 和幂等兜底,那我对着那批重复的对账记录排查的这一下午,就值了。
—— 别看了 · 2026