我扣库存的逻辑是"先查出当前库存、减一、再写回",平时好好的,一上量就超卖、库存对不上账:一次数据库并发更新丢失的深度复盘
那个超卖是搞活动放量后才炸的:我有个扣库存的逻辑,写得很"直白"——先 SELECT stock FROM product WHERE id=1 查出当前库存,在代码里 stock - 1,再 UPDATE product SET stock=? WHERE id=1 把新值写回去。平时流量小,一直没问题;可活动一放量、并发一高,就出事了:商品明明只有 100 件,却卖出去了 100 多件(超卖);对账时发现库存扣减的数量,和实际卖出的订单数对不上。我对着这个"越并发越不准"的现象,在纸上推演了两个并发请求,才看明白,后背发凉:问题出在我"先查、再算、后写回"(read-modify-write)这个非原子的操作上,它在并发下会"更新丢失(lost update)"。设想两个并发请求 A 和 B 同时来扣库存(当前 stock=100):A 查出 stock=100,B 也查出 stock=100(它俩几乎同时查,都读到了 100);A 算出 100-1=99,写回 99;B 也算出 100-1=99,写回 99;结果:两个请求各扣了一件、本该 stock=98,可数据库里却是 99——B 的写,把 A 已经扣过的那一件"覆盖丢失"了;也就是说,两次扣减,只在库存上体现了一次,凭空多出了一件可卖的库存,并发越高、这种丢失越多,就超卖了。根本原因是:"读-改-写"这三步之间没有任何保护,两个并发事务的读都读到了"旧值",各自基于旧值算完再写回,后写的覆盖了先写的。问题的根,是用"先查再算后写回"扣库存:这三步非原子,并发下两个事务读到同一个旧值、各自算完写回,后者覆盖前者,导致更新丢失、超卖。这篇就把这次"并发更新丢失"的坑,从头到尾复盘一遍。
故障现场:先查、减一、再写回,并发下丢更新
问题在于"读-改-写"非原子,并发下两个事务读到同一旧值、后写覆盖先写:
// ✗ 出问题的扣库存: 先查出库存, 代码里减1, 再写回
public void deductStock(long productId) {
// 1. 查出当前库存
int stock = db.queryInt("SELECT stock FROM product WHERE id=?", productId);
if (stock <= 0) throw new RuntimeException("库存不足");
// 2. 代码里算新值
int newStock = stock - 1;
// 3. 写回
db.update("UPDATE product SET stock=? WHERE id=?", newStock, productId);
}
// 并发竞态推演(两个并发请求 A、B, 当前 stock=100):
// A: SELECT → 读到 stock=100
// B: SELECT → 读到 stock=100 (A还没写回, B也读到旧值100)
// A: 算 100-1=99, UPDATE stock=99
// B: 算 100-1=99, UPDATE stock=99 ← B把99又写了一遍, 覆盖了A的扣减!
// 结果: 扣了两次, 库存却只从100变到99(本该98) → 凭空多了1件可卖 → 超卖。
// 为什么? "读-改-写"(read-modify-write)三步非原子:
// - "读"和"写"之间有时间差; 并发的A、B都在这个时间差里读到了【同一个旧值100】;
// - 它们各自基于这个旧值算新值(都算成99), 再写回;
// - → 后写的(B)覆盖了先写的(A)的结果 → A的那次扣减"丢失"了 → 这叫"更新丢失(lost update)"。
// 错误的"修补": 加个 WHERE stock=? 也不够稳, 或以为加事务就行——
// 注意: 仅仅把这三步包进一个事务, 在常见的"读已提交(RC)"隔离级别下, 【并不能】阻止这种更新丢失!
// 因为RC下两个事务的SELECT仍可能都读到旧值(普通SELECT不加锁)。
// 关键: "先查再算后写回"的读-改-写非原子, 并发下多个事务读到同一旧值、各自算完写回, 后者覆盖前者,
// 导致更新丢失、库存扣减不准、超卖 —— 并发修改同一数据, 不能用"查出来改了再写回"。
第一次在纸上推演出"A、B 都读到 100,都写回 99,A 的扣减被覆盖了"时,我又懊恼又警醒:"我以为'查出来、减一、写回去'天经地义,完全没想到在并发下,两个请求会读到同一个旧值、然后互相覆盖,凭空把库存'变多'了。"这个坑最隐蔽的地方在于:它只在并发(高流量)下才发作,低流量、单元测试时完全正常(请求都是串行的,读到的总是最新值);只有放量、并发起来,"读和写之间的时间差"被多个请求挤进来,才暴露;而且超卖了数据库也不报错,只是数据悄悄不对,直到对账才发现。下面就来拆解,并发扣库存到底该怎么写。
第一件事:搞懂并发下的"更新丢失"
我顺着这次事故,把并发更新丢失的成因和几种解法彻底理清了。
并发"更新丢失(lost update)"是怎么发生的? 怎么避免?
【核心: "读-改-写"非原子, 并发事务读到同一旧值各自算完写回、后者覆盖前者; 解法: 原子UPDATE/乐观锁/悲观锁, 把"读改写"变原子或串行】
1. 更新丢失的本质: 读-改-写 非原子
- 一个"基于当前值算新值"的更新(库存-1、余额+x、计数++), 要经历"读当前值→算新值→写回"三步;
- 这三步【不是原子的】, "读"和"写"之间有时间窗口;
- 并发的多个请求都在这个窗口里读到【同一个旧值】, 各自算完写回 → 后写的覆盖先写的 → 丢失了中间的更新。
2. 为什么"包进事务"不一定够:
- 在"读已提交(RC, MySQL默认是RR但很多场景表现类似)"等隔离级别下, 普通SELECT不加锁;
- 两个事务的SELECT仍可能都读到旧值 → 事务隔离≠自动解决更新丢失;
- (RR可重复读也不直接防这种"读后写"的丢失, 除非用加锁读。)
3. 解法一: 原子UPDATE(最简单, 推荐) —— 把"改"交给数据库一步原子做
- 不"查出来再算", 而是 UPDATE ... SET stock = stock - 1 WHERE id=? AND stock > 0;
- 数据库执行这条UPDATE时, 对该行加锁、读取-计算-写入是【原子】的, 并发会被串行化处理;
- WHERE stock>0 保证不会扣成负数; 看影响行数判断是否扣成功。
4. 解法二: 乐观锁(version/CAS) —— 适合冲突不频繁
- 加version字段; UPDATE ... SET stock=newStock, version=version+1 WHERE id=? AND version=旧version;
- 若影响行数为0, 说明version被别人改过(数据变了) → 重试(重新读最新值再试);
- "乐观"地假设冲突少, 冲突了才重试。
5. 解法三: 悲观锁(SELECT ... FOR UPDATE) —— 适合冲突频繁
- 在事务里 SELECT stock FROM product WHERE id=? FOR UPDATE → 给该行加排他锁;
- 其他事务的FOR UPDATE会阻塞等待 → 把并发的"读改写"串行化;
- 算完写回、提交事务释放锁; "悲观"地认为冲突多, 先锁住。
一句话: 更新丢失源于"读-改-写"非原子、并发读到同一旧值后写覆盖前写; 别"查出来改了再写回",
改用原子UPDATE(stock=stock-1 WHERE)、乐观锁(version CAS+重试)或悲观锁(FOR UPDATE), 把读改写变原子或串行。
这套认知,是整个坑的根。更新丢失的本质:读-改-写非原子——"基于当前值算新值"的更新要经历"读→算→写回"三步,这三步非原子、读和写之间有时间窗口,并发请求都在窗口里读到同一旧值、各自算完写回,后写覆盖先写。为什么包进事务不一定够:RC 等隔离级别下普通 SELECT 不加锁,两个事务的 SELECT 仍可能都读到旧值;事务隔离≠自动解决更新丢失。解法一:原子 UPDATE(推荐)——UPDATE ... SET stock = stock - 1 WHERE id=? AND stock > 0,数据库对该行加锁、读取-计算-写入原子完成、并发被串行化,WHERE stock>0 防扣负。解法二:乐观锁(version/CAS)——加 version 字段,UPDATE ... WHERE version=旧version,影响行数为 0 说明被改过就重试(适合冲突少)。解法三:悲观锁(SELECT ... FOR UPDATE)——给该行加排他锁、把并发读改写串行化(适合冲突多)。一句话:更新丢失源于"读-改-写"非原子、并发读到同一旧值后写覆盖前写;别"查出来改了再写回",改用原子 UPDATE、乐观锁或悲观锁,把读改写变原子或串行。
第二件事:正解——原子 UPDATE / 乐观锁 / 悲观锁
搞懂了原理,正解就清晰了:扣库存别"查出来减了再写回",改用原子的 UPDATE stock=stock-1 WHERE stock>0(首选);冲突少用乐观锁(version),冲突多用悲观锁(FOR UPDATE)。
// ====== 正解一: 原子 UPDATE(最简单、推荐) ======
public boolean deductStock(long productId) {
// 把"读-改-写"交给数据库一条原子UPDATE; WHERE stock>0 保证不超卖
int affected = db.update(
"UPDATE product SET stock = stock - 1 WHERE id = ? AND stock > 0",
productId);
return affected > 0; // 影响1行=扣成功; 0行=库存不足(stock<=0)没扣到
}
// → 这条UPDATE在数据库内部对该行加锁, "读当前stock-计算-写回"是原子的;
// 并发的多个UPDATE被数据库串行化处理, 不会丢更新; WHERE stock>0 兜住, 绝不扣成负数。
// ====== 正解二: 乐观锁(version字段, 适合冲突不频繁) ======
public boolean deductStockOptimistic(long productId) {
for (int retry = 0; retry < 3; retry++) { // 失败重试几次
Product p = db.queryProduct(productId); // 读出当前stock和version
if (p.stock <= 0) return false;
int affected = db.update(
"UPDATE product SET stock = ?, version = version + 1 " +
"WHERE id = ? AND version = ?", // ★ 带上读到的version做CAS
p.stock - 1, productId, p.version);
if (affected > 0) return true; // 成功
// affected=0: version被别人改过 → 数据变了 → 循环重试(重新读最新)
}
return false; // 重试多次仍冲突
}
-- ====== 正解三: 悲观锁(FOR UPDATE, 适合冲突频繁/逻辑复杂) ======
-- 在一个事务里:
BEGIN;
SELECT stock FROM product WHERE id = 1 FOR UPDATE; -- ★ 给该行加排他锁, 其他事务的FOR UPDATE阻塞等待
-- ... 在代码里判断、计算 ...
UPDATE product SET stock = stock - 1 WHERE id = 1;
COMMIT; -- 提交释放锁
-- → FOR UPDATE把并发的"读改写"串行化: 一个事务锁住该行, 别人等它提交才能继续。
-- ====== 选型建议 ======
-- 1. 简单的"基于当前值的增减"(库存/计数/余额): 首选【原子UPDATE】(stock=stock-1 WHERE), 最简单高效;
-- 2. 读改写中间逻辑复杂、但冲突不频繁: 乐观锁(version + 失败重试);
-- 3. 冲突频繁、或必须串行处理: 悲观锁(FOR UPDATE), 注意锁范围别太大(影响并发);
-- 4. 极高并发(秒杀): 往往还要前置(Redis预扣/限流/队列削峰), 别让所有请求都打到DB抢一行锁。
修复的核心,是"别查出来改了再写回,用原子 UPDATE 或加锁"。正解一:原子 UPDATE(推荐)——UPDATE product SET stock=stock-1 WHERE id=? AND stock>0,数据库内部对该行原子地读-算-写、并发被串行化,WHERE stock>0 绝不扣负,看影响行数判断成功。正解二:乐观锁(version+CAS+重试)——WHERE version=旧version,影响 0 行说明被改过就重试(冲突少时用)。正解三:悲观锁(FOR UPDATE)——给该行加排他锁串行化(冲突多时用,注意锁范围)。选型:简单增减首选原子 UPDATE、复杂逻辑冲突少用乐观锁、冲突多用悲观锁、极高并发还要前置 Redis 预扣/限流/队列削峰。归根结底:别"查出来改了再写回",用原子 UPDATE(stock=stock-1 WHERE)、乐观锁(version CAS+重试)或悲观锁(FOR UPDATE),把读改写变原子或串行。
第三件事:数据库并发与一致性的其他常见坑
排查后我把数据库并发、一致性相关的其他坑也系统梳理了一遍。
数据库并发与一致性的其他常见坑
# 1. 读改写更新丢失(本文): 并发读旧值后写覆盖。→ 原子UPDATE/乐观锁/悲观锁。
# 2. 以为"包进事务"就并发安全: 隔离级别下普通SELECT不加锁仍丢更新。→ 用加锁读或原子写。
# 3. 唯一索引并发插入: "先查不存在再插入"并发下重复插入。→ 唯一索引+冲突处理(ON DUPLICATE/INSERT IGNORE)。
# 4. 死锁: 多事务以不同顺序加锁互相等。→ 统一加锁顺序, 减小事务/锁范围。
# 5. 大事务/长事务: 锁持有久、回滚段大、阻塞别人。→ 事务尽量短小。
# 6. SELECT FOR UPDATE锁范围过大: 锁了过多行甚至间隙锁, 拖垮并发。→ 精确锁需要的行、走索引。
# 7. 扣减不判边界: 没有 WHERE stock>0, 扣成负数。→ 加边界条件。
# 8. 隔离级别理解错: RC/RR/幻读/不可重复读各自能防什么没搞清。→ 理解隔离级别。
# 共同根源: 数据库要在"多个事务并发访问同一数据"时保证正确, 这本质是个并发控制问题;
# 而很多"单线程下天经地义"的写法(查出来改了再写回、查不到再插入), 在并发下都会因为"时间差里别人也动了"而出错。
# 核心: 凡是"并发修改同一数据"的场景, 别用"单线程直觉"(读改写/先查后插); 用数据库提供的并发控制手段——
# 原子写、唯一约束、乐观/悲观锁; 让对同一数据的并发操作, 要么原子、要么串行、要么能检测冲突。
排查让我把数据库并发与一致性的其他坑也梳理清了。一、读改写更新丢失(本文)。二、以为包进事务就安全。三、唯一索引并发插入(先查再插重复)。四、死锁。五、大事务/长事务。六、FOR UPDATE 锁范围过大。七、扣减不判边界扣成负数。八、隔离级别理解错。它们的共同根源是:数据库要在"多个事务并发访问同一数据"时保证正确,这本质是个并发控制问题;而很多"单线程下天经地义"的写法(查出来改了再写回、查不到再插入),在并发下都会因为"时间差里别人也动了"而出错。核心是:凡是"并发修改同一数据"的场景,别用"单线程直觉"(读改写/先查后插);用数据库提供的并发控制手段——原子写、唯一约束、乐观/悲观锁;让对同一数据的并发操作,要么原子、要么串行、要么能检测冲突。下面这张图,是这次更新丢失坑的成因与解法:
第四件事:三种并发控制方案对比表
这次踩坑后,我把原子 UPDATE、乐观锁、悲观锁三种方案对比成一张表。
| 方案 | 原理 | 适合 | 代价 |
|---|---|---|---|
| 原子 UPDATE(stock=stock-1) | 数据库一步原子读改写 | 简单增减(库存/计数) | 仅适合能用一条SQL表达的 |
| 乐观锁(version CAS) | 假设冲突少, 写时校验+重试 | 冲突不频繁、逻辑复杂 | 冲突多时重试开销大 |
| 悲观锁(FOR UPDATE) | 先锁住, 串行处理 | 冲突频繁、必须串行 | 锁等待、并发下降 |
这张表把三种方案钉清了。核心是:三种方案的本质,是应对"并发修改同一数据"的三种不同策略——原子 UPDATE 是"让操作小到能一步原子完成";乐观锁是"先假设没冲突、干完再检查、错了重来";悲观锁是"先假设有冲突、上来就锁住、独占着干";它们对应着对"冲突概率"的不同假设(乐观=冲突少、悲观=冲突多),也是"重试成本"和"等待成本"之间的权衡。它给我的最大启发是:处理"共享资源的并发访问"(无论是数据库的行、内存的变量、还是分布式的资源),都绕不开这几种基本范式——"原子操作"(把操作做到不可分割)、"乐观并发控制"(冲突少时,无锁+检测+重试)、"悲观并发控制"(冲突多时,加锁+独占);没有哪个绝对最好,要根据"冲突有多频繁、操作有多复杂、对并发吞吐的要求"去权衡选择。这给了我一种处理并发的清醒:面对任何"多方并发访问/修改同一资源"的问题时,先评估"冲突的概率有多高?操作能否原子化?"——能原子化就原子化(最简单)、冲突少用乐观、冲突多用悲观;"用'原子/乐观/悲观'这套并发控制的范式去思考共享资源的访问、按冲突频率选型",是写对一切并发代码的通用方法论。认清三种并发控制范式、按冲突频率与操作复杂度选型——是这个坑带给我的认知。
第五件事:这次事故暴露的"单线程直觉"在并发下的失效
这次让我反思更深一层:我写"查出来减一再写回"时,脑子里是个单线程的世界。我把"单线程直觉"和"并发现实"对比成表。
| 维度 | 单线程直觉 | 并发现实 |
|---|---|---|
| 我读到的值 | 就是最新的、没人会动 | 读完到写之间, 别人可能已改 |
| 读改写 | 是一个连贯不可分的动作 | 是三步, 中间可被插入 |
| "先查再操作" | 查到的状态一直成立 | 查完状态可能已变(TOCTOU) |
| 执行顺序 | 按我写的顺序 | 多方交错执行 |
| 结果 | 确定 | 取决于交错时序 |
这张表道出了问题的思维根源。核心是:我写出那段有 bug 的代码,根上是因为我用"单线程的直觉"去写"会被并发执行的代码"——在单线程的世界里,"我查出来的库存,在我写回之前不会变"是天经地义的;可在并发的世界里,"我查完到我写回之间,别人也读了、也写了"是常态;我默认了一个"我独占这份数据、没人和我抢"的前提,而并发恰恰打破了这个前提。它给我的深刻启发是:"单线程顺序执行" 和 "并发交错执行" 是两个规则迥异的世界——大量在单线程下绝对正确的直觉(读到的值是稳定的、操作是连贯的、顺序是确定的),在并发下统统失效;最经典的就是 TOCTOU(检查时和使用时,状态已变):"先检查(查库存够)、再使用(扣减)",在并发下检查通过不代表使用时还成立;"用单线程的脑子写并发的代码", 是并发 bug 最主要的来源。这给了我一种写并发代码的根本审慎:但凡我写的代码"会被并发执行"(Web 请求、多线程、多实例),就要主动切换到"并发思维"——时刻假设"在我这两行代码之间, 别人也在动同一份数据",然后问"这样还对吗?我读的值会不会已经过期?我的'检查'到'动作'之间状态会不会变?"——并用并发控制手段(原子/锁/CAS)把关键的"读改写"保护起来;"意识到自己的代码身处并发环境、用并发思维而非单线程直觉去推理",是写出并发安全代码的根本前提。认清单线程直觉在并发下失效、用并发思维主动假设别人也在动同一数据——是这个超卖坑带给我的工程态度。
第六件事:并发修改同一数据时,我现在的自检习惯
现在每当我要写"修改数据库里某个共享数据"的逻辑,我都会先按这张图问自己:
这张图的精髓,是"并发改共享数据别读改写,能原子就原子、冲突少乐观、冲突多悲观"。不并发普通读改写、简单增减原子 UPDATE、复杂冲突少乐观锁、冲突多悲观锁。这套习惯,让我从"查出来改了写回去"变成了"先想这数据会不会被并发改、用对的并发控制"——核心始终是:并发修改同一数据别用"查出来再写回"的单线程写法,用原子 UPDATE、乐观锁或悲观锁,把读改写变原子或串行,避免更新丢失和超卖。
我立下的几条规矩
这场"放量就超卖、库存对不上账"的事故,换来了我写数据库并发操作时,刻进骨子里的几条铁律:
- "先查再算后写回"在并发下会更新丢失。两个事务读到同一旧值、后写覆盖先写。
- 仅仅包进事务不一定能防更新丢失。普通 SELECT 不加锁仍读到旧值。
- 简单增减(库存/计数)用原子 UPDATE:SET x=x-1 WHERE 边界。首选。
- 扣减一定加边界条件(WHERE stock>0),绝不扣成负数。
- 冲突不频繁用乐观锁(version CAS+重试),频繁用悲观锁(FOR UPDATE)。
- 极高并发(秒杀)还要前置 Redis 预扣/限流/队列削峰。
- 代码会被并发执行时,用并发思维:假设别人也在动同一份数据。
写在最后
回头看,这场由"查出来减一再写回"引发的、放量即超卖的事故,真正教给我的,远不止"用原子 UPDATE 扣库存"这一个技巧。它让我对"一段代码在'只有我一个人用'时正确, 完全不代表它在'很多人同时用'时正确; 而我们写代码时, 脑子里默认的, 往往是那个'只有我一个人'的、并不真实的世界",有了一次刻骨的体会。我栽跟头,是因为我写那段扣库存代码时,脑子里不自觉地预设了一个"只有我这一个请求在动这个库存"的、安静的世界——在那个世界里,"我查出来的 100,在我写回 99 之前,理所当然还是 100"。可真实的生产环境,是一个"成百上千个请求同时涌向同一行库存"的、喧闹的世界;在那里,"我查到的 100"从我读到的那一刻起就可能已经过时了——因为在我"查"和"写"的那道缝隙里,挤进了无数个同样在查、在算、在写的请求;我用一个"独处"的心态, 写了一段注定要"在人群中被推搡"的代码。这让我领悟到一个关于"独处假设与并发现实"的深刻认知:我们写下的绝大多数"会被并发调用"的代码(每一个 Web 接口、每一个被多线程调用的方法),都生活在一个"众声喧哗、互相争抢"的并发世界里;而"读到的数据是稳定的、我的操作是连贯不被打断的"这类'独处假设', 在这个世界里是不成立的奢望;"把并发环境下的代码, 想象成单人独享的代码", 是一种危险而普遍的思维默认。这给了我一种身处并发世界的根本清醒:写任何代码时,都要先清醒地意识到"它将在一个什么样的环境里运行——独占的, 还是并发争抢的?"——若是并发的, 就主动卸下"独处假设", 戴上"众人争抢"的眼镜去重新审视每一处"读了再写、查了再用"的地方, 用并发控制把它们保护好;"承认并直面自己代码所处的并发现实、不被独处的直觉误导", 是写出在真实高并发下依然正确的系统的根本前提。认清独处假设在并发现实中不成立、主动戴上众人争抢的眼镜审视读写——这,是我用一次超卖的事故,换来的、关于数据库并发、也关于如何在喧闹的并发世界里正确思考的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写扣库存、改余额这类并发操作时,本能地避开"查出来再写回"、换上原子 UPDATE 或锁,那我对着那超卖的账目排查的这段时间,就值了。
—— 别看了 · 2026