一个 Redis 分布式锁的过期时间没扛住业务耗时,锁提前自动释放让两个线程同时进了临界区、还互相误删了对方的锁:一次分布式锁失效的深度复盘

用 Redis 分布式锁保证同一笔账只有一个节点处理,跑了大半年都好好的,直到对账量大、业务跑了 40 秒、超过了锁的 30 秒过期时间——锁中途自动释放,第二个线程进了临界区两线程同时处理,第一个线程干完 del 又删掉了第二个线程的锁,锁彻底形同虚设。本文讲透分布式锁的两大核心难题(过期时间两难、误删),给出唯一标识+Lua 原子校验删除防误删、看门狗自动续期解决过期、生产用 Redisson 的正解,系统梳理分布式锁常见坑,最后落到'悲观地审视并守护每一个假设'的分布式思维。

一个 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;最后再加一层业务幂等兜底这套习惯,让我从"遇到并发就上分布式锁"变成了"先想能不能不用、要用就用对并兜底"——核心始终是:分布式锁是把双刃剑,能用更简单的方案就别上;要上就用成熟库、解决过期与误删、并用业务幂等做最后兜底。

我立下的几条规矩

这场"锁过期失效 + 误删"的事故,换来了我做分布式互斥时,刻进骨子里的几条铁律:

  1. 锁必须设过期时间,但过期要扛得住业务耗时。用看门狗续期,别写死短 TTL。
  2. 锁的 value 设唯一标识(UUID)。解锁前校验是不是自己的,防误删。
  3. 解锁用 Lua 脚本原子完成"校验+删除"。分两步之间有竞态。
  4. 加锁用 SET NX EX 一条命令。别用 setnx + expire 两步(中间宕机锁永存)。
  5. 生产用 Redisson 等成熟库,别自己手写。分布式锁暗坑太多。
  6. 临界区尽量短,别把耗时 IO 都塞进锁里。缩短持锁时间。
  7. 业务再加一层幂等兜底。锁万一失效,幂等保证不出脏数据。

写在最后

回头看,这场由"锁的过期时间没扛住业务耗时"引发的、对账重复处理的事故,真正教给我的,远不止"分布式锁要用看门狗续期、要唯一标识防误删"这几个技巧。它让我对"分布式系统里,一切都是不可靠的,你不能假设任何事情会'恰好如你所愿'地发生",有了一次刻骨的体会。我栽跟头,根源在于一连串"想当然的假设":我假设业务会在 30 秒内跑完(实际跑了 40 秒);我假设"我加的锁,解锁时还是我的锁"(实际它过期被别人占了);我假设"del 删的是我加的那把锁"(实际删了别人的)。这些假设在"一切顺利"时都成立,但分布式环境恰恰充满了"不顺利"——业务会突然变慢、锁会到点过期、节点会宕机、操作之间会插入别的操作;我的每一个"想当然",都是一个没有被守护的假设,而分布式环境会精准地、在你最意想不到的时候,击穿每一个这样的假设。这让我领悟到分布式编程一条根本的思维方式:写分布式代码,要从"乐观地假设一切正常"转向"悲观地审视每一个假设"——每写一步,都问自己:"如果这一步和下一步之间,时间过去了很久怎么办?如果这中间节点宕机了怎么办?如果这中间别的线程插进来了怎么办?如果这个操作没有原子性怎么办?";把每一个"我以为会这样"的隐含假设挖出来、并主动地去守护它(用原子操作、用唯一标识、用续期、用幂等),才能写出在不可靠环境下依然正确的代码从"乐观假设"转向"悲观地审视并守护每一个假设"——这,是我用一次分布式锁失效的事故,换来的、关于架构、也关于如何在不可靠的分布式世界里写出可靠代码的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写下一个分布式锁时,先问一句"它的过期时间扛得住业务吗?它会不会误删别人的锁?",转而用上 Redisson 和幂等兜底,那我对着那批重复的对账记录排查的这一下午,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

一次只升级了查询侧 embedding 模型、却忘了重建向量库的 RAG 事故,让检索召回全变成噪声、问答彻底答非所问:一次向量空间不一致的深度复盘

2026-6-2 14:04:35

技术教程

一个被随手写成 async void 的方法抛了异常,我外层的 try-catch 一个都没抓到、进程直接崩溃退出:一次 C# 异步返回类型用错的深度复盘

2026-6-2 14:57:28

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索