事情是这样的。那天我正在愉快地摸鱼,突然钉钉群里炸了——
"库存超卖了!超了3000多单!"
我当时的反应是:什么?超卖?3000单?这三个词放在一起,让我瞬间清醒。
赶紧打开电脑,登上服务器,一看日志——好家伙,数据库里某些商品的库存变成了负数。负数啊!库存这东西,怎么能是负的呢?你卖的是手机又不是特斯拉股票!
先说说什么是库存超卖
简单来说,库存超卖就是:你本来只有100件商品,结果卖出了130件。剩下的30件从哪儿来?从空气里来、从数据库的bug里来、从程序员的不小心来。
在线上零售场景里,超卖是个要命的问题。你让3000个人下单了,结果发不出货,投诉、赔偿、平台扣分,一套组合拳下来,够你喝一壶的。
当时我赶紧去看代码,找到了罪魁祸首——一段大概长这样的SQL:
UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?
看起来好像没问题对吧?加了 stock >= ? 的条件,应该不会超卖才对。但问题在于——并发。
并发下的数据库操作,就像在早高峰的地铁里抢座位
假设商品A只剩最后1件库存。现在同时来了两个请求:
请求1:想买1件,查库存→1>=1→可以买→执行扣减→库存变成0
请求2:想买1件,查库存→1>=1→可以买→执行扣减→库存变成-1
等等,你不是说加了条件吗?问题出在哪儿?
问题出在查询和更新之间有时间窗口。两个请求同时读到库存=1,都觉得可以买,都去执行更新,结果就是超卖。
这就是经典的竞态条件(Race Condition)。在单线程环境下这段代码没问题,但在并发环境下,它就成了一颗定时炸弹。
我试过的那些方案
第一反应是加锁。Python的threading.Lock、Redis分布式锁,能想到的都上了。但加锁有个问题——性能。锁粒度太粗,系统吞吐量直接腰斩;锁粒度太细,又容易出现死锁。
第二反应是给库存扣减加个预扣机制。比如先预扣,异步确认,但这样架构改造成本太大,短期内搞不定。
后来请教了DBA,他听完我的描述,只问了一句:"你们用的什么事务隔离级别?"
我愣住了。隔离级别?MySQL默认不是挺好的吗?
DBA笑了笑:"默认的REPEATABLE READ,在某些情况下,并不能保证你想要的原子性。"
事务隔离级别:数据库给你的安全网,你真的了解吗?
大部分人对事务隔离级别的了解,可能只停留在"读已提交"和"可重复读"两个名字上。但具体有什么区别、什么时候用、会出现什么问题,估计一半的程序员都答不上来。
MySQL InnoDB支持四种隔离级别:
1. READ UNCOMMITTED(读未提交)
最弱的隔离级别,允许脏读。事务A修改了数据,还没提交,事务B就能读到。这个级别基本没人用,除非你真的不在乎数据一致性。
2. READ COMMITTED(读已提交)
只能读到已提交的数据,避免脏读。但有个问题——不可重复读。事务A在T1时刻读了一条记录,事务B在T2时刻提交了修改,事务A再次读取,会读到不同的值。
3. REPEATABLE READ(可重复读)
MySQL InnoDB的默认隔离级别。事务A读取的数据,在事务期间始终一致。但这里有个坑——幻影读(Phantom Read)。事务A执行 SELECT COUNT(*) FROM orders WHERE status=pending,两次执行结果可能不同,因为其他事务可能插入了新的数据。
4. SERIALIZABLE(串行化)
最强隔离级别,所有操作串行执行。数据安全是安全了,但性能嘛……你懂的。
解决超卖的关键:锁
回到超卖问题。DBA给我的建议是:用SELECT FOR UPDATE显式加锁。
BEGIN;
SELECT stock FROM products WHERE id = ? FOR UPDATE;
-- 检查 stock >= 需求数量
UPDATE products SET stock = stock - ? WHERE id = ?;
COMMIT;
SELECT ... FOR UPDATE 会在事务期间锁定查到的行,其他事务想操作同一行就得排队等着。这比单纯的条件判断靠谱多了。
当然,还有更优雅的方案——乐观锁。给商品表加个version字段,更新时检查版本号:
UPDATE products
SET stock = stock - ?, version = version + 1
WHERE id = ? AND version = ? AND stock >= ?
如果影响行数为0,说明版本冲突,需要重试。这种方案性能更好,但重试逻辑要写对。
事件驱动架构:用消息队列削峰
如果你不想在数据库层面扣库存,可以把库存扣减变成一个异步过程:用户下单→创建订单(状态:待确认)→发消息到队列→消费者扣减库存→回调更新订单状态。
这样秒杀高峰时,下单和扣库存解耦了,数据库压力小很多。当然,代价是架构复杂了,需要考虑消息丢失、重复消费等问题。
踩坑总结
这次事故之后,我总结了几个经验:
1. 永远不要相信并发场景下的简单条件判断
库存够不够,不能只靠 WHERE stock >= ? 来保证。要么加锁,要么用乐观锁,要么把扣库存做成原子操作。
2. 了解你用的数据库的隔离级别
默认设置不等于最优设置。REPEATABLE READ听起来很美好,但在高并发写入场景下,可能不是你想要的。
3. 做好库存的监控和告警
库存变成负数之前,应该有告警。一个成熟的电商系统,应该能提前发现超卖风险。
4. 考虑最终一致性,而不是强一致性
库存这种东西,其实允许短暂的最终不一致。关键是通过异步补偿机制,保证最终是对的。
写给同行的话
超卖这个问题,看似简单,其实涉及并发控制、事务隔离、分布式一致性等多个知识点。很多时候,我们写代码的时候觉得逻辑没问题,但一到线上并发量上来,就各种翻车。
我的建议是:写涉及库存、余额这类资源的代码时,先在脑子里模拟1000并发请求进来,你的代码会不会出问题。想不清楚就找DBA聊,别等线上事故了再后悔。
那次事故之后,团队做了次全面代码review,类似的隐患还发现了好几个。现在每次发版前,数据库相关的改动都要过DBA评审。
不是我们胆小,是被教训过的人才知道疼。