秒杀链路里,库存扣减和下单为什么要拆开

秒杀场景最容易暴露系统的并发问题。

平时一个普通下单接口,可能只需要校验库存、创建订单、扣减数据库库存,再返回结果。但到了秒杀场景,请求会在很短时间内集中打进来。如果还让每个请求都直接访问数据库,数据库会很快变成瓶颈。

这类链路最核心的问题其实就三个:

不能超卖
不能重复下单
不能让瞬时流量直接打到数据库

所以在这次设计里,我没有把“库存校验、库存扣减、订单落库”都放在一个同步接口里完成,而是把秒杀链路拆成了两段:

第一段:Redis 中完成资格判断和库存预扣
第二段:通过消息队列异步落库

前半段负责抗并发,后半段负责可靠写入。


一、为什么不能直接用数据库扣库存

数据库当然可以防止超卖。

比如可以写成:

UPDATE stock_table
SET stock = stock - 1
WHERE resource_id = ?
  AND stock > 0;

这个 SQL 在并发下是能保证库存不会扣成负数的。

但问题在于,秒杀时大量请求都会直接打到数据库。即使最后只有少数请求成功,失败请求也会参与数据库竞争。

高峰期数据库要同时承担:

库存判断
行锁竞争
订单插入
重复下单判断
事务提交

这些操作放在普通业务里没问题,但在秒杀流量下,数据库压力会被瞬间放大。

所以更合理的做法是:不要让所有请求都进入数据库。先在 Redis 层把大部分无效请求挡掉,只让真正抢到资格的请求进入后续下单流程。


二、用 Redis Lua 做原子资格判断

秒杀资格判断至少包含三件事:

库存是否充足
用户是否已经下过单
库存扣减

这三个动作不能拆开执行。

如果先查库存,再判断用户是否下单,再扣库存,中间任何一步都可能被并发请求插入,产生竞态问题。

所以这里使用 Redis Lua 脚本,把这几个操作放到一个原子执行单元里。

大致逻辑是:

1. 判断库存是否大于 0
2. 判断用户是否已经下单
3. 扣减 Redis 库存
4. 记录用户已下单
5. 返回资格判断结果

Redis 执行 Lua 脚本时是单线程原子执行的。也就是说,在同一时间,不会有另一个请求插进来修改同一份库存和用户集合。

这能解决两个关键问题:

库存不会被并发扣成负数
同一个用户不会在 Redis 层多次通过资格判断

这里的 Redis 扣减可以理解为“预扣库存”。真正的订单落库不在 Redis 里完成,而是交给后面的异步消费链路。


三、为什么要异步下单

如果 Redis 判断成功后,接口继续同步写数据库,那么数据库仍然会在秒杀瞬间承受较高压力。

虽然 Redis 已经过滤掉了失败请求,但成功请求还是会集中落到数据库。

所以这里把“抢购资格”和“订单落库”拆开:

用户请求
  ↓
Redis Lua 判断资格并预扣库存
  ↓
成功后生成订单消息
  ↓
发送到消息队列
  ↓
接口快速返回
  ↓
消费者异步创建订单

这样做的好处是削峰。

前端请求的瞬时高峰,不再等价于数据库写入的瞬时高峰。数据库只需要按照消费者的处理能力逐步消化消息。

也就是说,请求峰值被消息队列平滑掉了。


四、完整链路是怎么走的

脱敏后的核心链路可以概括为:

用户提交秒杀请求
  ↓
负载均衡转发到后端实例
  ↓
后端执行 Redis Lua 脚本
      - 判断库存
      - 判断用户是否已下单
      - 扣减 Redis 库存
      - 记录用户已抢购
  ↓
生成订单编号
  ↓
发送持久化订单消息
  ↓
接口返回抢购结果
  ↓
消费者异步消费消息
  ↓
判断订单是否已存在
  ↓
扣减数据库库存
  ↓
保存订单记录
  ↓
手动 ACK

这条链路里,Redis 负责挡住高并发,消息队列负责削峰,数据库负责最终落库。

每一层都有自己的职责,不能混在一起看。


五、Redis 预扣成功,但消息发送失败怎么办

这条链路里有一个很关键的异常点:

Redis 已经扣了库存
但是消息队列发送失败

如果不处理,这个用户就会出现“抢到了资格,但订单没有进入队列”的问题。

同时 Redis 库存也会少扣一次,用户下单标记也会被占用。

所以发送消息失败时,需要做补偿:

回滚 Redis 库存
删除用户已下单标记
返回失败或提示稍后重试

这一步不能忽略。

