防止促销券秒空时库存超卖,核心是让“扣减库存”和“锁定资格”在同一步完成,并封死回滚漏洞,而不是靠抢购结束后对账补救。
秒空场景下,超卖是怎么发生的
用户抢到券的瞬间,系统通常要干三件事:校验库存、扣减库存、生成券码,很多超卖问题出在“先校验、再扣减”的间隙里,两个并发请求同时读到库存还剩1张,双双校验通过,然后依次扣减,结果就发出了两张券。
另一个常见漏洞是“先发券、后扣库存”,用户看到抢券成功,但库存扣减失败或事务回滚,券码已经异步发出去,导致实际发出数量超过设定库存。
还有一类隐蔽问题:库存扣减了,但用户没支付或放弃核销,库存没回补,表面看不是超卖,但下一轮活动时可用库存越算越少,运营误以为全部售罄,实际库存数据早已失真。
行业共识认为,99%的秒空超卖都不是“手速”问题,而是库存操作没有做到原子化。
库存防超卖的原子操作设计
把扣减动作放到数据库事务里
最稳妥的做法是使用数据库的UPDATE语句做条件扣减,
UPDATE coupon_stock SET remaining = remaining - 1 WHERE id = ? AND remaining > 0;
这条语句自带行锁,数据库会保证同一时刻只有一个请求能修改这条记录,受影响行数为0,说明库存不足,直接返回“已抢光”。
操作路径:
- 先执行条件扣减SQL
- 如果影响行数为1,再插入用户领取记录
- 如果插入记录失败,回滚事务并回补库存
这个顺序保证“扣减”和“记录”在同一个事务里,任何一步失败都整体回滚,不会出现多发。
用Redis做前置过滤,但别让它做最终裁决
秒杀场景流量极大,直接打数据库容易把连接池打满,通常先用Redis的DECR或Lua脚本做预扣:
if redis.call('get', key) <= 0 then
return 0
end
redis.call('decr', key)
return 1
Lua脚本保证判断和扣减是原子的,避免并发下超卖。
但Redis在高可用集群下主从切换可能丢数据,一旦库存记录丢失或未同步,数据库层又没有兜底,就会超卖,所以Redis只能做请求过滤和削峰,最终库存扣减必须回归数据库事务,Redis扣成功了,数据库扣失败的时候,要回补Redis库存。

订单有效期与库存回补策略
抢到的券如果未支付或过期作废,库存应在订单关闭时自动回补,回补操作同样要走条件更新:
UPDATE coupon_stock SET remaining = remaining + 1 WHERE id = ?;
同时将用户订单状态置为“已关闭”,注意先回补库存再改状态,避免用户恰好第二次抢券时读到旧状态。
削峰填谷:把瞬时流量摊开
分层校验挡住无效请求
秒空场景最怕的不是超卖,而是大量无效请求直接怼到扣库存接口,需要在入口做多层筛选:
- 用户登录态校验,未登录直接拦截
- 活动时间校验,未开始或已结束直接返回
- 用户参与资格校验,黑名单、限购次数、设备指纹等
- 令牌桶或滑动窗口限流,控制入口流量
每一层都前置,越早拦截越能保护库存扣减链路。
消息队列异步落库的“坑”
有团队为了提升吞吐,把“扣库存”动作丢进消息队列异步执行,这非常危险,消息消费有延迟,用户端已经显示“抢券成功”,实际库存还没扣,如果队列积压,等真正扣减时可能已经超卖。
如果非要异步,必须做到:
- 生产者发出消息前先锁住用户维度,防止同用户重复提交
- 消费者消费消息时仍要执行数据库条件扣减
- 消费失败要重试,重试仍失败则走人工补偿
但我的建议是:核心凭证类库存,不要异步扣减,异步只适合“发放后允许超发5%”的营销品,不适合真实资金或稀缺权益。
Redis与数据库的一致性兜底方案
最终一致性的标准操作流程
比较成熟的方案是“Redis预扣 + 数据库确认 + 缓冲回补”,具体流程:
- 用户请求进来,先走Lua脚本预扣Redis库存,扣减成功才继续
- 扣减成功后将请求写入待处理队列
- 消费者逐个处理队列,执行数据库条件扣减
- 数据库扣减失败时,自动回补Redis库存并记录失败日志
- 数据库扣减成功后,生成券码并推送用户
这个流程里Redis和数据库的数据短暂不一致是允许的,但最终必须一致。
对账任务作为最后防线
即使前面流程都正确,仍需定时对账,每5分钟跑一次任务,比对Redis剩余库存、数据库库存记录和实际已发放数量,发现差异就触发告警,同时锁定活动入口,防止继续超卖。

