电商超卖最常见根因是事务隔离级别为repeatable read且业务用快照读判断库存。此时普通select读mvcc快照,看不到其他事务已提交的库存变更,导致“查-判-写”逻辑失效;应改用select ... for update或原子update语句。

直接看事务隔离级别是否为 REPEATABLE READ 且业务用了快照读做库存判断——这是电商超卖最常见、最隐蔽的根因。
确认当前会话/全局隔离级别是否为 REPEATABLE READ
MySQL 8.0+ 默认已改为 READ COMMITTED,但老系统或显式配置仍可能沿用 REPEATABLE READ。这个级别下普通 SELECT 是快照读,事务内多次查询看到的是同一份旧数据,根本看不到其他事务刚提交的库存变更。
- 查当前会话级别:
SELECT @@transaction_isolation; - 查全局默认:
SELECT @@global.transaction_isolation; - 若返回
REPEATABLE-READ(注意中间是短横线),且业务中存在“先SELECT stock再UPDATE”逻辑,风险极高 - 不要只信配置文件——应用连接池可能在初始化时执行
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ覆盖全局设置
检查业务代码里是否混用快照读和当前读
在 REPEATABLE READ 下,SELECT 和 SELECT ... FOR UPDATE 行为完全不同:前者读 MVCC 快照,后者强制读最新已提交数据并加锁。如果代码里用普通 SELECT 判断库存,再用 UPDATE 扣减,就等于拿过期数据做决策。
- 典型错误模式:
BEGIN; SELECT stock FROM inventory WHERE id = 123; -- 快照读,值=1;UPDATE inventory SET stock = stock - 1 WHERE id = 123 AND stock > 0; COMMIT; - 问题在于:两次事务同时执行该逻辑,都读到
stock = 1,都满足stock > 0,都执行减 1,最终变 -1 - 正确做法必须用
SELECT ... FOR UPDATE替代普通SELECT,且必须在同一个事务内(BEGIN后、COMMIT前) - 注意:
FOR UPDATE对非索引字段查询可能升级为间隙锁甚至表锁,务必确保WHERE条件命中主键或唯一索引
验证是否存在幻读导致的库存校验失效
幻读不是“读到不存在的行”,而是“读不到刚发生的变更”。在库存场景中,它表现为:事务 A 多次 SELECT 都显示 stock = 10,但事务 B 已经把其中 9 个扣掉了,A 却还在基于 10 做后续操作。
- 复现关键点:在
REPEATABLE READ下开启两个会话,会话 A 执行BEGIN; SELECT stock FROM inventory WHERE id = 100;,会话 B 立即执行UPDATE并COMMIT,会话 A 再次SELECT—— 结果不变 - 这不是 bug,是 MVCC 的设计行为。但业务上不能接受,因为库存判断完全失效
- 解决幻读不靠改隔离级别(
SERIALIZABLE性能太差),而靠用FOR UPDATE强制当前读 + Next-Key 锁,堵住这个窗口 - 如果业务允许少量超卖(如优惠券发放),可降级到
READ COMMITTED,但必须同步改代码——此时普通SELECT每次都读最新,但不再保证可重复读
用原子性 UPDATE 替代“查-判-写”流程
哪怕不用锁,单条带条件的 UPDATE 也能拦截大部分超卖,因为它本身是原子操作,且 InnoDB 会在匹配行上隐式加行锁。
- 安全写法:
UPDATE inventory SET quantity = quantity - 1 WHERE id = 123 AND quantity >= 1; - 执行后检查
ROW_COUNT():若为 0,说明库存不足,直接拒绝下单 - 比“先查后更安全”:少一次网络往返,避免两次操作间的时间窗口被插队
- 硬性要求:
quantity字段必须是UNSIGNED INT,否则quantity - 1为负数不会报错,只会静默更新成大正数(溢出) - 局限:无法支撑多商品扣减、跨表校验等复杂逻辑,这类场景仍需
FOR UPDATE+ 显式事务
真正容易被忽略的是:即使加了 FOR UPDATE,如果事务里穿插了普通 SELECT 做中间判断,那个 SELECT 读到的仍是快照——锁只保当前读,不保整个事务的一致视图。所以锁和读必须严格配对,不能混搭。











