普通update易超卖是因为“读-判-写”非原子:两请求同时读到stock=1,均执行减1致库存-1;where stock>0为快照读不加锁,无法阻塞并发修改。

为什么不能用普通 UPDATE 减库存
直接写 UPDATE goods SET stock = stock - 1 WHERE id = 123 AND stock > 0 看似合理,但在高并发下会漏减、超卖。根本原因是:读取当前 stock 值和执行减法不是原子操作,两个请求可能同时读到 stock=1,都判断通过,然后都减成 0 —— 实际扣了两次,库存变成 -1。
即使加了 WHERE stock > 0,也只是防止最终写入负数,但无法阻止多个事务同时通过该条件(因为 InnoDB 的可重复读隔离级别下,这个判断不加锁)。
- 必须让“检查 + 扣减”在单条 SQL 中完成,且具备行级写锁能力
- 不能依赖应用层判断库存是否充足
- 避免使用
SELECT ... FOR UPDATE在事务中先查再更新,它容易引发死锁且吞吐低
用 UPDATE 的 WHERE 条件做原子校验与扣减
核心是把库存是否足够的判断逻辑塞进 WHERE 子句,并确保该语句能命中索引、触发行锁。正确写法:
UPDATE goods SET stock = stock - 1 WHERE id = 123 AND stock >= 1;
这条语句执行时,InnoDB 会对满足 id = 123 AND stock >= 1 的行加 **next-key lock**(间隙锁 + 记录锁),后续并发请求会被阻塞直到前一个事务提交。更重要的是:返回的 affected_rows 是关键信号 —— 只有等于 1 才表示成功扣减;等于 0 说明库存不足或记录不存在。
- 务必给
id字段建主键或唯一索引,否则可能锁表 - 不要写
stock > 0,用stock >= 1更明确,语义一致且部分旧版本 MySQL 对>的锁行为有差异 - 应用层必须检查
mysql_affected_rows()或对应驱动的返回值,不能只看 SQL 是否执行成功
如何防止超卖后的“回滚不干净”问题
秒杀场景下,用户下单成功但支付失败,需要回补库存。如果用简单 UPDATE SET stock = stock + 1,可能因并发导致库存多加。更稳妥的方式是带版本号或状态校验:
UPDATE goods SET stock = stock + 1 WHERE id = 123 AND order_id = 'abc123' AND status = 'cancelled';
但更推荐在扣减时就预留“预占”状态,比如用字段 locked_stock 或将库存拆为 total_stock 和 available_stock。不过对纯单行扣减场景,最简方案仍是依赖 affected_rows 判断回滚是否生效:
- 扣减成功后,记录订单与商品关联关系(含本次扣减的
version或时间戳) - 回补时用
UPDATE ... WHERE id = ? AND stock = ?(基于扣减前的值)或乐观锁字段 - 避免无条件
stock = stock + 1,它在两次回补并发时会让库存+2
为什么不用 Redis + Lua 做扣减
Redis 确实快,但秒杀不是只比速度:你需要强一致性地关联订单、支付、发货等后续流程,而这些都在 MySQL 里。如果库存在 Redis,订单在 MySQL,就会出现「Redis 扣成功、MySQL 写订单失败」的中间态,业务上难以补偿。
真正高效的方案是:用 MySQL 单行原子扣减守住库存底线,再用缓存(如 Redis)做前置过滤(例如限流、资格校验、热点 key 预热),而不是把库存主逻辑移出数据库。
- Redis 可以承担 90% 的无效请求拦截(比如用户没资格、活动未开始),但不替代 MySQL 的最终扣减
- 不要为了“看起来快”而引入跨存储一致性难题
- MySQL 在 8.0+、SSD、合理配置
innodb_buffer_pool_size下,单行 UPDATE QPS 轻松过 5000,够撑住大多数秒杀峰值
实际最难的不是语法,是确保每一处调用都检查 affected_rows,并设计好超时、重试、补偿的边界逻辑。漏掉一次判断,就可能在线上悄悄超卖。











