高并发抢购是小程序电商最容易出事故的场景:库存 100 件,实际卖出 107 单。本文复盘某社区团购平台大促拼团的超卖治理过程,完整呈现从数据库行锁、乐观锁到 Redis+Lua 原子预扣的方案演进与实测效果。
事故发生在一次晚间爆品开团:一款社区爆品限量 100 份,开抢后一分钟内涌入约 3000 个下单请求。活动结束后库存表显示已售 107 份,平台只能安抚超买用户并协调补货,客服投诉集中爆发。这类问题的根源只有一个——"查库存"和"扣库存"不是原子操作,多个请求同时读到同一份剩余量,各自判定有货后同时扣减。
把事故现场还原一下更能说明问题:请求 A 和请求 B 在同一毫秒读到库存均为 1,都通过了 if num >= 1 的判断,随后各自执行扣减,库存变成了 -1。请求量越大,这种碰撞的概率越高,超卖数量几乎随并发量线性增长。也就是说,只要扣减链路存在非原子的"读-判-写",超卖就不是概率问题,而是必然问题。
一、方案演进:三个阶段踩过的坑
1. 第一版:数据库行锁(synchronized 查改一体)
最初把查询和扣减放进同一个数据库事务,用 SELECT ... FOR UPDATE 锁住库存行。逻辑上正确,但开抢瞬间几千个请求在单行上排队,连接池迅速耗尽,大量请求超时报错,用户看到的是"开小差了"。锁是加上了,吞吐也锁没了。
2. 第二版:乐观锁版本号
改为 UPDATE stock SET num=num-1, version=version+1 WHERE id=? AND num>=1 AND version=?,失败即重试。超卖问题解决了,但热点行的更新冲突率极高,重试风暴让数据库 CPU 打满,高峰期平均响应时间超过 2 秒,抢购体验依然糟糕。
| 方案 | 正确性 | 瓶颈 | 适用场景 |
|---|---|---|---|
| 行锁 FOR UPDATE | 正确 | 单行锁排队,连接池耗尽 | 低并发、流量可预测 |
| 乐观锁版本号 | 正确 | 冲突重试风暴,CPU 打满 | 中等并发、冲突率低 |
| Redis+Lua 预扣 | 正确 | 需处理缓存与数据库一致性 | 高并发秒杀、拼团抢购 |
3. 最终版:Redis+Lua 原子预扣
核心思路是把"能不能买"的判断前移到 Redis,用一段 Lua 脚本把读库存、判断、扣减合并成一次原子操作,数据库只承接确定成功的订单写入:
-- inventory_deduct.lua
local stock = tonumber(redis.call('GET', KEYS[1]))
if not stock or stock < tonumber(ARGV[1]) then
return -1 -- 库存不足,直接拒绝
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return stock - tonumber(ARGV[1])
下单流程变为:请求先经网关限流与用户去重,再执行 Lua 预扣——返回 -1 直接提示"已抢完";预扣成功则写入订单并发送 MQ 异步落库,数据库最终把库存扣减结果同步回 Redis。单机 Redis 轻松承载每秒上万次原子扣减,数据库压力下降一个数量级。这个改动还有个隐性收益:预扣失败率本身就是实时的抢购热度信号,运营可以据此决定是否追加库存或延长活动,而改造前这些决策只能等第二天的数据报表。
实战提示:Lua 脚本中不要混用 GET 和 DECR 两次独立调用,任何非原子路径都会重新引入竞态窗口。判断与扣减必须在同一段脚本内完成。
二、三个必须解决的配套问题
1. 库存回补:取消与超时订单
预扣成功不等于最终成交。用户取消支付或 15 分钟未付款时,必须把数量加回 Redis。我们用延迟消息队列统一触发回补,并保证回补接口幂等——同一笔订单重复回补只生效一次,靠订单状态机兜底。
2. 缓存与数据库对账
Redis 与数据库可能出现短暂不一致,尤其是主从切换或脚本执行中断的瞬间。我们建立两条防线:每笔订单落库时校验扣减记录;每 5 分钟跑一次对账任务,以数据库为准修正 Redis 残差,差异超过阈值立即告警人工介入。对账任务的查询全部走从库,不给主库增加负担。
3. 防刷与公平性
同一用户、同一 IP、同一设备号三重去重,配合令牌桶限流把无效流量挡在预扣之前,避免脚本抢购挤占真实用户名额。开抢瞬间还会出现大量"点进来看一眼就退出"的浏览流量,我们把商品详情页做成静态缓存承载绝大部分读请求,让下单链路只服务真正提交订单的用户,读写分离后预扣接口的容量估算也变得简单直接。
三、上线效果
方案先在一场中等规模的活动上灰度验证了两周,对账残差稳定为零后才在全量大促中铺开。核心指标变化如下:
| 指标 | 改造前(乐观锁版) | 改造后 | 变化 |
|---|---|---|---|
| 超卖件数 | 单场最多 7 件 | 0 | 彻底消除 |
| 下单平均响应 | 2100ms | 120ms | -94% |
| 数据库峰值 QPS | 4200 | 380 | -91% |
| 接口报错率 | 6.8% | 0.2% | -97% |
一句话总结:高并发库存问题的本质是把"竞争"从数据库挪到内存中的原子操作里,数据库只做确定性写入。方案不追求复杂,追求每一步都可对账、可回补、可兜底。
四、避坑清单
- 预热先行:开抢前把活动库存主动载入 Redis 并校验数值,别指望第一次请求触发懒加载。
- 限流在前,扣减在后:先挡流量再谈扣减,Redis 再快也扛不住恶意刷单。
- 回补必须幂等:延迟消息可能重复投递,状态机+幂等键是底线。
- 预演要真实:上线前用压测工具按真实流量模型打一轮,重点观察连接池与 MQ 堆积。
- 监控到秒级:预扣成功率、回补量、对账残差三个指标进大盘,异常 1 分钟内告警。
大促拼团、爆品秒杀总出问题?
超卖治理和链路压测,从一次架构诊断开始。





