秒杀库存扣减与异步下单:Redis Lua、消息队列和幂等消费的工程取舍
秒杀链路里,库存扣减和下单为什么要拆开
秒杀场景最容易暴露系统的并发问题。
平时一个普通下单接口,可能只需要校验库存、创建订单、扣减数据库库存,再返回结果。但到了秒杀场景,请求会在很短时间内集中打进来。如果还让每个请求都直接访问数据库,数据库会很快变成瓶颈。
这类链路最核心的问题其实就三个:
不能超卖
不能重复下单
不能让瞬时流量直接打到数据库
所以在这次设计里,我没有把“库存校验、库存扣减、订单落库”都放在一个同步接口里完成,而是把秒杀链路拆成了两段:
第一段: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,而是一条能解释、能验证、能恢复的工程链路。
更多推荐



所有评论(0)