2026年8月8日
秒杀系统 03 如何正确的扣掉库存
核心交易链路:库存怎么正确地被"卖掉"**。
一、库存预热
要解决的问题: 如果每次用户抢购都直接去查询MySQL里的库存字段,10万个请求同时读写同一行数据,数据库连接池会瞬间被打满,行锁竞争会让响应时间从几毫秒暴涨到几秒甚至超时。
具体做法:
- 秒杀活动开始之前(通常提前几分钟到几小时),把商品库存数量从MySQL加载到Redis里,用一个Key存储,比如:
seckill:stock:{商品ID}→1000 - 活动期间,所有的库存读取和扣减操作只针对Redis进行,完全不碰MySQL
- MySQL在这个阶段只是一个"最终归档"的角色,不参与实时的库存判断逻辑
为什么不直接读MySQL做校验? 因为"读"和"写"分离开来看:秒杀读多写少,如果每次扣减前还要先SELECT一次库存判断是否>0,这个"先读后写"的过程在高并发下天然存在竞态条件(两个请求同时读到库存为1,都以为自己能买,结果都写成功,库存变成-1),所以必须把"判断+扣减"合并成一个不可分割的原子操作,这就引出下一个问题。
二、原子扣减——防超卖的核心
这是整个系统最容易出错、也是面试最爱刨根问底的地方。
方案一:Redis + Lua脚本(最主流的做法)
为什么单纯用Redis的DECR不够安全: 如果代码逻辑是这样写的:
stock = GET stock:1001 // 读取库存
if stock > 0:
DECR stock:1001 // 扣减库存
这两步之间不是原子的。假设库存是1,两个请求几乎同时执行到"读取库存"这一步,都读到1,都判断"大于0可以买",然后都执行了DECR,库存变成-1,这就是超卖。
正确做法:用Lua脚本把"判断+扣减"这两步打包成一个原子操作
Redis执行Lua脚本的过程本身是单线程、不可被打断的,所以脚本里的逻辑等价于一个"事务":
-- 参数:KEYS[1] = 库存的key
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock > 0 then
redis.call('DECR', KEYS[1])
return 1 -- 成功
else
return 0 -- 库存不足,失败
end这样无论多少个请求同时打过来,Redis会把它们一个个串行执行这段脚本,"读取"和"扣减"之间不可能被别的请求插进来,从根本上杜绝了超卖。
方案二:数据库乐观锁(作为MySQL层面的兜底)
如果某些场景必须直接操作数据库(比如库存量较小、不适合搞Redis这套),用版本号或者条件更新来实现原子性:
UPDATE stock
SET count = count - 1, version = version + 1
WHERE product_id = 1001 AND count > 0 AND version = 当前读到的version这条SQL本身是原子的(MySQL保证单条UPDATE语句的原子性),WHERE count > 0 这个条件保证了不会扣成负数——即使100个并发请求同时执行这条SQL,MySQL的行锁机制会让它们排队执行,每次执行时都会重新判断count > 0这个条件,所以扣到0之后,后续请求这条UPDATE会因为条件不满足而影响0行,应用层判断"影响行数=0"就知道库存不足了。
两种方案怎么选:
- Redis+Lua:性能极高(单机Redis能扛10万+ QPS),但需要额外处理Redis和数据库的最终一致性问题
- 数据库乐观锁:强一致,不需要额外同步,但数据库的QPS承载能力远低于Redis,只适合库存量本身不大、并发不是特别夸张的场景
实际项目里通常是两者结合:Redis做第一道拦截(挡掉99%的无效请求),真正"扣减成功"的少量请求,才会异步落到MySQL做最终持久化。
三、幂等性设计
要解决的问题: 用户网络卡顿重复点击、或者前端重试机制、或者消息队列的消息被重复消费,都可能导致同一个用户对同一次抢购,后端处理了两次,如果没做幂等控制,一个人可能扣两次库存、生成两个订单。
具体做法:
- 请求层面:前端生成一个唯一的请求ID(比如UUID),后端用Redis的
SETNX在处理请求前先占位:
如果SETNX seckill:request:{requestId} 1 EX 60SETNX返回0,说明这个请求已经处理过或正在处理,直接拒绝,不再重复走后续逻辑 - 用户维度:用
用户ID+商品ID做唯一标记,一个用户对同一件秒杀商品只允许成功一次,提前用Redis的Set结构或者SETNX判断"这个用户是否已经抢购过" - 数据库层面兜底:订单表对
用户ID+商品ID(或者活动ID)建唯一索引,即使前面的逻辑都失效了,数据库插入时如果违反唯一索引会直接报错,应用层捕获这个异常也能防止重复下单
四、队列削峰——最后一道保护
为什么需要这一步: 即使前面Redis扣库存已经做到了原子性、极快的响应速度,但"扣库存成功"之后,还要走创建订单、扣减用户余额/发优惠券、更新数据库、发短信通知等一系列相对"重"的操作。如果这些操作也在用户请求的主线程里同步完成,数据库和其他服务照样会被瞬间冲垮。
具体做法:
- 用户请求进来,先经过Redis原子扣减库存(耗时可能只有1-2毫秒,这一步必须同步完成,因为要立刻告诉用户"抢到了"还是"没抢到")
- 库存扣减成功后,不直接同步创建订单,而是把这个"抢购成功"的事件封装成一条消息,扔进消息队列(Kafka/RocketMQ/RabbitMQ)
- 立刻给用户返回"恭喜您抢购成功,订单生成中,请稍后查看"这样的响应(注意:这里给用户的不是"订单已完成",而是"排队处理中")
- 后端有一批消费者服务,按照数据库能承受的速度(比如控制在每秒2000条),匀速地从消息队列里取消息,逐条创建真正的订单、扣减用户余额、更新数据库
- 用户可以通过轮询接口或者WebSocket推送,查看自己的订单是否已经生成成功
举个具体的数字例子: 1万个商品,分成10份每份1000件秒杀。假设某一时刻有50万人同时点击"抢购",但Redis库存原子扣减这一步在几秒内就能把1000份库存分配完毕(因为Redis够快),这1000个"抢购成功"事件被放进消息队列。数据库如果只能撑住每秒500的写入速度,那消费者就按每秒500条的速度处理这1000条消息,大概2秒钟就能把所有订单落库完成——用户感知到的是"秒杀"(库存分配是瞬间完成的),但数据库实际承受的是被拉长、削平之后的平稳写入,这就是"削峰"的本质。
队列削峰要注意的问题:
- 消息丢失:要用支持持久化的消息队列(Kafka/RocketMQ),并且开启消息确认机制,防止服务宕机导致"库存已经扣了但订单没生成"
- 消费者处理失败怎么办:需要有重试机制和死信队列,多次重试仍失败的消息进入死信队列,人工介入处理,并触发退款/回滚库存的补偿逻辑
- 消费速度追不上生产速度:可以通过增加消费者实例数量(多个消费者组同时消费不同分区)来提升处理能力,做水平扩展
五、最终一致性对账
即使做了以上所有措施,严谨的秒杀系统还会有一道"事后兜底"机制:
- 活动结束后,跑一个对账任务,核对Redis里扣减的库存总数、消息队列里成功处理的订单数、数据库里真实生成的订单数,这三者应该完全一致
- 如果发现不一致(比如Redis显示卖出1000件,但数据库只有998个订单),说明中间某个环节丢数据了,需要触发人工介入或者自动补偿流程(比如给这2个用户主动联系说明情况、退款或者重新发货)
三个阶段串联起来看完整链路
【第一阶段:入口防护】
CDN静态页 → 加密链接 → 验证码/错峰 → 网关拦截恶意请求
【第二阶段:内部自保】
独立部署秒杀服务 → 网关/接口限流 → 熔断保护下游调用 → 触发降级保核心链路
【第三阶段:核心交易】
库存预热到Redis → Lua脚本原子扣减(防超卖) → 幂等性校验(防重复)
→ 扣减成功后消息入队(削峰)→ 消费者匀速处理生成订单 → 事后对账兜底