我用 Redis 加了分布式锁保护临界区,以为万无一失,结果偶发两个进程同时进了临界区、一个进程还把别人的锁给删了:一次分布式锁实现陷阱的深度复盘
那个并发问题是数据偶发错乱才暴露的:我有段临界区代码(同一资源同一时刻只能一个进程处理),用 Redis 加了个"分布式锁"来保护:SET lock_key 1 NX EX 30 抢锁、处理完 DEL lock_key 释放。我以为这就万无一失了。可线上偶发:明明加了锁,却出现了两个进程同时进入临界区的数据错乱;更诡异的是,有时一个进程把另一个进程正持有的锁给删了。我对着这"加了锁还并发"的怪事查了好久,推演了时序,才看明白,后背发凉:我这个"分布式锁"有两个致命漏洞。漏洞一:锁过期时间(EX 30)到了,但业务还没处理完。我设了锁 30 秒自动过期(为了防止持锁进程崩了导致死锁,这本身是对的);可如果进程 A 的业务执行超过了 30 秒,锁就自动过期释放了,而 A 还在临界区里跑;此时进程 B 来抢锁,锁已过期、B 抢到了,于是 A 和 B 同时在临界区里——锁形同虚设;漏洞二:释放锁时,直接 DEL lock_key,没check 这把锁是不是自己的。接着上面:A 终于跑完了,执行 DEL lock_key——可这把锁此刻已经是 B 的了(A 的早过期、B 重新加的),A 这一删,把 B 的锁删掉了;于是 B 的临界区又失去了保护,C 又能进来……连锁崩坏。根本原因是:分布式锁不只是"SET NX 抢、DEL 释放"这么简单,它要正确处理"持锁超时"和"只能释放自己的锁"这两个关键问题。问题的根,是分布式锁实现有缺陷:锁过期了业务没完成致两进程同时进临界区、释放时不校验所有权而误删了别人的锁。这篇就把这次"分布式锁实现陷阱"的坑,从头到尾复盘一遍。
故障现场:锁过期 + 误删别人的锁
问题在于锁过期了业务还没完成、且释放时直接 DEL 没校验所有权:
// ✗ 出问题的分布式锁: 看似没问题, 实则两个致命漏洞
public void doWork() {
// 抢锁: SET lock NX EX 30 (不存在才设, 30秒过期)
boolean locked = redis.set("lock_key", "1", "NX", "EX", 30);
if (!locked) return; // 没抢到, 退出
try {
criticalSection(); // ✗ 临界区业务; 如果它执行超过30秒...
} finally {
redis.del("lock_key"); // ✗ 释放锁: 直接DEL, 没check是不是自己的锁!
}
}
// 漏洞一: 锁过期了, 业务还没跑完
// - 设EX 30是对的(防止持锁进程崩了, 锁永不释放=死锁);
// - 但若 criticalSection() 跑了超过30秒:
// * 第30秒, 锁自动过期释放;
// * 进程A还在临界区里跑;
// * 进程B此时来抢锁 → 锁已过期, B抢到了 → A和B【同时在临界区】→ 锁失效, 数据错乱!
// 漏洞二: 释放锁时直接DEL, 删了别人的锁
// - 接上: A终于跑完, 执行 del("lock_key");
// - 可此刻这把锁已经是B的了(A的过期、B重新SET的);
// - A这个del → 把【B的锁】删掉了! → B的临界区失去保护 → C又能进来 → 连锁崩坏。
// 时序推演:
// T0: A 抢到锁(EX 30)
// T30: 锁过期(A业务还没完)
// T31: B 抢到锁(锁已过期) ← A、B同时在临界区!
// T35: A 业务完成, del lock_key ← 删的是B的锁!
// T36: C 抢到锁 ← 又一个进来, B、C同时...
// 关键: 分布式锁不是"SET NX抢+DEL释放"这么简单; 锁过期但业务没完会让多进程同时进临界区,
// 释放时不校验所有权会误删别人的锁 —— 要处理"持锁超时"和"只删自己的锁"。
第一次推演出"A 的锁过期了、B 进来了,A 跑完又把 B 的锁删了"时,我又懊恼又警醒:"我以为分布式锁就是 SET NX 抢一下、DEL 删一下这么简单,完全没想到'锁会过期而业务没完''会删了别人的锁'这两个要命的细节。"这个坑最隐蔽的地方在于:它偶发——只在"业务执行时间恰好超过锁 TTL"或"恰好有并发来抢过期的锁"时才触发,平时(业务很快、并发不高)完全正常;而且"加了锁"给了你强烈的安全错觉,让你根本不会怀疑锁本身有问题。下面就来拆解,分布式锁该怎么正确实现。
第一件事:搞懂分布式锁的几个关键问题
我顺着这次事故,把分布式锁要正确处理的几个关键问题彻底理清了。
分布式锁要正确处理哪些关键问题?
【核心: 分布式锁要解决 ①防死锁(设过期) ②持锁超时(过期但业务没完→续期) ③只释放自己的锁(校验owner) ④原子性; 别只SET NX + DEL】
1. 为什么要分布式锁:
- 多个进程/实例并发访问同一共享资源(临界区), 需要"同一时刻只有一个能进";
- 单机锁(synchronized/Lock)只在一个进程内有效, 多实例下无效 → 要用分布式锁(Redis/ZK等)。
2. 关键问题①: 必须设过期时间(防死锁)
- 若持锁进程崩了/卡死, 锁不释放 → 别人永远抢不到 → 死锁;
- 所以要给锁设过期时间(EX), 即使持锁者挂了, 锁也会自动释放。
3. 关键问题②: 过期时间 vs 业务时长的矛盾(持锁超时)
- 过期时间设短了: 业务没跑完锁就过期, 别人抢到 → 多进程同时进临界区(本文漏洞一);
- 过期时间设长了: 持锁者崩了, 别人要等很久才能抢到 → 可用性差;
- 矛盾的解法: "看门狗"自动续期——持锁期间后台定时给锁续期, 业务没完锁就不过期;
业务完成或进程崩了(续期停止), 锁才到期释放 → 兼顾"不误释放"和"防死锁"。
4. 关键问题③: 只能释放自己的锁(防误删)
- 释放时直接DEL → 可能删了别人的锁(本文漏洞二);
- 解法: 加锁时value设一个【唯一标识(如UUID)】; 释放时先check"锁的value是不是我的", 是才删;
- 且"check+del"必须【原子】(用Lua脚本), 否则check和del之间锁又可能过期被别人拿到。
5. 关键问题④: 原子性
- 抢锁(SET NX EX)要原子(一条命令同时设值和过期, 别分两步);
- 释放(校验owner + del)要原子(Lua脚本)。
6. 实践: 别自己造轮子, 用成熟实现
- Redis: 用 Redisson(封装好了看门狗续期、唯一value、Lua原子释放等);
- 也了解Redlock的争议(多节点, 但有争议, 一般单实例Redisson够多数场景);
- 强一致要求高: 考虑ZooKeeper/etcd(基于一致性协议)。
一句话: 分布式锁要正确处理防死锁(设过期)、持锁超时(看门狗续期)、只释放自己的锁(唯一value+Lua原子校验删)、
原子性; 别只"SET NX抢+DEL释放"; 优先用Redisson等成熟实现, 别自己造有坑的轮子。
这套认知,是整个坑的根。为什么要分布式锁:多进程/实例并发访问同一共享资源,单机锁多实例下无效,要用分布式锁。关键问题①必须设过期(防死锁):持锁进程崩了锁不释放=死锁,设过期即使持锁者挂了锁也自动释放。②过期时间 vs 业务时长的矛盾(持锁超时):设短了业务没完锁就过期、别人抢到(漏洞一);设长了持锁者崩了别人等很久;解法是"看门狗"自动续期(持锁期间后台续期、业务没完锁不过期,完成或崩了才释放)。③只能释放自己的锁(防误删):直接 DEL 可能删别人的锁(漏洞二);加锁 value 设唯一标识(UUID)、释放时先校验是自己的才删,且 check+del 必须用 Lua 原子。④原子性:抢锁(SET NX EX 一条命令)、释放(Lua)都要原子。实践:别造轮子,用 Redisson(封装好看门狗续期、唯一 value、Lua 原子释放),强一致用 ZK/etcd。一句话:分布式锁要正确处理防死锁(设过期)、持锁超时(看门狗续期)、只释放自己的锁(唯一 value+Lua 原子校验删)、原子性;别只"SET NX 抢+DEL 释放";优先用 Redisson 等成熟实现,别自己造有坑的轮子。
第二件事:正解——唯一 value + Lua 原子释放 + 看门狗续期(或用 Redisson)
搞懂了原理,正解就清晰了:加锁带唯一 value、释放用 Lua 原子校验所有权再删、用看门狗自动续期解决持锁超时;最省心的是直接用 Redisson。
// ====== 正解一: 唯一value + Lua原子释放(手写最小正确版) ======
String lockValue = UUID.randomUUID().toString(); // ★ 每次加锁用唯一标识
// 抢锁: SET NX EX 原子地设值+过期(value是自己的唯一标识)
boolean locked = redis.set("lock_key", lockValue, "NX", "EX", 30);
if (!locked) return;
try {
criticalSection();
} finally {
// ★ 释放: 用Lua脚本原子地"校验value是自己的才删", 防止删了别人的锁
String lua =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end";
redis.eval(lua, List.of("lock_key"), List.of(lockValue));
// → 只有锁还是我的(value匹配)才删; 若已过期被别人拿到(value变了), 不删 → 不会误删别人的锁。
}
// ====== 正解二: 用 Redisson(推荐, 自带看门狗续期+唯一value+Lua释放) ======
RLock lock = redisson.getLock("lock_key");
lock.lock(); // 加锁; Redisson默认开启"看门狗": 持锁期间后台每隔一段自动续期, 业务没完锁不过期
try {
criticalSection(); // 即使业务跑很久, 看门狗一直续期, 锁不会中途过期
} finally {
lock.unlock(); // 释放(内部已校验是自己的锁、原子操作)
}
// → Redisson把看门狗续期、唯一标识、Lua原子释放都封装好了, 解决了本文的两个漏洞, 省心可靠。
# ====== 实现要点 ======
# 1. 加锁原子: SET key value NX EX ttl 一条命令(别先SET再EXPIRE分两步, 中间崩了又死锁);
# 2. 唯一value: 每次加锁用UUID等唯一标识, 用来证明"这把锁是我的";
# 3. 释放原子+校验: 用Lua脚本"get校验是自己的 then del", 别用裸DEL(会删别人的);
# 4. 持锁超时: 用看门狗自动续期(Redisson默认), 让锁在"业务执行期间不过期、业务完/进程崩才释放";
# 若不用看门狗: 把ttl设得足够长(>业务最大耗时)并监控, 但不如续期优雅;
# 5. 优先用成熟库: Redisson(Redis)、curator(ZooKeeper); 别自己造轮子(细节多、易错);
# 6. 想清一致性要求: Redis分布式锁在主从切换等极端情况有丢锁风险(Redlock有争议);
# 对"绝对不能两个同时进"的强一致场景, 考虑ZK/etcd, 或在业务层加幂等/校验兜底。
# ====== 一个兜底思想 ======
# - 分布式锁很难做到100%可靠(分布式系统的固有难题); 关键业务别只依赖锁, 还要在业务层
# 加"幂等/唯一约束/状态机校验"等兜底(就算锁偶尔失效, 业务层也能挡住重复/并发的危害)。
# 核心: 分布式锁加锁带唯一value+原子设过期、释放用Lua校验所有权再删、用看门狗续期解决持锁超时;
# 优先用Redisson等成熟实现; 关键业务别只靠锁, 业务层再加幂等/校验兜底。
修复的核心,是"唯一 value + Lua 原子释放 + 看门狗续期,或直接用 Redisson"。正解一:唯一 value + Lua 原子释放(手写最小正确版)——加锁 value 用 UUID、释放用 Lua"校验 value 是自己的才删",只有锁还是我的才删、不会误删别人的。正解二:用 Redisson(推荐)——lock.lock() 自带看门狗(持锁期间后台续期、业务没完锁不过期)、唯一标识、Lua 原子释放,把两个漏洞都封装解决了。实现要点:加锁原子(SET NX EX 一条)、唯一 value、释放原子+校验(Lua)、持锁超时用看门狗续期、优先用成熟库、想清一致性要求(Redis 锁有丢锁风险、Redlock 有争议、强一致用 ZK/etcd)。兜底思想:分布式锁很难 100% 可靠,关键业务别只依赖锁,业务层再加幂等/唯一约束/状态机校验兜底。归根结底:分布式锁加锁带唯一 value+原子设过期、释放用 Lua 校验所有权再删、用看门狗续期解决持锁超时;优先用 Redisson 等成熟实现;关键业务别只靠锁,业务层再加幂等/校验兜底。
第三件事:分布式系统协调中其他常见的坑
排查后我把分布式锁、分布式协调相关的其他坑也系统梳理了一遍。
分布式系统协调的其他常见坑
# 1. 分布式锁实现缺陷(本文): 锁过期没续期/误删别人锁。→ 看门狗+唯一value+Lua释放。
# 2. 锁没设过期: 持锁进程崩了, 死锁。→ 必设过期(配合续期)。
# 3. 只依赖锁不做幂等: 锁偶尔失效就出事。→ 业务层加幂等/唯一约束兜底。
# 4. 锁粒度太粗: 锁了过大范围, 并发全卡住。→ 锁到最小必要的资源(如按ID锁)。
# 5. 分布式事务想用2PC强一致: 复杂、性能差、协调者单点。→ 多数用最终一致(消息/SAGA)+幂等。
# 6. 时钟不同步: 依赖各节点本地时间做判断/排序出错。→ 别依赖墙上时钟, 用逻辑时钟/中心化时间。
# 7. 脑裂: 网络分区下多个节点都以为自己是主。→ 基于多数派(quorum)的选主。
# 8. 重复消息/乱序(MQ): 消费者要幂等、要能处理乱序。→ 幂等消费+必要时排序。
# 共同根源: 分布式系统里, "多个独立节点要对某件事达成一致协调", 而它们之间隔着"不可靠的网络、各自的故障、
# 没有全局时钟"——这使得很多单机下简单的事(加把锁、保证只执行一次)在分布式下变得极其困难;
# 任何"想当然地认为分布式协调和单机一样简单"的做法, 都会在网络分区/节点故障/超时等情况下露馅。
# 核心: 分布式协调(锁/事务/选主/一致性)是难题, 别用单机直觉简单处理; 用成熟的中间件和算法、
# 设过期+续期、做幂等兜底、基于多数派、别依赖时钟; 并接受"分布式锁等不是100%可靠"而在业务层留后手。
排查让我把分布式协调的其他坑也梳理清了。一、分布式锁实现缺陷(本文)。二、锁没设过期死锁。三、只依赖锁不做幂等。四、锁粒度太粗。五、分布式事务想用 2PC 强一致。六、时钟不同步。七、脑裂。八、重复消息/乱序。它们的共同根源是:分布式系统里"多个独立节点要对某件事达成一致协调",而它们之间隔着不可靠的网络、各自的故障、没有全局时钟——这使得很多单机下简单的事在分布式下变得极其困难;任何"想当然认为分布式协调和单机一样简单"的做法,都会在网络分区/节点故障/超时等情况下露馅。核心是:分布式协调(锁/事务/选主/一致性)是难题,别用单机直觉简单处理;用成熟的中间件和算法、设过期+续期、做幂等兜底、基于多数派、别依赖时钟;并接受"分布式锁等不是 100% 可靠"而在业务层留后手。下面这张图,是这次分布式锁坑的成因与解法:
第四件事:错误锁 vs 正确锁对比表
这次踩坑后,我把"有缺陷的分布式锁"和"正确的分布式锁"对比成一张表。
| 维度 | 有缺陷的锁(本文) | 正确的锁 |
|---|---|---|
| 过期时间 | 固定 EX, 业务超时就过期 | 看门狗续期, 业务期间不过期 |
| 锁的 value | 固定值(如 "1") | 唯一标识(UUID) |
| 释放 | 直接 DEL(删谁都行) | Lua 校验是自己的才删 |
| 持锁超时后果 | 多进程同时进临界区 | 不会(续期保住) |
| 误删风险 | 会删别人的锁 | 不会(校验所有权) |
| 可靠性 | 偶发崩坏 | 可靠(仍非100%) |
这张表把两种锁钉清了。核心是:那个错误的锁,"正常路径"上看起来完全没问题(抢到锁、干活、释放),它的 bug 全藏在"异常/边界路径"里——业务超时、锁过期、并发抢到过期锁、删了别人的锁;而"正确的锁"和"错误的锁"的差距,恰恰就在于是否认真处理了这些"不那么常见、但一定会发生"的边界情况。它给我的最大启发是:区分"玩具实现"和"生产级实现"的,往往不是"正常情况能不能跑"(两者都能),而是"对'异常、边界、并发、故障'这些情况的处理是否周全"——"happy path(顺利路径)谁都会写, 真正的功力在 unhappy path";那些被忽略的边界(超时、并发、部分失败、对方挂了),正是 bug 和事故的温床。这给了我一种写关键代码时的清醒:实现任何"关键的、要可靠的"机制(锁、事务、状态机、协议)时,不能满足于"正常流程跑通了",而要专门、系统地把"异常和边界路径"列出来逐个考量——"如果超时了?如果对方挂了?如果并发来了?如果中途失败了?如果重复了?";"把功力下在 unhappy path、周全处理异常与边界",是写出生产级可靠代码、而非玩具代码的关键。认清玩具与生产级的差距在异常边界路径、把功力下在 unhappy path——是这个坑带给我的认知。
第五件事:这次事故暴露的"看似简单的东西其实很难"
这次让我反思更深一层:"加把锁"在单机里是件小事,在分布式下却暗藏这么多陷阱。我把"单机锁"和"分布式锁"对比成表。
| 维度 | 单机锁(synchronized/Lock) | 分布式锁 |
|---|---|---|
| 范围 | 一个进程内 | 跨进程/跨机器 |
| 持锁者崩了 | JVM 自动释放 | 要靠过期(否则死锁) |
| 过期 vs 业务时长 | 无此问题 | 核心难题(要续期) |
| 释放 | 简单可靠 | 要防误删(校验所有权) |
| 可靠性 | 语言保证 | 受网络/故障影响, 难100% |
| 本质 | 简单 | 是个分布式共识难题 |
这张表道出了一个认知落差。核心是:"加一把锁"这件事,在单机里简单到我们从不思考(synchronized 一写,JVM 全包了);可一旦搬到分布式环境,它就暴露出底层是一个'多个节点在不可靠网络上对'谁持有锁'达成共识'的难题——过期、续期、所有权、网络分区,每一个都是坑;我栽跟头,正是因为我用"单机锁那么简单"的心态,去对待"分布式锁这个难题"。它给我的深刻启发是:很多概念"名字一样、单机/小规模下简单",但在分布式/大规模/高可靠的语境下,会变成一个完全不同量级的难题——"锁、事务、计数、唯一、缓存、调用一个函数",在分布式下都比单机难一个数量级;"用'简单场景的简单认知'去对待'复杂场景下的同名问题'",会严重低估它的难度、做出脆弱的实现。这给了我一种面对"看似简单"问题的敬畏:当我要在一个"更复杂的环境(分布式/高并发/高可靠)"里实现一个"名字听起来很简单"的功能时,要警惕"它在简单环境里的简单"给我的轻敌,主动去问"在这个复杂环境里, 它真的还简单吗?它隐藏了哪些单机下没有的难题?有没有成熟的方案别自己造?";"对'看似简单却在复杂环境下变难'的问题保持敬畏、借助成熟方案而非轻率自造",是避免在难题上栽跟头的关键。认清看似简单的东西在分布式下是难题、对复杂环境下的同名问题保持敬畏——是这个分布式锁坑带给我的工程态度。
第六件事:要用分布式锁时,我现在的自检习惯
现在每当我要用分布式锁,我都会先按这张图问自己:
这张图的精髓,是"优先用成熟库,手写要带唯一 value+Lua 释放+续期,关键业务再加兜底"。能用库用 Redisson、手写唯一 value+Lua 释放+看门狗续期、关键业务业务层加幂等兜底。这套习惯,让我从"SET NX 抢 DEL 释放就完事"变成了"用成熟库、处理好续期和所有权、再加兜底"——核心始终是:分布式锁优先用 Redisson 等成熟实现,手写要带唯一 value、Lua 原子校验所有权释放、看门狗续期解决持锁超时,关键业务别只靠锁、业务层再加幂等校验兜底。
我立下的几条规矩
这场"加了锁还并发、还删了别人的锁"的事故,换来了我用分布式锁时,刻进骨子里的几条铁律:
- 分布式锁不是"SET NX 抢 + DEL 释放"这么简单。要处理超时、所有权、原子性。
- 锁必须设过期时间(防持锁者崩了死锁)。但要解决"业务超时锁却过期"的矛盾。
- 用看门狗自动续期:持锁期间续期,业务没完锁不过期,完成/崩了才释放。
- 加锁 value 用唯一标识(UUID),释放时用 Lua 校验是自己的才删。防误删别人的锁。
- 抢锁、释放都要原子(SET NX EX 一条命令;校验+删用 Lua)。
- 优先用 Redisson 等成熟实现,别自己造有坑的轮子。
- 分布式锁难 100% 可靠,关键业务在业务层加幂等/唯一约束兜底。
写在最后
回头看,这场由"一个看似简单的分布式锁"引发的、临界区失守的事故,真正教给我的,远不止"用 Redisson、Lua 释放"这几个技巧。它让我对"一个'保护机制', 若它自身的实现有漏洞, 那么它给你的'安全感'就是假的、甚至比没有它更危险——因为你会仗着这份虚假的安全感, 放松了本该有的警惕",有了一次刻骨的体会。我栽跟头,最深的一层,是因为"我加了锁"这个动作,给了我一种强烈的、却是虚假的安全感——我以为"有锁保护了, 临界区就一定是安全的、互斥的",于是我对临界区里可能的并发问题, 彻底放松了警惕,也没在业务层做任何兜底;可那把锁自身是有漏洞的(会过期、会误删),它并没有真正提供它"看起来"提供的保护;于是当锁失效时,既没有锁的真实保护、也没有我因信任锁而省掉的业务层防护——两头落空,问题就这么发生了。这让我领悟到一个关于"保护机制与虚假安全感"的深刻认知:一个"不可靠的保护机制",其危害可能超过"没有保护"——因为"没有保护"时你会小心翼翼、处处设防;而"有一个看起来可靠的保护"时,你会信任它、依赖它、并因此卸下其他防备;一旦这个保护在关键时刻失效,你就处在"毫无防备"的最脆弱状态;"安全带没系好"比"知道没安全带而小心开车"更危险。这给了我一种对待"保护机制"的根本审慎:当我依赖一个"保护/保障机制"(锁、事务、校验、备份、监控、容灾)时,要先严格地确认"它本身是不是真的可靠、它真的提供了我以为的保护吗?",而不要因为"有了它"就盲目地放松警惕——对关键的东西, 既要让保护机制尽量可靠, 也要保留'万一它失效'的兜底(纵深防御);"不被保护机制带来的安全感麻痹、确认其真实可靠性并留好兜底",是避免'因虚假安全感而双重失守'的关键意识。认清不可靠的保护比没有保护更危险、别被安全感麻痹要确认其可靠性并留兜底——这,是我用一次分布式锁失守的事故,换来的、关于分布式系统、也关于如何清醒地依赖保护机制的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次用分布式锁时,用上 Redisson、处理好续期和所有权、并在业务层留个兜底,那我对着那"加了锁还崩坏"的数据排查的这段时间,就值了。
—— 别看了 · 2026