bigywater.tech
分布式事务 · 19

库存扣减:超卖的几种防止方式

秒杀和下单最怕超卖。几种常见的库存扣减方式——读后写、加锁、条件 UPDATE、乐观锁、库存分片、Redis 预扣——到底哪些防得住?各自多快?Redis 预扣的代价是什么?这篇用真实的 MySQL 和 Redis,用同样的 3000 个买家抢 1000 件,把它们摆在一起量。

2000
先 SELECT 再 UPDATE 时 3000 个买家抢 1000 件的超卖数(其余五种都是 0)
11135 vs 1121
单行条件 UPDATE 与乐观锁的吞吐(请求/秒,32 线程抢同一行)
526
Redis 预扣、没持久化被 kill -9 并按 MySQL 重建后,超卖的件数

检查与扣减要原子

实测 · 本机执行输出
  做法                             | 成功的单数 | 超卖(成功数 - 1000) | MySQL 里最终库存 | 落库的订单行数(只有 Redis 方案是异步落库)
  -------------------------------+-------+----------------+-------------+--------------------------
  先 SELECT 再 UPDATE(不加锁)         | 3000  | 2000           | 901         | -                        
  SELECT ... FOR UPDATE 再 UPDATE | 1000  | 0              | 0           | -                        
  一条条件 UPDATE(qty >= 1)          | 1000  | 0              | 0           | -                        
  乐观锁(version CAS,最多重试 50 次)     | 1000  | 0              | 0           | -                        
  库存拆成 8 行,随机选一行做条件 UPDATE       | 1000  | 0              | 0           | -                        
  Redis Lua 先扣减,再异步批量落库          | 1000  | 0              | 0           | 1000                     
  先读后写的做法(第一行)成功的单数远多于库存;其余几种成功数正好是 1000

先读后写会超卖;把检查写进同一条 UPDATE 的 WHERE,或加行锁,都能防住。

吞吐

实测 · 本机执行输出
  做法                             | 请求/秒(3 轮中位数) | 3 轮范围         | 延迟 p99(第 1 轮)
  -------------------------------+--------------+---------------+--------------
  先 SELECT 再 UPDATE(不加锁)         | 11338        | 11332 ~ 11427 | 8.0 ms       
  SELECT ... FOR UPDATE 再 UPDATE | 9493         | 8561 ~ 9651   | 12.3 ms      
  一条条件 UPDATE(qty >= 1)          | 11135        | 9249 ~ 11390  | 12.8 ms      
  乐观锁(version CAS,最多重试 50 次)     | 1121         | 1120 ~ 1144   | 168.7 ms     
  库存拆成 8 行,随机选一行做条件 UPDATE       | 3434         | 3431 ~ 3459   | 17.8 ms      
  Redis Lua 先扣减,再异步批量落库          | 17262        | 15599 ~ 17912 | 7.0 ms       
  Redis 方案:3000 个请求在 0.19 s 内处理完(成功的 1000 个在 Redis 里已经确认),全部落到 MySQL 用了 0.24 s,即最后一个订单比用户看到成功晚 0.04 s 才进库

单行条件 UPDATE 既简单又够快;乐观锁在热点行上大量重试;分片在库存充足时更快;Redis 预扣最快。

争用与分片

实测 · 本机执行输出
  做法                             | 请求/秒(库存充足,3 轮中位数) | 3 轮范围         | 成功的单数(应为 3000)
  -------------------------------+-------------------+---------------+---------------
  SELECT ... FOR UPDATE 再 UPDATE | 5818              | 5766 ~ 5959   | 3000 ~ 3000   
  一条条件 UPDATE(qty >= 1)          | 7741              | 6336 ~ 7950   | 3000 ~ 3000   
  乐观锁(version CAS,最多重试 50 次)     | 528               | 512 ~ 529     | 2323 ~ 2349   
  库存拆成 8 行,随机选一行做条件 UPDATE       | 9021              | 6815 ~ 13660  | 3000 ~ 3000   
  Redis Lua 先扣减,再异步批量落库          | 17792             | 17286 ~ 18122 | 3000 ~ 3000   
  乐观锁在 32 个线程抢同一行时,有的买家重试 50 次仍然抢不到(库存明明充足),成功数少于 3000;分片后单行争用小了,库存充足时的吞吐上去了;上面库存卖完时分片更慢,是因为卖完之后每个失败请求要把 8 个分片挨个试一遍

乐观锁在 32 线程抢同一行时,有买家重试 50 次仍失败;分片把争用分散,但卖完后失败请求要试多个分片。

Redis 预扣的代价

实测 · 本机执行输出
  Redis 被杀时:已经对用户确认成功的订单 600 个,其中已经落到 MySQL 的 2 个(落后 598 个);重启后按 MySQL 里的库存 898 重新初始化 Redis(此时 MySQL 库存应该是 400)
  之后的买家继续抢:最终对用户确认成功的订单一共 1526 个,库存只有 1000 件 —— 超卖 526 件;MySQL 里的库存最终是 -526(负数 = 超卖)

异步落库有延迟窗口,Redis 一旦丢数据而用 MySQL 重建,落后的这段会被重复卖出。

分步演示

库存扣减
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇用一个 MySQL 26.7.0 实例(stock 表,默认 REPEATABLE-READ)和自己起的 Redis 8.10.2,32 个线程、3000 个买家各买 1 件;乐观锁最多重试 50 次;分片是 8 行、随机选一行再依次试;Redis 方案是 Lua 脚本检查并 DECR,成功的订单由另一个线程每批最多 500 个异步写进 MySQL;第三节把落库节奏放慢到每批 100 个、间隔 300 ms 以暴露窗口
    • 没有验证:Redis 开了 AOF / RDB 持久化后窗口有多大;Redis 主从切换下的丢失;库存预扣后订单取消的回补(第 6 到 9 篇的 TCC / Saga 范围);更大并发(几千线程)与真实网络;MySQL 的 SELECT ... FOR UPDATE 在不同隔离级别下的差别;热点行的 innodb 参数调优
    京ICP备2026067502号-1京公网安备11010502063487号