高并发下单时库存超卖了,怎么用MySQL保证不超卖?
本文目录
先说结论
用MySQL保证不超卖的核心是:把“查询库存 + 扣减库存”变成一个原子操作。有三种实现方式,从简单到复杂:悲观锁、乐观锁、WHERE条件扣减。
先把这段讲顺;面试官追问时,再展开下面的原理和细节。
详细讲解
库存超卖是电商/交易场景里最经典的问题。看起来简单——“先查库存再扣减”——但在高并发下这样做会超卖。今天把从“为什么会超卖”到“怎么保证不超卖”彻底讲清楚。
为什么“先查再扣”会超卖?
-- ✖ 会超卖的写法
-- 并发场景:库存只剩1件,两个请求同时到达
-- 请求A -- 请求B
SELECT stock FROM product SELECT stock FROM product
WHERE id=1; -- 查到stock=1 WHERE id=1; -- 查到stock=1
-- 两个请求都认为库存足够,都执行扣减
UPDATE product SET stock=stock-1 WHERE id=1; -- stock变成0
UPDATE product SET stock=stock-1 WHERE id=1; -- stock变成-1!超卖了
问题在于:SELECT和UPDATE之间有时间差,别的事务可以插进来。
方案一:悲观锁(SELECT FOR UPDATE)
BEGIN;
-- 加排他锁,其他事务读写都要等待
SELECT stock FROM product WHERE id=1 FOR UPDATE;
-- 检查库存
-- if (stock > 0) {
UPDATE product SET stock = stock - 1 WHERE id = 1;
INSERT INTO orders (product_id, ...) VALUES (1, ...);
-- }
COMMIT;
优点:简单可靠。缺点:并发低,所有请求排队等待锁。适合并发量不高的场景。
方案二:乐观锁(版本号控制)
-- 表加一个version字段
SELECT stock, version FROM product WHERE id = 1;
-- 假设查到 stock=5, version=10
-- 更新时带上version条件
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 10; -- 只有version没变才能更新成功
-- 如果影响行数=0,说明别人已经改过了,需要重试或报错
优点:不加锁,并发高。缺点:强冲突时重试多。适合读多写少的场景。
方案三:WHERE条件扣减(最实用★)
这是我在生产环境最常用的方案,也是最优雅的:
-- 把“查询+扣减”合并成一条原子SQL
UPDATE product
SET stock = stock - 1
WHERE id = 1 AND stock > 0; -- 关键:stock > 0保证不会扣成负数
-- 检查影响行数
-- if (affectedRows == 1) 下单成功
-- if (affectedRows == 0) 库存不足,返回“已售罄”
为什么这样不会超卖?因为UPDATE是原子的,MySQL会在执行UPDATE时自动加行锁。stock > 0这个条件保证了即使并发执行,也不会把stock扣成负数。
优点:一条SQL搞定,不需要事务,性能最好。我在元宇宙社交App的虚拟礼物库存用的就是这个方案。
实战踩坑:unsigned字段的额外保护
如果stock字段是UNSIGNED INT,即使不加stock > 0条件,扣成负数时MySQL也会报错而不是静默成功。但不能依赖这个——应用层必须自己做检查。
另外,如果一次要扣减多件(比如购买3件),WHERE条件要写成stock >= 3,不是stock > 0。这个细节很多人忽略。
总结
| 方案 | 原理 | 并发性能 | 适用场景 |
|---|---|---|---|
| 悲观锁 | SELECT FOR UPDATE加行锁 | 低(排队等待) | 并发不高的场景 |
| 乐观锁 | version字段控制 | 中(强冲突时重试多) | 读多写少 |
| WHERE条件扣减 | stock > 0原子检查 | 高(一条SQL) | 库存扣减场景首选 |
面试时能说清楚“为什么先查再扣会超卖”,再给出三种方案并分析各自适用场景,就很完整了。
第1-5篇完结。后续将继续聊事务隔离级别、B+树索引原理、联合索引设计、Binlog应用场景、读写分离架构等实战场景。
你们生产环境怎么防止超卖的?评论区聊聊~
关注「Wood聊大数据」| 9年大数据老兵,聊技术、聊职场、聊成长