秒杀和下单最怕超卖。几种常见的库存扣减方式——读后写、加锁、条件 UPDATE、乐观锁、库存分片、Redis 预扣——到底哪些防得住?各自多快?Redis 预扣的代价是什么?这篇用真实的 MySQL 和 Redis,用同样的 3000 个买家抢 1000 件,把它们摆在一起量。
做法 | 成功的单数 | 超卖(成功数 - 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 被杀时:已经对用户确认成功的订单 600 个,其中已经落到 MySQL 的 2 个(落后 598 个);重启后按 MySQL 里的库存 898 重新初始化 Redis(此时 MySQL 库存应该是 400) 之后的买家继续抢:最终对用户确认成功的订单一共 1526 个,库存只有 1000 件 —— 超卖 526 件;MySQL 里的库存最终是 -526(负数 = 超卖)
异步落库有延迟窗口,Redis 一旦丢数据而用 MySQL 重建,落后的这段会被重复卖出。