对账逻辑:
- 统计所有“待扣减”和“已扣减”单据
- 与Redis剩余库存相加,是否等于初始库存
- 不等于则定位到具体操作链路,人工介入
高并发扣库存的代码级注意事项
避免在循环里查库存
有团队写了类似这样的代码:
for(int i = 0; i < reqList.size(); i++){
int stock = queryStock(i);
if(stock > 0){
reduceStock(i);
}
}
每个请求都先查一遍库存再扣,查询操作并发下产生大量行读锁,事务还会升级为表锁,直接拖垮数据库,应改成单条批量条件更新:
UPDATE coupon_stock SET remaining = remaining - :batchSize WHERE id = :id AND remaining >= :batchSize;
数据库连接池大小与超时时间
秒空场景大量请求等待数据库连接,连接池默认20-50个完全不够,业内经验是,连接池线程数不要超过CPU核心数乘以3,同时设置超时时间500毫秒以内,快速失败的请求直接返回“活动火爆”,不要堆积。
事务范围尽量短
事务里只放“扣减库存”和“插入领取记录”两步,不要做远程调用、发短信、生成二维码等耗时操作,这些放到事务提交后的异步回调里执行。
实践原则:
- 远程调用一律移出事务
- 事务内不sleep
- 锁粒度用行锁,不要用表锁
- 尽量用乐观锁条件更新替代先查后改
业务规则层面防超卖的老办法为什么失效
很多人提到“超卖就超卖吧,多发几张券成本可控”,但促销券和普通商品不同,优惠券超发意味着平台要承担额外补贴,一张满500减300的券超卖1000张,直接损失30万。
限购规则也能缓解超卖,例如每个用户限抢1张,配合用户ID维度分布式锁,防止一个人刷多张,但分布式锁只能拦“同一用户”,拦不住“多个不同用户同时抢最后1张”的本质并发问题,所以业务规则是辅助,原子扣减才是根本。
压测时最容易暴露超卖问题的三个场景
首次参与秒杀的新手团队,压测时建议重点验证以下场景:
- 并发数超过库存数的10倍,例如库存500张,5000并发同时抢,观察最终发放数量
- 同一个用户毫秒级重复请求,带相同设备指纹和账号,观察是否发多张
- 数据库主从延迟下,扣减走主库,查询走了从库读到旧库存,观察是否出现判断偏差

压测工具用JMeter或wrk都可,重点看事务成功率、数据库慢查询数和最终库存是否为0。
超卖事故发生后的紧急止血步骤
真到了已经超卖那一步,按以下顺序处理:
- 立即关闭活动入口,在网关层直接拒绝所有抢券请求
- 查数据库实际发放记录,找出超出库存的具体订单
- 对超发用户推送道歉消息,给出两种方案:一种是活动方承认超发并履约,另一种是补偿等值无门槛券或退款
- 复盘库存扣减日志,找出是Redis主从切换、数据库死锁还是事务范围过长导致的漏洞
止血速度比追责重要,多数情况下,用户能接受“超发但履约”的方案,反感的是平台隐瞒或强行收回。
促销券秒空库存超卖防护的最终结论
防超卖没有银弹,就是一条硬规矩:库存扣减必须是有条件的原子操作,前置Redis拦截流量,数据库事务做最终裁决,定时对账扫尾,任何一环都不能跳过,秒空活动上线前,多花两小时做并发压测,比事后赔偿损失便宜得多。
促销券秒空时库存超卖怎么预防?相关问题解答
问:促销券秒空时库存超卖一般发生在哪个环节?
答:多发环节,典型是用户请求并发到达,数据库先查库存再扣减的间隙被同时穿过,或者异步发券流程里“发券成功”和“扣库存成功”不在同一个事务内,另一个高发点是对账缺失,库存回补失败导致实际发放数大于设定数。
问:库存超卖防护用Redis锁和数据库锁哪个更可靠?
答:数据库锁更可靠,Redis锁速度快,但存在主从切换丢锁、锁过期误删等风险,适合作为前置过滤,数据库条件更新语句UPDATE ... WHERE remaining > 0自带原子性,是最终防线,高并发场景下两者结合使用,各司其职。
问:秒空结束后发现超卖,用户已经领到券了,应该怎么处理?
答:先确认超发数量,然后根据成本选择全部履约或部分履约,全部履约虽然短期损失补贴,但保住平台信誉,部分履约则需要逐单联系用户协商,给出等价补偿,同时关闭活动入口,修正库存扣减代码,避免下次再犯。