库存还剩 1,却卖出了 3 单
凌晨一点四十,运营在群里甩来一张截图:某款商品库存还剩 1,结果同一时间卖出去了 3 单。更离谱的是,扣库存那条 SQL 我写得明明白白——先查库存,再判断,再扣减,中间还加了事务和 SELECT ... FOR UPDATE。
我当时的第一反应跟所有被 prod 半夜叫醒的程序员一样:加锁啊。
然后我发现,锁早就加了,问题在于它锁的东西跟我以为的不是一回事。
这篇就把这次事故从头捋一遍,顺带把 InnoDB 的行锁、间隙锁、next-key lock 到底在锁什么讲清楚。这块是 MySQL 最容易被似懂非懂的部分,也是「明明加了锁还是超卖」的第一现场。
一、先复现:那段看起来无懈可击的代码
典型的下单扣库存逻辑,Java 伪代码:
@Transactional
public void deduct(Long skuId, int count) {
Sku sku = skuMapper.selectForUpdate(skuId); // SELECT ... FOR UPDATE
if (sku.getStock() < count) {
throw new BizException("库存不足");
}
skuMapper.updateStock(skuId, sku.getStock() - count);
orderMapper.insert(order);
}
看起来三要素齐全:有事务、有行锁、有判断。教科书上这写法挑不出毛病。
但线上真实执行下来,低并发时完全正常,一到活动流量上来就必然超卖。而且不是偶发,是稳定复现——这才是最让人头皮发麻的地方。
凡是在压测里跑不出问题、上线后才炸的并发 bug,八成不是「加锁加少了」,而是「锁压根没锁在你以为的那一行上」。
二、根因:RR 隔离级别下,SELECT FOR UPDATE 锁住的比你以为的多
MySQL 默认隔离级别是 Repeatable Read(RR)。RR 下 InnoDB 用的是 next-key lock,它是两段锁的合体:
- 记录锁(Record Lock):锁住索引上那条已存在的记录。
- 间隙锁(Gap Lock):锁住记录之间的「空隙」,阻止其他事务在这个区间插入新记录。
next-key lock = 记录锁 + 前面的间隙锁。关键在于它锁的是扫描过的索引范围,而不只是那一条命中的行。
于是有两条经典死法浮出水面:
死法一:没走索引,锁升级成全表
如果 sku_id 没建索引(或者你写了个 WHERE sku_id = ? AND status = 1 导致优化器选错了索引),InnoDB 只能全表扫描,那把行锁会直接升级成表级别的锁——并发度瞬间归零。
更隐蔽的是:即使 SQL 带了 FOR UPDATE,如果查询条件没有可用索引,整张表都被锁,而你以为你只锁了一行。
死法二:两个事务交叉更新,直接死锁
假设 SKU 表里有两行,事务 A 更新 id=1,事务 B 更新 id=2:
-- 事务 A -- 事务 B
BEGIN; BEGIN;
SELECT ... WHERE id=1 FOR UPDATE; -- A 拿到 (1, 2) 区间
SELECT ... WHERE id=2 FOR UPDATE; -- B 拿到 (2, 3) 区间
UPDATE ... WHERE id=1; UPDATE ... WHERE id=2; -- B 阻塞
-- A 继续更新 id=2 → 需要 B 持有的锁
-- B 继续更新 id=1 → 需要 A 持有的锁
-- 死锁诞生
MySQL 会主动检测死锁,牺牲其中一个事务,抛出:
Deadlock found when trying to get lock; try restarting transaction
这就是错误码 1213。而上面那 3 单超卖,是应用层捕获异常之后继续重试并放行订单导致的——锁被打破的瞬间,库存判断的原子性就没了。
三、排查:别猜,去看锁的真实状态
怀疑死锁,第一件事不是改代码,是把现场捞出来:
SHOW ENGINE INNODB STATUS\G -- LATEST DETECTED DEADLOCK 段落
SHOW ENGINE INNODB STATUS\G -- TRANSACTIONS 段落,看谁在等谁
SELECT * FROM performance_schema.data_locks\G
SELECT * FROM performance_schema.data_lock_waits\G
performance_schema.data_locks 这张表是现代版本的正确工具,它会明确告诉你锁的类型是 RECORD、GAP 还是 INSERT_INTENTION,以及锁住的具体索引名。索引名这一列是重点:如果你看到锁打在一个业务根本没用到的索引上,那基本可以断定是「没走索引导致锁范围扩大」或者「索引选择器选错了」。
另外强烈建议开启死锁日志落盘:
innodb_print_all_deadlocks = ON
innodb_deadlock_detect = ON
有了这个,deadlock 会被写进 MySQL 错误日志,不用等线上报警时再去翻 SHOW ENGINE INNODB STATUS。
四、修复方案,从最省事到最彻底
方案 A:让判断和扣减变成一条原子 SQL
这是最推荐的一招。不查库存、不判断、直接用条件更新,让数据库自己判断:
UPDATE sku SET stock = stock - #{count}
WHERE sku_id = #{skuId} AND stock >= #{count};
-- 影响行数 = 0 → 库存不足,直接回滚
-- 影响行数 = 1 → 扣减成功
为什么这招有效?因为单条 UPDATE 语句在 InnoDB 里对同一行的操作本身就是原子的,它拿的是排他行锁,判断和扣减在锁的保护下连续完成,中间不可能有第二个事务插进来。
逻辑从「先读后写」变成了「条件写」,少一次往返,也少一个「读到旧值」的窗口。
方案 B:唯一索引做最终兜底
在订单表上建一个唯一约束,把业务唯一键钉死:
ALTER TABLE orders
ADD UNIQUE KEY uk_sku_seq (sku_id, seq_no);
并发下哪怕应用层逻辑有漏洞,数据库也会用「重复键错误」把第二次插入挡掉。业务层捕获这个错误返回「请求重复」,比超卖好一万倍。
方案 C:乐观锁版本号
适合冲突不极端但需要精确计数的场景:
UPDATE sku SET stock = #{newStock}, version = version + 1
WHERE sku_id = #{skuId} AND version = #{version};
-- 影响行数 = 0 → 版本号已被别人改过,读取最新值重试
乐观锁不会阻塞别人,高冲突下重试会打爆 CPU,所以它有它的适用边界,别无脑套。
方案 D:热点行拆分
如果是极端热点(比如一个爆款 SKU),可以把库存拆成 N 个子库存,随机路由到其中一个:
stock_0: 100 stock_1: 97 stock_2: 103 ...
扣减时随机挑一个扣,失败就换下一个。等价于把一把大锁拆成 N 把小锁,牺牲精确扣减前的库存视图,换取吞吐。这个方案的坑是:总库存会短暂「虚高」(某个子库存还没扣完),如果你的业务对「实时可售库存」要求严格,就别用。
方案 E:Redis 预扣减 + 异步落库
秒杀场景的标配。Redis 里用 Lua 脚本做原子扣减:
-- KEYS[1]=stock_key ARGV[1]=count
local stock = tonumber(redis.call('GET', KEYS[1]))
if not stock or stock < tonumber(ARGV[1]) then
return 0
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1
Lua 在 Redis 里是原子执行的,所以这段代码天生没有并发问题。但它把「一致性」问题换了个位置:Redis 扣了、DB 写失败怎么办?必须配一个可靠的落库机制(本地消息表 / 事务消息 / 定时对账),否则你只是把超卖换成了少卖。
五、那些容易被忽略的运维细节
修复业务代码只是第一步,这几个配置项同样重要:
- 隔离级别要不要降到 RC?RC 下没有间隙锁,并发度会显著提高,但代价是可能出现不可重复读。很多互联网业务确实会全局改成 RC 来「反正也不需要严格可重复读」。这不是银弹,但它能砍掉一大半因间隙锁带来的死锁。
- innodb_lock_wait_timeout:默认 50 秒。事务拿着锁等着,很容易把连接池耗光。建议压到 5~10 秒,让它快速失败快重试,而不是长时间占着资源。
- 事务里不要干别的事:事务里发 HTTP 请求、读文件、写日志,都会把锁的持有时间拉长到不可控。事务应该只有「改数据库」这一件事,越短越好。
- 批量操作要小步快跑:一次锁 5000 行改 5 分钟,等于锁住全表 5 分钟。拆成每批 500 行,每批之间提交,能明显改善并发表现。
六、最后的总结
这次事故留下的几条结论,我认为是通用的:
- 「先查再改」几乎总是错的。能用一条条件 UPDATE 表达的逻辑,就不要用两次查询拼出来。数据库的条件更新本身就是原子的。
- 锁的范围比你以为的大。RR + next-key lock + 没走索引,等于全表锁。永远确认一下执行计划(
EXPLAIN),确认走的是哪个索引。 - 死锁不是 bug,是并发的正常形态。但你需要:识别 1213、重试、以及把事务拆短来降低重试概率。
- 一致性要放在数据库做最后一道防线。唯一索引、CHECK 约束、原子条件更新——业务代码会出错,约束不会。
最后一句吐槽:这次线上事故复盘会,运营问了一个让我当场沉默的问题——「那我们这个库存,到底能不能信?」
我说:能。但前提是你得知道它是怎么保证的,以及在什么情况下它会不保证。
并发编程没有银弹,只有「知道自己丢了什么」的工程。