大家好,我是数据库小学妹👋 我踩过的坑,你别再踩。
去年大促的零点,我盯着大屏看订单曲线往上冲,心里挺美。二十分钟后,客服群里有人问,同一个商品怎么卖了 137 件,库存明明只有 100。我第一反应是不可能。库存扣减是包在事务里的,事务还能出错?
后来把日志摊开,一条条对下来。问题不在事务,在事务里面那几行代码的先后顺序。
先说清楚,超卖到底是怎么发生的
我用最典型的那种写法复现了一次。逻辑是"先查库存,再判断,再扣减"。
java
// 应用层写法:看着天衣无缝,其实中间有个致命的空隙
@Transactional
public void deduct(Long productId) {
Product p = mapper.selectById(productId); // 1. 查
if (p.getStock() < 1) { // 2. 判断
throw new BizException("库存不足");
}
mapper.updateStock(productId, p.getStock() - 1); // 3. 扣
}
三步,看着没问题。但第 1 步和第 3 步之间,是有时间的。假设库存只剩 1 件,两个线程同时进来。它们都读到 1,都判断通过,都去扣。库存就变成了 -1。
更麻烦的是,MySQL 默认的 RR 隔离级别下,selectById 走的是快照读。它读到的可能压根不是最新值。你就算把事务加上,也不会让这两步变成一个原子操作。事务保证的是要么全成功、要么全失败。它不保证这两步之间没人插进来。
这一点我搞混过很久。刚转行那阵子我一直以为,加了 @Transactional 就等于锁上了。其实事务只是给你划了个边界。边界里面有没有并发问题,得看你自己的操作是不是原子的。
真正原子的写法,其实只有一行
MySQL 的 UPDATE 有个特性,很多人没注意到。它是当前读,会去读最新的数据,同时给命中的行加排他锁。判断和修改都在这把锁里面完成。
sql
-- 把"判断"和"扣减"塞进同一条 UPDATE
UPDATE product
SET stock = stock - 1, updated_at = NOW()
WHERE id = 1001
AND stock >= 1;
应用层只需要看一个返回值,affected_rows。它返回 1,说明扣减成功。返回 0,说明 stock >= 1 没满足,库存已经没了,直接告诉用户抢光了。整个过程没有空隙,因为判断和修改发生在同一条语句、同一把锁里。
java
int rows = mapper.deductAtomic(productId);
if (rows == 0) {
throw new BizException("库存不足");
}
就这一行 SQL,把我那次事故的根因堵死了。我当时的第一反应是,那还要分布式锁干嘛。这个问题值得展开说说。我现在的判断是,单行库存的扣减用不上分布式锁。分布式锁解决的是跨资源、跨库的互斥,比如"一个用户不能在两个节点同时下单",这种拿不到共享数据结构的场景。单行库存本身就在数据库里,行锁就是现成的互斥手段。你在行锁外面再套一层 Redis 锁,等于上了把更慢的锁,还额外引入锁超时、锁续期这一堆新问题。
但热点行会卡住,这一步绕不开
原子 UPDATE 解决了正确性,没解决性能。所有请求都去抢同一行的锁,只能排队。库存只剩 100 件的时候无所谓。库里 10 万件、几十万人在抢的时候,这一行就是瓶颈。
我当时也去查过,这个瓶颈是硬的,调参绕不过去。一条单行 UPDATE 能扛多少 QPS,取决于硬件、事务大小、刷盘配置。别人的数字我不确定,反正我们自己压出来的结果,远低于预期。
后来用了个很土但有效的办法,把库存分桶。
sql
-- 一行热点拆成 N 行,总库存 = SUM(stock)
CREATE TABLE stock_bucket (
product_id BIGINT NOT NULL,
bucket_no TINYINT NOT NULL,
stock INT NOT NULL,
PRIMARY KEY (product_id, bucket_no)
);
-- 扣减时,挑一个桶去扣
UPDATE stock_bucket
SET stock = stock - 1
WHERE product_id = 1001
AND bucket_no = 7
AND stock >= 1;
原理很简单。十个桶就是十行,十行就是十把锁,并发度直接上去。代价是逻辑变复杂。你要处理某个桶扣空之后换桶,要能算出总库存,退款归还的时候还得知道还回哪个桶。
这里有个坑我踩过,桶不是越多越好。桶多了,总库存的误差和归还的复杂度都会上来。我们最后用十到二十个,具体看单品的抢购规模,没有通用数字。
把这些方案摆在一起看
| 方案 | 会不会超卖 | 性能上限 | 复杂度 | 适合什么场景 |
|---|---|---|---|---|
| 先查后改 | 会 | 高 | 低 | 任何并发场景都不该用 |
| 原子 UPDATE | 不会 | 受单行锁限制 | 低 | 中小规模抢购,首选 |
| 分桶库存 | 不会 | 高 | 中 | 单品抢购量很大 |
| Redis 预扣 + 异步落库 | 不会,但要处理补偿 | 最高 | 高 | 超高并发,且能接受最终一致 |
| 分布式锁 | 不会 | 低 | 中 | 单行库存用不上,跨资源才需要 |
Redis 预扣这条路我列进去了,但我们最后没上。不是它不好,是当时的量还没到。它能扛住最高的并发,代价是把一致性推给了补偿逻辑。预扣成功、落库失败怎么办,Redis 挂了怎么恢复,超时未支付怎么归还,每一个都是新的事故面。我没把握把这几条补偿都做对,所以先用分桶,量真上来了再说。
还有一层,隔离级别和索引会改变锁的范围
这是我在压测里才发现的。同样是扣库存,走得通的路不一样,锁的范围差很多。
如果 WHERE 走的是主键等值或者唯一索引等值,比如 id = 1001,那它在 RR 下加的是行锁。只锁这一行,并发影响可控。
如果条件走的是普通索引,比如按活动 ID 批量扣减,写法是 WHERE activity_id = 5 AND stock >= 1。那加的就是 next-key lock,会把 activity_id = 5 范围内的行和间隙一起锁上。并发一上来,锁等待和死锁就都来了。
这条我原来一直没太在意。直到压测里出现连片的锁等待超时,我才回头去翻 information_schema.innodb_trx,把每个事务锁了哪些行一条条对出来。
还有个组合场景要小心。一次下单要扣两个商品的库存。事务 A 先锁商品 1 再锁商品 2,事务 B 反过来,两边就会互相等。解法很土,统一按商品 ID 从小到大排序,再依次扣。
顺带提一句,这套逻辑在不同隔离级别下表现也不一样。RC 对不匹配的行会提前放锁,锁范围比 RR 小。我后来把扣库存的那几个接口单独确认了一遍隔离级别,不再让它跟着全局配置走。
避坑清单
stock 字段设成 UNSIGNED 有用,但我见过有人把它当唯一防线。它确实能兜住最后一步。在严格模式下,扣到 0 再减会直接报错、事务回滚,不会真的变成负数。可这个报错来自数据库,不来自你的业务逻辑,用户看到的是一句看不懂的异常。它该是兜底,不该是唯一一道。
库存归还比扣减更容易出事。订单取消、超时未支付、退款,每条路径都要还库存。我见过一次重复归还,就是退款接口重试了两次。归还的接口得带幂等键,或者落一张归还流水表,靠唯一索引拦住重复。
压测别只压成功路径。我们第一次压测跑得很漂亮,超卖为零。后来才发现压的全是库存充足的情况,压根没进到 affected_rows = 0 那条分支。库存不足时的返回、扣减失败后的重试、以及重试会不会把热点放得更大,都得单独压一遍。
写在最后
这次事故之后,我把一句话写进了我们的评审清单。数据库负责库存逻辑的最后一道正确性,但它不负责扛下所有性能。 这两件事经常被混在一起,混在一起就会出事。要么为了正确性,把并发都砸到数据库上硬扛;要么为了性能,把正确性让出去。
我现在的顺序是,先保住正确。用一条原子 UPDATE 把根因堵死,压测看量,量不够再谈分桶,还不够再谈 Redis。至于分布式锁,那是给跨资源场景准备的,不是给单行库存准备的。
你们大促的库存是怎么扣的?踩过超卖的坑吗?评论区聊聊。
我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