因为 Redis 预扣只是为了抗并发,不是最终订单事实。只有消息成功进入后续链路,才说明这个订单具备继续落库的条件。

当然,在更复杂的系统里,也可以通过本地消息表、事务消息或可靠事件表来进一步增强一致性。但在第一版秒杀链路里,至少要保证发送 MQ 失败时 Redis 状态能回滚。


六、为什么消费者还要做幂等

很多人会觉得,前面 Redis Lua 已经做了一人一单,消费者是不是就不用再判断了?

不行。

因为消息队列存在重复投递的可能。

例如:

消费者处理完订单
但是 ACK 发送失败
消息队列认为消息没有消费成功
于是重新投递

如果消费者不做幂等,就可能重复创建订单,甚至重复扣减数据库库存。

所以消费者消费订单消息时,第一步不是直接写库,而是先判断订单是否已经存在。

常见做法是:

根据 orderId 判断是否已处理
或者根据 userId + resourceId 唯一约束判断是否重复下单

如果订单已经存在,说明这条消息之前已经处理过,直接 ACK 即可。

如果不存在,再继续执行数据库库存扣减和订单保存。

这一步的核心思想是:

消息可以重复投递
但业务结果只能生效一次

七、数据库库存还要不要扣

Redis 已经扣了库存,数据库还要不要扣?

要扣。

Redis 的库存更像秒杀入口的快速判断和预扣,数据库才是最终数据落点。

消费者落库时仍然需要更新数据库库存,并保存订单记录。

这里数据库层也应该保留兜底条件,例如:

UPDATE stock_table
SET stock = stock - 1
WHERE resource_id = ?
  AND stock > 0;

这样即使 Redis 和数据库之间出现边界问题,数据库也不会轻易扣成负数。

不过正常情况下,能进入消息队列的请求已经经过 Redis 预扣,数据库承受的请求量会小很多。

数据库在这里主要承担最终一致性的落库职责,而不是直接抗秒杀入口流量。


八、为什么使用手动 ACK

消费者处理消息时,ACK 策略也很重要。

如果使用自动 ACK,消息一旦投递给消费者,队列就认为它处理成功了。

但实际情况可能是:

消息刚拿到
数据库还没写成功
服务就宕机了

如果这时消息已经被自动确认,就会造成订单丢失。

所以这里更适合使用手动 ACK。

流程是:

消费消息
  ↓
幂等判断
  ↓
扣减数据库库存
  ↓
保存订单
  ↓
事务提交成功
  ↓
手动 ACK

只有业务真正处理成功后,才确认消息。

如果处理失败,消息可以重新投递,或者进入重试、异常处理、死信队列等后续链路。

手动 ACK 的价值在于:它让“消息确认”和“业务落库成功”对齐。


九、一人一单为什么要放在 Redis 里判断

一人一单也可以放在数据库里做,比如给:

user_id + resource_id

加唯一索引。

但在秒杀入口,如果所有重复请求都打到数据库唯一索引上,数据库仍然会承受大量无效写入竞争。

所以更好的方式是前置到 Redis。

可以用一个 Set 记录已经成功抢到资格的用户:

seckill:users:{resourceId}

Lua 脚本里先判断当前用户是否已经存在于这个 Set。

如果存在,直接返回重复下单。

如果不存在,并且库存充足,再扣库存并把用户加入 Set。

这样同一个用户的大量重复请求会被 Redis 挡住,不会进入消息队列,也不会打到数据库。

数据库唯一约束仍然可以保留,作为最终兜底,而不是主要抗流量手段。


十、多实例下为什么还要压测

单机测试只能证明单 JVM 内逻辑基本正确。

但秒杀系统通常是多实例部署,请求可能被负载均衡转发到不同后端。

这种情况下,真正需要验证的是:

不同实例同时执行 Lua,会不会超卖
不同实例同时发送消息,会不会重复下单
多个消费者并发消费,会不会重复落库
Redis 库存和数据库库存最终是否一致
队列消息最终能不能被消费完

所以压测时应该至少模拟:

负载均衡
多个后端实例
Redis
消息队列
数据库
多个消费者

只有多实例压测通过,才能说明这条链路在跨 JVM 场景下也是可靠的。


十一、压测主要看哪些指标

这类压测不能只看 QPS。

QPS 只能说明接口吞吐,不能说明业务正确。

秒杀链路更重要的是业务校验。

我会重点看这些结果:

最终订单数是否等于初始库存
Redis 库存是否扣减到预期值
数据库库存是否扣减到预期值
是否存在重复下单用户
是否出现超卖
队列是否最终清空
消费者是否正常工作
错误率是否可接受

