2023 年我负责一个电商的库存扣减功能。逻辑很朴素:用户下单时,先查一下这个商品还剩多少库存,大于零就把库存减一。单机测试、Demo 演示都毫无问题。直到一次大促,运营在后台对账时发现了一件可怕的事:某个爆款商品,系统记录卖出去的数量,比实际库存还多——超卖了。一个标价 100 件库存的商品,系统让 103 个人下单成功了。我第一反应是去看那段扣减代码,它明明就是"查库存、判断、减一",哪来的错?盯着日志复盘很久才想明白:问题不在那三行业务逻辑,而在并发。两个请求几乎同时进来,同时查到库存是 1,同时判断"大于零",于是同时各扣一次——库存变成了 -1,两个人都买到了那"最后一件"。我心想这不简单吗,加个锁不就行了。我先用 Java 的 synchronized 加了锁,本地一测,果然不超卖了。可一上线,超卖照旧。我又懵了。后来才反应过来:我们的服务是多实例部署的,synchronized 只能锁住一个 JVM 进程内部,实例 A 的锁和实例 B 的锁互不相干,跨进程的并发它根本管不着。于是我转向"分布式锁",用 Redis 来做。可接下来这条路上的坑,比我想象的多得多:锁加上了却不是原子的、锁会自己提前过期、我甚至亲手删掉了别的线程的锁……每一个坑都让超卖换一种方式卷土重来。那次之后我才认真把分布式锁这件事从头搞明白。这篇文章就把它梳理一遍:为什么需要分布式锁、怎么加锁才对、锁为什么会提前过期、为什么你会删掉别人的锁,以及把分布式锁真正做稳要避开的那些坑。
问题背景
先把那次事故的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:多实例部署的服务做库存扣减,高并发下出现超卖——系统卖出的数量超过了实际库存。用 synchronized 加锁后本地不超卖,上线后超卖照旧。改用 Redis 分布式锁,又接连出现锁不原子、锁提前过期、误删他人锁等问题。
我当时的错误认知:"并发问题加把锁就解决了;分布式环境就把锁换成 Redis 的 SETNX,加上、用完删掉,就这么简单。"
真相:分布式锁真正的难点,根本不在"加锁"那一下。SETNX 谁都会写。难的是锁的整个生命周期:加锁和设过期时间必须是原子的、锁的过期时间和业务耗时根本对不齐、释放锁时你怎么确认删的是自己那把。一把分布式锁的正确性,是由"加锁、持有、续期、释放"这四个环节共同保证的,任何一环偷懒,互斥就会在某个并发时刻悄悄失效。
要把分布式锁做稳,需要几块认知:
- 为什么单机锁(
synchronized)在多实例下必然失效; - 为什么"
SETNX占位 +EXPIRE设过期"这两步会出大事; - 锁为什么会在业务还没干完时就提前过期,看门狗怎么救;
- 为什么你会在不知情的情况下删掉别的线程的锁;
- 可重入、锁粒度、Redis 单点这些工程坑怎么处理。
一、为什么需要分布式锁:单机锁跨不过进程边界
先看清我第一版用 synchronized 为什么注定失败,这决定了后面所有方案的方向。
synchronized(以及 ReentrantLock 这些)是JVM 进程内的锁。它的工作原理,是在当前这个 Java 进程的内存里,对一个对象做标记——同一个进程里的多个线程来抢,它能保证只有一个抢到。注意"同一个进程"这个前提:
// 反面教材:用 JVM 的 synchronized 给库存扣减加锁。
public synchronized void deductStock(Long skuId) {
int stock = stockMapper.selectStock(skuId); // 查库存
if (stock > 0) {
stockMapper.updateStock(skuId, stock - 1); // 减一
}
}
// 问题:synchronized 锁的是"当前这个 JVM 进程内"的对象。
// 服务一旦多实例部署(实例 A、实例 B 各是一个独立的 JVM 进程),
// A 的锁和 B 的锁是两个毫不相干的东西 ——
// 实例 A 的某个线程和实例 B 的某个线程,可以同时进入这段代码。超卖照旧。
现代的后端服务,为了扛流量、为了高可用,几乎都是多实例部署的:同一份代码,起好几个进程,可能还分布在好几台机器上,前面用负载均衡把请求分摊过来。这个架构下,"同一个进程"这个前提就被打破了——一个用户的请求落到实例 A,另一个落到实例 B,它们是两个独立的 JVM,各自的 synchronized 拦不住对方。
所以我们需要的,是一把所有实例都能看见、都来这里抢、谁抢到了别人就一定知道的锁。这把锁不能放在任何一个进程的内存里,它必须放在一个所有进程共享的、外部的地方。这个"外部的地方",最常见的选择就是 Redis——它本身就是个所有实例共享的内存存储,而且单线程执行命令,天然适合做这种"抢占"。这就是分布式锁的出发点。
二、加锁必须原子:SETNX + EXPIRE 的陷阱
用 Redis 做锁,核心思路是"占坑":往 Redis 里写一个约定好的 key,写成功就代表抢到了锁,别人再写同一个 key 会失败。Redis 的 SETNX(SET if Not eXists,不存在才设置)正是干这个的。
但这里立刻有个问题:如果只是 SETNX 写了个 key,那持有锁的进程万一崩溃了,这个 key 就永远留在 Redis 里,锁永远不会释放,后面所有人都卡死——这叫死锁。所以锁必须有过期时间作兜底:就算持有者崩了,过一会儿锁也能自动消失。于是很自然地,我写出了下面这版"先占位、再设过期":
// 反面教材:加锁分两步 —— 先 setnx 占位,再单独 expire 设过期。
public boolean tryLockBad(String key) {
Long ok = jedis.setnx(key, "1"); // 第一步:占位,成功返回 1
if (ok == 1) {
jedis.expire(key, 30); // 第二步:给它设 30 秒过期
return true;
}
return false;
}
// 致命问题:这两步不是一个原子操作。如果进程恰好在
// "setnx 已成功、expire 还没来得及执行"的那一瞬间崩溃,
// 这把锁就变成了一把没有过期时间的锁 —— 它会永远占着,
// 谁也拿不到,谁也清不掉。死锁。
这个 bug 极其隐蔽:两步之间的窗口只有几微秒,平时跑一百万次都不会出事,可一旦在那个窗口里赶上进程崩溃、被 kill、或 GC 长暂停,就会留下一把永久死锁。它和上一篇缓存里"SETNX 和 EXPIRE 分两步"是同一类病根:两个本该一起成功或一起失败的操作,被拆成了两步。
正确的做法是把"占位"和"设过期"合并成一条命令。Redis 的 SET 命令支持 NX 和 PX 参数,一条命令同时完成两件事,而单条 Redis 命令的执行是原子的:
// 正确做法:用一条 SET 命令同时完成"占位 + 设过期",保证原子性。
// NX:仅当 key 不存在时才设置 —— 这实现了互斥
// PX:设置过期时间(毫秒)—— 它和占位是同一条命令、同一个原子操作
public boolean tryLock(String key, String token, long ttlMillis) {
SetParams params = new SetParams().nx().px(ttlMillis);
String result = jedis.set(key, token, params);
return "OK".equals(result); // 返回 OK 表示这条 SET 生效了,抢锁成功
}
注意这里我没有像反面教材那样写一个固定的 "1" 当 value,而是传了一个 token 参数。这个 token 是什么、为什么必须是它,是下一节的主题——它正是解决"误删别人锁"那个坑的关键。
三、你删了别人的锁:owner 校验与 Lua 原子释放
加锁解决了,接着是释放。释放锁,直觉上不就是把那个 key 删掉吗?
// 反面教材:释放锁时,直接把 key 删掉。
public void unlockBad(String key) {
jedis.del(key); // 直接删除,简单粗暴
}
这行 del 藏着分布式锁里最隐蔽的一个坑。我们顺着一个时间线看:线程 A 抢到了锁,锁的过期时间是 30 秒;但 A 的业务跑得慢,跑了 35 秒——在第 30 秒,A 的锁自动过期了;第 31 秒,线程 B 来抢,key 已经不存在,B 成功抢到了锁,开始干自己的活;第 35 秒,A 终于跑完了,它老老实实地执行 jedis.del(key)——可它删掉的,是 B 的锁!于是第 36 秒,线程 C 又能抢到锁了,此刻 B 和 C 同时持有锁。互斥,就在这一连串"看似合理"的步骤里彻底失效了。
问题的根源是:del 这个动作,分不清这把锁到底是不是自己的。要解决它,就得让每把锁带上"主人是谁"的标记——这就是上一节那个 token 的用途:加锁时,每个线程生成一个全局唯一的 token(比如 UUID)写进 value;释放锁时,先读出 value、确认它等于自己的 token,才删。
但"先读、再比、再删"又是三步——如果在"比对通过"和"执行删除"之间锁恰好过期、被别人抢走,你还是会删掉别人的锁。这三步也必须打包成一个原子操作。Redis 给的工具是 Lua 脚本:Redis 会把整段 Lua 脚本作为一个不可分割的整体来执行,中途绝不会插进别的命令。下面是一个把这些都做对的完整锁实现:
public class RedisLock {
private final Jedis jedis;
// Lua 脚本:先比对 value 是不是自己的 token,是才删 —— 整段原子执行
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 RedisLock(Jedis jedis) { this.jedis = jedis; }
public String tryLock(String key, long ttlMillis) {
// token 用全局唯一值,作为"这把锁属于谁"的凭证
String token = UUID.randomUUID().toString();
SetParams params = new SetParams().nx().px(ttlMillis);
boolean ok = "OK".equals(jedis.set(key, token, params));
return ok ? token : null; // 抢到锁,返回 token;没抢到返回 null
}
public boolean unlock(String key, String token) {
// 把"判断 owner + 删除"交给 Lua,一个原子操作完成,杜绝误删
Object r = jedis.eval(UNLOCK_LUA,
Collections.singletonList(key),
Collections.singletonList(token));
return Long.valueOf(1).equals(r);
}
}
用的时候,tryLock 返回的 token 一定要保存好,释放时原样传回 unlock,并且释放动作务必放在 finally 里——否则业务一抛异常,锁就只能傻等过期了。但你可能已经发现:这套方案里还有个没解决的问题,就是上面那个时间线的起点——"A 的业务跑了 35 秒,锁 30 秒就过期了"。锁提前过期本身,就是个大麻烦。
四、锁会提前过期:看门狗自动续期
上一节那个误删的时间线,真正的起因是一个我们无力回答的问题:锁的过期时间该设多久?
设短了,业务还没干完锁就过期了,别的线程趁虚而入,互斥失效;设长了,万一持有锁的进程真的崩了,这把锁要白白占用很久,期间谁也别想干活。更要命的是,业务的真实耗时根本没法预估——平时一次库存扣减 50 毫秒,但赶上数据库慢查询、网络抖动、GC 长暂停,它可能突然要跑 5 秒、10 秒。你设任何一个固定值,都是在赌"业务一定在这个时间内跑完",而这个赌注迟早会输。
业界对这个两难的标准解法,是看门狗(watchdog)机制。思路很巧:加锁时先给一个不长的过期时间(比如 30 秒);同时,起一个后台线程,每隔一小段时间(通常是过期时间的 1/3,即 10 秒)就去检查——只要业务还在跑,就把锁的过期时间重新延长回 30 秒。这样一来:业务正常跑着,锁就被这条后台线程一次次"续命",永不过期;而一旦持有锁的进程崩溃,这条后台线程也跟着没了,再没人续期,锁就会在最多 30 秒后自然过期——死锁兜底依然成立。它同时拿到了"锁不会提前过期"和"崩溃能自动释放"两个好处。
// 看门狗:加锁后起一个后台线程,定期把锁的过期时间往后延。
// 业务没跑完就一直续,业务跑完(或进程崩了)就停止续 —— 锁自然到期。
public class LockWatchdog {
private final ScheduledExecutorService scheduler =
Executors.newSingleThreadScheduledExecutor();
// 续期脚本:同样要先校验 owner —— 只续"自己还持有的"那把锁
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 ScheduledFuture<?> start(Jedis jedis, String key,
String token, long ttlMillis) {
// 每过 ttl 的 1/3 续一次:留足提前量,别等锁快过期了才动手
return scheduler.scheduleAtFixedRate(
() -> jedis.eval(RENEW_LUA,
Collections.singletonList(key),
Arrays.asList(token, String.valueOf(ttlMillis))),
ttlMillis / 3, ttlMillis / 3, TimeUnit.MILLISECONDS);
}
}
// 业务结束、释放锁时,记得 future.cancel(true) 停掉这条续期线程。
看门狗也不是没有代价:如果持有锁的进程没崩、但卡死了(比如陷入死循环),看门狗会一直忠实地续期,这把锁就真的永远不会释放了。所以实践中通常还会再加一道保险——给锁一个"绝对持有上限",续期最多续到某个总时长就不再续。但无论如何,看门狗已经把"固定过期时间"这个最大的坑填上了。到这里,一把正确的分布式锁需要的所有零件——原子加锁、owner 校验、Lua 释放、自动续期——都凑齐了。
五、别自己造轮子:用 Redisson
看完前面四节你应该有个感受:一把正确的分布式锁,要操心的细节多得吓人。原子加锁、唯一 token、Lua 原子释放、看门狗续期、续期里还要再校验 owner……自己手写,极容易在某个角落留下 bug。
好消息是,这些坑前人早就踩遍并封装好了。Java 生态里,Redisson 就是事实标准——它是一个基于 Redis 的工具库,提供的 RLock 把上面所有细节全部包在了内部。你只管像用 JDK 的锁一样用它:
// 生产环境别自己造轮子:Redisson 把前面所有坑都封装好了。
RedissonClient redisson = Redisson.create(config);
public void deductStock(Long skuId) {
RLock lock = redisson.getLock("stock:lock:" + skuId);
// lock() 内部已包含:原子加锁 + 唯一 token + 看门狗自动续期
lock.lock();
try {
int stock = stockMapper.selectStock(skuId);
if (stock > 0) {
stockMapper.updateStock(skuId, stock - 1);
}
} finally {
// unlock() 内部:校验 owner + Lua 原子释放,绝不会误删别人的锁
lock.unlock();
}
}
Redisson 还顺手解决了一个我们前面一直没提的问题:可重入。设想方法 A 加了锁,在锁里又调用了方法 B,而 B 内部也想加同一把锁——如果是裸 SETNX 实现,B 会发现 key 已存在而拿不到锁,于是当前线程把自己锁死了。可重入锁能识别"来加锁的还是同一个线程",允许它重复加锁,内部用一个计数器记录加了几层,解锁时减到零才真正释放:
// Redisson 的锁是可重入的:同一线程可重复 lock 同一把锁,不会自己锁死自己。
public void outer(Long skuId) {
RLock lock = redisson.getLock("stock:lock:" + skuId);
lock.lock(); // 第 1 次加锁,重入计数:0 -> 1
try {
inner(skuId); // 调用内层方法,它还会再 lock 同一把锁
} finally {
lock.unlock(); // 重入计数:1 -> 0,锁此刻才真正释放
}
}
private void inner(Long skuId) {
RLock lock = redisson.getLock("stock:lock:" + skuId);
lock.lock(); // 同一线程再次加锁,重入计数:1 -> 2
try {
// ... 内层业务逻辑 ...
} finally {
lock.unlock(); // 重入计数:2 -> 1,锁还没释放
}
}
// 若用裸 SETNX 自己实现,inner 会因为 key 已存在而拿不到锁 —— 线程自我死锁。
结论很直接:除非是学习原理,生产环境请直接用 Redisson 这类成熟方案。前面四节手写代码的价值,不在于让你照抄,而在于让你看懂 Redisson 内部到底在替你操心什么——这样它出问题时你才排查得动。
六、工程坑:锁粒度、加锁失败兜底、Redis 单点
锁本身做对了,但要把它真正用进生产,还有几个绕不开的工程坑。
坑 1:锁粒度要尽量细。一个常见错误是用一把"大锁"锁住所有商品的库存扣减——那意味着商品 X 的下单和商品 Y 的下单会互相排队,完全不相干的请求被硬生生串行化,吞吐量被锁死。正确的做法是按业务键加锁:锁的 key 里带上 skuId(像前面代码里的 "stock:lock:" + skuId),不同商品用不同的锁,互不阻塞。锁的范围越小,并发能力越强。
坑 2:抢不到锁,要有兜底,不能无限等。高并发下,大量请求会抢同一把锁,抢不到的怎么办?如果让它们都无限期阻塞等待,请求会越堆越多,最终拖垮整个服务。务必给加锁设一个等待超时:在限定时间内没抢到,就快速失败、返回一个友好的兜底结果(对秒杀就是"系统繁忙,请重试"),绝不让请求无止境地堆积:
// 工程实践:加锁要设"等待超时",抢不到就快速失败,别让请求无限堆积。
public boolean deductWithTimeout(Long skuId) throws InterruptedException {
RLock lock = redisson.getLock("stock:lock:" + skuId);
// 最多花 3 秒去抢锁;抢到后锁的持有上限 10 秒(兜底,防看门狗失效)
boolean got = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!got) {
// 没抢到:直接返回"系统繁忙",把请求快速放掉,不堆积
return false;
}
try {
int stock = stockMapper.selectStock(skuId);
if (stock <= 0) return false;
stockMapper.updateStock(skuId, stock - 1);
return true;
} finally {
lock.unlock();
}
}
坑 3:Redis 本身是单点。我们把锁放在 Redis 上,可如果这台 Redis 挂了呢?锁服务就整个没了。生产环境的 Redis 至少要主从加哨兵。但这里还藏着一个更微妙的问题:Redis 主从是异步复制的——线程 A 在主节点上加锁成功,主节点还没来得及把这条数据同步给从节点就宕机了,从节点被哨兵提升为新主,而新主上根本没有这把锁;于是线程 B 来加锁,也成功了——两个线程同时持有了"同一把"锁。
坑 4:Redlock 与它的争议。为了解决上面这个主从切换丢锁的问题,Redis 作者提出过 Redlock 算法:部署多个独立的 Redis 节点(不是主从),加锁时要往大多数节点(比如 5 个里的 3 个)都加成功才算数。但 Redlock 在分布式领域是有争议的——有专家指出它在时钟漂移、GC 暂停等极端情况下依然不能保证绝对安全。这里你需要记住的结论是:没有 100% 完美的分布式锁。对绝大多数业务,Redisson + 主从哨兵已经足够;但如果你的场景绝对不能容忍哪怕极小概率的互斥失效(比如涉及资金),那正确的做法不是去迷信某个更复杂的锁,而是在最终的数据落地处再加一道防线——比如给库存扣减的 SQL 加上 WHERE stock > 0 的乐观条件,让数据库本身成为最后一道不会骗人的关卡。
下面这张图,把一把分布式锁从抢占、续期到释放的完整生命周期串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 单机锁的局限 | synchronized 只锁单个 JVM 进程,多实例部署下完全失效 |
| 分布式锁 | 把锁放在 Redis 等所有实例共享的外部存储,实现跨进程互斥 |
| SET NX PX | 一条命令原子完成"占位 + 设过期",取代不原子的 SETNX+EXPIRE |
| 过期时间兜底 | 锁必须设过期,否则持有者崩溃会留下永久死锁 |
| owner token | 每把锁写入唯一 token 标记主人,释放前校验,防误删他人锁 |
| Lua 原子释放 | 把"判断 owner + 删除"打包成 Lua 脚本,整段原子执行 |
| 看门狗续期 | 后台线程定期延长过期时间,业务没完就续、进程崩了自然过期 |
| 可重入 | 同一线程可重复加同一把锁,用计数器记层数,防自我死锁 |
| 锁粒度 | 按业务键(如 skuId)加锁,不同对象用不同锁,别用一把大锁 |
| Redlock 争议 | 多节点加锁算法,仍不保证绝对安全;关键业务要在数据层再兜底 |
避坑清单
- synchronized、ReentrantLock 只在单个 JVM 进程内有效,服务多实例部署后必然拦不住跨进程并发。
- 分布式锁要放在所有实例共享的外部存储(如 Redis),"写 key 成功"即"抢锁成功"。
- 锁必须带过期时间兜底,否则持有者崩溃会留下永久死锁。
- 加锁的"占位"和"设过期"必须原子完成,用 SET key token NX PX 一条命令,别用 SETNX + EXPIRE 两步。
- 释放锁前必须校验 owner:每把锁写入唯一 token,只删 value 等于自己 token 的锁,否则会误删别人的锁。
- "判断 owner + 删除"要用 Lua 脚本原子执行,否则判断和删除之间的窗口仍会误删。
- 固定过期时间无法兼顾"业务跑完"和"崩溃释放",用看门狗后台线程定期续期来破解这个两难。
- 锁要可重入,否则方法嵌套加同一把锁会让线程自我死锁;生产环境直接用 Redisson 等成熟方案。
- 锁粒度要细,按业务键加锁,别用一把大锁串行化所有不相干请求;抢不到锁要设等待超时并快速失败兜底。
- Redis 主从异步复制 + 故障切换可能丢锁;没有 100% 完美的分布式锁,关键业务要在数据层(如 WHERE stock > 0)再加一道防线。
总结
回头看那次超卖事故,以及后面我在分布式锁这条路上接连踩的坑,最该记住的不是某一段 Lua 脚本,而是我动手前那个想当然的判断——"并发问题加把锁就解决了"。这句话错在它把"锁"理解成了一个一加上就生效的开关。可一把分布式锁根本不是开关,它是一个有生命周期的东西:它要被原子地创建、要在持有期间被妥善照看、要在不确定的耗时里被持续续命、要被它真正的主人安全地销毁。这中间任何一个环节你没照看到,它就会在某个并发的瞬间悄悄失效——而失效的锁,比没有锁更危险,因为它会让你误以为自己有保护。
所以做分布式锁,真正的工程量根本不在 SETNX 那一下。那行命令谁都会写,Demo 里它也确实能挡住并发。真正的工程量在加锁之后:这把锁会不会因为我没原子地设过期而变成永久死锁?会不会因为业务跑太久而提前过期、让别人趁虚而入?我释放它的时候,删的到底是不是我自己那一把?这篇文章的几节,其实就是顺着一把锁的生命周期展开的:先认清单机锁为什么跨不过进程边界;再看加锁为什么必须原子;然后是它会提前过期、会被误删这两个最隐蔽的坑;接着是看门狗续期、用 Redisson 这样的成熟方案;最后是锁粒度、兜底、单点这些工程细节。
你会发现,这套思路和我们处理任何"共享资源"的工程经验是相通的。我们不相信一个写操作一定原子,所以有事务;不相信一个连接一定健康,所以有探活;不相信一次调用一定成功,所以有重试和超时。分布式锁只是把这种"不轻易相信"用在了"互斥"这件事上——你不能相信锁加上了就一定互斥,你得追问它在崩溃时、在超时时、在主从切换时分别会怎样,并为每一种"不互斥"的可能准备好应对。也正因为没有 100% 完美的锁,真正稳的系统从不把宝全押在锁上,而是在最终的数据落地处再留一道关卡。
最后想说,分布式锁做没做对,差距永远不会在 Demo 里暴露——Demo 的并发量不够、进程不会崩、业务跑得飞快、Redis 也不会故障切换,怎么写都不超卖。它只在真实的大促流量、真实的进程崩溃、真实的慢查询、真实的主从切换面前才显形。那时候它会用最难看的方式给你结账:一个标价 100 件的商品,系统让 103 个人付了款。所以别等运营拿着对不上的账目来找你,在你写下第一行 jedis.set 的时候就该想清楚:这把锁原子吗?它会提前过期吗?我释放的是我自己的锁吗?它背后的 Redis 倒下时会怎样?这几个问题都有了答案,你的分布式锁才不只是 Demo 里那个看起来很安心的开关,而是一道在洪峰、在崩溃、在故障切换面前都真正守得住的互斥防线。
—— 别看了 · 2026