2026年8月8日

秒杀系统 03 如何正确的扣掉库存

核心交易链路:库存怎么正确地被"卖掉"**。

一、库存预热

要解决的问题: 如果每次用户抢购都直接去查询MySQL里的库存字段,10万个请求同时读写同一行数据,数据库连接池会瞬间被打满,行锁竞争会让响应时间从几毫秒暴涨到几秒甚至超时。

具体做法:

  1. 秒杀活动开始之前(通常提前几分钟到几小时),把商品库存数量从MySQL加载到Redis里,用一个Key存储,比如: seckill:stock:{商品ID}1000
  2. 活动期间,所有的库存读取和扣减操作只针对Redis进行,完全不碰MySQL
  3. 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做最终持久化。

三、幂等性设计

要解决的问题: 用户网络卡顿重复点击、或者前端重试机制、或者消息队列的消息被重复消费,都可能导致同一个用户对同一次抢购,后端处理了两次,如果没做幂等控制,一个人可能扣两次库存、生成两个订单。

具体做法:

  1. 请求层面:前端生成一个唯一的请求ID(比如UUID),后端用Redis的SETNX在处理请求前先占位:
    SETNX seckill:request:{requestId}  1   EX 60
    
    如果SETNX返回0,说明这个请求已经处理过或正在处理,直接拒绝,不再重复走后续逻辑
  2. 用户维度:用用户ID+商品ID做唯一标记,一个用户对同一件秒杀商品只允许成功一次,提前用Redis的Set结构或者SETNX判断"这个用户是否已经抢购过"
  3. 数据库层面兜底:订单表对用户ID+商品ID(或者活动ID)建唯一索引,即使前面的逻辑都失效了,数据库插入时如果违反唯一索引会直接报错,应用层捕获这个异常也能防止重复下单

四、队列削峰——最后一道保护

为什么需要这一步: 即使前面Redis扣库存已经做到了原子性、极快的响应速度,但"扣库存成功"之后,还要走创建订单、扣减用户余额/发优惠券、更新数据库、发短信通知等一系列相对"重"的操作。如果这些操作也在用户请求的主线程里同步完成,数据库和其他服务照样会被瞬间冲垮。

具体做法:

  1. 用户请求进来,先经过Redis原子扣减库存(耗时可能只有1-2毫秒,这一步必须同步完成,因为要立刻告诉用户"抢到了"还是"没抢到")
  2. 库存扣减成功后,不直接同步创建订单,而是把这个"抢购成功"的事件封装成一条消息,扔进消息队列(Kafka/RocketMQ/RabbitMQ)
  3. 立刻给用户返回"恭喜您抢购成功,订单生成中,请稍后查看"这样的响应(注意:这里给用户的不是"订单已完成",而是"排队处理中")
  4. 后端有一批消费者服务,按照数据库能承受的速度(比如控制在每秒2000条),匀速地从消息队列里取消息,逐条创建真正的订单、扣减用户余额、更新数据库
  5. 用户可以通过轮询接口或者WebSocket推送,查看自己的订单是否已经生成成功

举个具体的数字例子: 1万个商品,分成10份每份1000件秒杀。假设某一时刻有50万人同时点击"抢购",但Redis库存原子扣减这一步在几秒内就能把1000份库存分配完毕(因为Redis够快),这1000个"抢购成功"事件被放进消息队列。数据库如果只能撑住每秒500的写入速度,那消费者就按每秒500条的速度处理这1000条消息,大概2秒钟就能把所有订单落库完成——用户感知到的是"秒杀"(库存分配是瞬间完成的),但数据库实际承受的是被拉长、削平之后的平稳写入,这就是"削峰"的本质。

队列削峰要注意的问题:

  • 消息丢失:要用支持持久化的消息队列(Kafka/RocketMQ),并且开启消息确认机制,防止服务宕机导致"库存已经扣了但订单没生成"
  • 消费者处理失败怎么办:需要有重试机制和死信队列,多次重试仍失败的消息进入死信队列,人工介入处理,并触发退款/回滚库存的补偿逻辑
  • 消费速度追不上生产速度:可以通过增加消费者实例数量(多个消费者组同时消费不同分区)来提升处理能力,做水平扩展

五、最终一致性对账

即使做了以上所有措施,严谨的秒杀系统还会有一道"事后兜底"机制:

  • 活动结束后,跑一个对账任务,核对Redis里扣减的库存总数、消息队列里成功处理的订单数、数据库里真实生成的订单数,这三者应该完全一致
  • 如果发现不一致(比如Redis显示卖出1000件,但数据库只有998个订单),说明中间某个环节丢数据了,需要触发人工介入或者自动补偿流程(比如给这2个用户主动联系说明情况、退款或者重新发货)

三个阶段串联起来看完整链路

【第一阶段:入口防护】
CDN静态页 → 加密链接 → 验证码/错峰 → 网关拦截恶意请求

【第二阶段:内部自保】
独立部署秒杀服务 → 网关/接口限流 → 熔断保护下游调用 → 触发降级保核心链路

【第三阶段:核心交易】
库存预热到Redis → Lua脚本原子扣减(防超卖) → 幂等性校验(防重复) 
→ 扣减成功后消息入队(削峰)→ 消费者匀速处理生成订单 → 事后对账兜底
秒杀系统 03 如何正确的扣掉库存 · Kevin's Blog