在一次多实例压测中,初始库存设置为一个固定值,压测请求数远大于库存。最终结果是:

订单数等于初始库存
Redis 和数据库库存都扣减到 0
重复下单用户数为 0
没有出现超卖
消息队列最终清空

这比单纯说接口 QPS 更有意义。

另外还做了同一用户重复请求的压测。大量重复请求最终只生成一条订单,说明 Redis 层的一人一单判断生效了。


十二、这条链路的一致性边界

这套方案不是强事务模型。

Redis、消息队列、数据库之间无法天然放在一个本地事务里。

所以它采用的是一种最终一致性的链路:

Redis 先做资格判断和预扣
消息队列负责异步传递订单
数据库消费者最终落库
失败时通过回滚、重试、幂等兜底

这里最容易出问题的边界主要有三个。

1. Redis 成功,消息发送失败

需要回滚 Redis 预扣库存和用户标记。

2. 消息发送成功,消费者处理失败

依赖消息重试、手动 ACK、死信或异常记录,保证消息后续还能被处理。

3. 消息重复投递

依赖消费者幂等和数据库唯一约束,保证重复消息不会产生重复订单。

所以这条链路的重点不是追求 Redis、MQ、MySQL 的强事务,而是把每个失败点都补上兜底。


十三、为什么不是一开始就用更复杂的分布式事务

秒杀链路更关注吞吐和可用性。

如果为了追求强一致,把请求链路设计得很重,可能会让秒杀入口承受不了高峰流量。

这里的设计取舍是:

入口阶段尽量轻
高并发判断放到 Redis
落库阶段异步削峰
通过幂等和补偿保证最终正确

对于秒杀这种高并发、库存有限、允许短暂异步落库的场景,这个取舍比较合理。

更复杂的方案,比如事务消息、本地消息表、库存流水、对账补偿,也可以继续演进,但不一定要在第一版就全部堆上。

架构设计不是越复杂越好,关键是当前风险有没有被覆盖。


十四、这套方案还能继续优化什么

这条链路已经能覆盖基本秒杀场景,但后续仍然有继续优化空间。

1. 增加本地消息表

如果希望进一步提升“Redis 扣减成功后消息一定能发出去”的可靠性,可以引入本地消息表。

请求线程先写入本地消息记录,再由后台任务可靠投递消息队列。

这样比单纯 try-catch 发送 MQ 更稳,但实现复杂度也会上升。

2. 增加订单状态机

订单落库后,可以把订单状态拆得更清楚:

INIT
CREATED
FAILED
CANCELLED

方便后续支付、取消、超时关单等流程扩展。

3. 增加库存对账

定时对比:

Redis 库存
数据库库存
订单数量
消息消费记录

发现不一致时触发告警或补偿。

4. 增加死信队列

消费者多次失败后,不应该无限重试。

可以把超过重试次数的消息转入死信队列,后续人工或补偿任务处理。

5. 增加限流和排队

秒杀入口还可以继续做:

用户级限流
接口级限流
验证码或滑块
排队等待页
热点资源预热

这些更多是入口流量治理,和 Redis Lua + MQ 下单链路可以配合使用。


十五、总结

这次秒杀链路设计给我的体会是,秒杀系统最难的不是写一个扣库存接口,而是把高并发下的正确性和数据库压力同时处理好。

如果所有请求都直接进数据库,虽然可以通过条件更新防止超卖,但数据库会承受大量瞬时压力。

更合理的做法是把链路拆开:

Redis Lua:负责原子资格判断和预扣库存
消息队列:负责削峰和异步传递订单
消费者:负责幂等落库
数据库:负责最终订单和库存事实

这条链路里,每一层都解决一个明确问题:

Lua 解决并发原子性
Redis 解决入口抗压
MQ 解决削峰
手动 ACK 解决消息可靠确认
幂等消费解决重复投递
数据库唯一约束和条件扣减做最终兜底

真正重要的是,不要把“下单成功”只理解成接口返回成功。

在异步秒杀链路里,用户拿到的是抢购资格,订单真正可靠落库还要经过消息队列和消费者处理。

所以这类系统一定要想清楚:

Redis 扣了但消息没发出去怎么办
消息发了但消费失败怎么办
消息重复投递怎么办
同一用户重复请求怎么办
多实例并发下库存会不会被扣穿
最终 Redis、数据库和订单数量能不能对上

这些边界想清楚了,秒杀链路才不是一个简单的高并发 Demo,而是一条能解释、能验证、能恢复的工程链路。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