设计秒杀系统时,我们很容易先想到 Redis、MQ、分库分表,甚至马上开始画一张很复杂的架构图。
但我觉得,真正应该先回答的问题是:
面对百万级瞬时请求,系统怎样避免让百万请求都进入数据库?
秒杀的成功订单通常远少于请求量。架构设计的重点不是让所有请求都被处理,而是尽早过滤无效请求、控制进入核心链路的流量,并保证库存和订单最终正确。
下面是我对一套高并发秒杀系统的设计梳理。这里的百万级流量是设计目标,不代表已经完成对应规模的线上部署或压测。
一、先明确系统目标
我先把业务目标拆成四项:
- 不能超卖;
- 同一用户不能重复购买;
- 峰值流量不能压垮数据库;
- 用户可以异步查询秒杀结果,不要求每个请求都同步完成下单。
这几个目标之间存在取舍。例如,为了保护系统,入口可以限流;为了应对突发流量,可以让用户排队;但这些机制都意味着部分请求会被拒绝或等待。系统需要把"请求已受理"和"订单创建成功"区分开。
二、整体架构
用户请求
↓
CDN:承载静态页面和活动配置
↓
WAF / 风控:识别异常流量
↓
API 网关:鉴权、多维度限流、活动窗口校验
↓
Admission Service:资格和幂等校验
↓
RocketMQ / Kafka:缓冲突发请求
↓
秒杀消费者
↓
Redis:原子预占库存
↓
MySQL:最终库存扣减、订单创建、Outbox 写入
↓
MQ:支付、通知、积分等异步下游
↓
对账与补偿:修复未完成或不一致状态
可以把它看成一条逐步收窄的漏斗:前面负责挡住无效和超出容量的流量,后面只处理经过筛选的有效请求。
三、入口层:尽量让无效请求停在前面
活动页面、图片、倒计时脚本和活动规则等静态内容放在 CDN,减少应用服务的回源压力。库存、用户资格、订单状态等动态信息不能当普通静态资源缓存。
请求进入 API 网关后,可以按多个维度限流:
- 单 IP;
- 单用户;
- 单设备;
- 单活动;
- 系统整体负载。
只按 IP 限流容易被代理绕过,因此需要结合用户、设备和活动级别控制。对于明显异常的请求,还可以在 WAF 或风控层使用验证码、设备特征和行为规则进行拦截。
网关返回结果也要区分清楚,例如:
- 活动未开始;
- 用户无资格;
- 请求过于频繁;
- 活动库存已售罄;
- 系统当前过载。
这样既便于客户端展示,也方便监控系统区分业务拒绝与系统故障。
通过网关的请求进入 Admission Service,完成轻量资格检查、活动状态检查和请求幂等校验,然后生成 requestId 并提交到消息队列。
接口返回 ACCEPTED 只代表请求已被系统受理,不代表用户已经抢到商品。用户可以凭 requestId 查询最终状态。如果客户端没有收到响应而重试,应复用同一个幂等键,避免一次操作变成多条独立请求。
四、MQ 削峰:把接入速度和处理速度解耦
秒杀请求不应该直接打到数据库。MQ 可以把瞬时请求转换成消费者可控的处理速率。
消息中可以携带:
messageId
requestId
activityId
skuId
userId
eventType
version
生产端只有在发送得到明确确认后,才向客户端返回"已受理"。如果发送超时,不能简单地重新生成一个 requestId 再发送,否则可能产生重复业务请求。
消费者数量、线程池大小、数据库连接池都要根据实际处理能力设置。MQ 能暂存消息,不代表可以无限接收而不限制积压。如果队列积压超过可接受范围,应降低入口接纳速率、限流或暂时关闭活动,不能让积压无限增长。
分区策略要结合业务顺序和吞吐选择。若需要保证同一库存桶内的消息顺序,可以按库存桶作为分区键;如果业务不要求严格顺序,就不必为了顺序牺牲不必要的并行度。最终方案需要通过压测验证。
五、Redis 库存预占:快,但不是最终正确性的唯一保障
消费者收到消息后,可以通过 Redis Lua 脚本原子完成一组检查和操作:
- 检查活动是否有效;
- 检查当前请求是否已处理;
- 检查对应库存桶是否还有可用库存;
- 扣减 Redis 中的可用库存;
- 创建带有
reservationId的预占记录。
通过一次原子脚本执行,可以避免多个消费者同时读到同一份可用库存再重复扣减。预占记录需要有状态和有效期,避免消费者宕机后库存一直被占住。
为什么需要库存分桶
Redis Cluster 按 Key 分片。同一个 Key 始终由同一个分片处理,因此把一个爆款 SKU 的库存全部放在一个 Key 上,可能让这个 Key 成为热点;增加 Redis 节点并不会自动拆分单 Key 的访问压力。
可以把库存拆成多个独立桶:
css
seckill:stock:{activityId}:{skuId}:{bucketId}
例如将总库存分配到多个桶中,让不同 Key 有机会分布到不同 Redis 分片。桶的数量和库存分配需要根据集群规模和压测结果确定。
这里还要处理一个细节:如果用户只被路由到一个库存桶,而这个桶提前卖完、其他桶还有库存,就可能出现"局部售罄"。因此路由策略需要支持有限的备用桶尝试,或者通过合理的库存配额和路由机制降低库存分布不均。分桶解决的是热点问题,不会自动解决库存调度问题。
Redis 预占的目的是快速筛选和控制并发,不能单独作为最终的防超卖保证。
六、MySQL 最终确认:把业务正确性落在事务和约束上
Redis 预占成功后,消费者进入 MySQL 事务完成最终确认。事务内可以包括:
- 根据活动和用户创建订单;
- 使用条件更新扣减数据库库存;
- 更新预占记录状态;
- 写入 Outbox 事件。
订单表可以使用唯一约束阻止重复购买,例如:
scss
UNIQUE(activity_id, user_id)
UNIQUE(request_id)
UNIQUE(reservation_id)
库存扣减使用条件更新,例如只有 available_stock > 0 时才能扣减。即使 Redis 发生数据异常或重复投递,数据库约束仍然是最终兜底。
订单、库存流水、reservation 状态和 Outbox 事件应在同一个本地事务中提交或回滚。这样可以避免订单已创建但后续事件没有可靠记录的问题。
如果数据库事务失败,不能直接对 Redis 执行无条件 INCR。因为可能出现数据库已经提交、客户端却没有收到结果的情况。库存释放应该根据 reservationId 和状态执行幂等补偿,例如只有 RESERVED 状态可以转换为 RELEASED,且只能成功一次。
七、订单状态机:限制状态跳转
订单状态可以设计为:
markdown
CREATING → CREATED → PAYING → PAID
└────→ CLOSED
必要时还可以设置 FAILED、REFUNDING 等状态。所有状态变更都应该带当前状态或版本条件,避免重复回调和并发更新造成非法跳转。
例如:
- 支付回调重复到达,订单已经是
PAID,直接返回成功; - 订单已经
CLOSED,收到支付成功回调,不能简单改回PAID,需要进入退款或人工补偿流程; - 订单超时未支付,通过延迟消息或定时任务关闭订单,并幂等释放库存。
状态机负责约束业务流程是否合法,数据库唯一键和条件更新负责并发下的最终保护。
八、Outbox 和消息幂等:接受重复,保证可恢复
数据库事务和 MQ 发送不是同一个事务。若先提交数据库、再发送 MQ,可能出现数据库成功但消息发送失败;若先发 MQ、再提交数据库,也可能出现消费者处理了一个尚未成功落库的业务事件。
一种常见方式是 Outbox:
本地事务:
写订单
扣数据库库存
更新预占状态
写 Outbox 事件
后台 Relay:
读取 Outbox
发送 MQ
标记事件已发送
Relay 可能重复发送,因此消费者仍然要通过 eventId、业务唯一键或状态机保证幂等。
消费端通常先执行数据库业务事务,成功后再确认消息。若数据库事务已提交但 ACK 丢失,消息会重新投递;此时消费者查询到业务已完成,就返回成功,而不是重复创建订单。
重试需要区分错误类型:
- 数据库短暂不可用、网络抖动:有限重试;
- 用户无资格、库存不足、活动不存在:作为业务结果结束处理;
- 超过最大重试次数:进入死信队列,供查询、补偿和人工处理。
死信不能直接丢弃。需要保留消息和失败原因,并支持重新投递或业务状态修复。
九、故障时如何保护核心链路
- Redis 不可用:停止或降低新请求接纳,不把流量直接放给数据库。
- MQ 不可用 :不向客户端返回
ACCEPTED,对请求快速失败或暂时关闭入口。 - 消费者宕机:依赖 MQ 重投,业务处理通过幂等机制避免重复。
- 数据库短暂故障:有限重试,并根据 reservation 状态判断后续确认或释放。
- 支付回调重复:用支付流水号和订单状态机去重。
- 订单超时未支付:关闭订单并幂等释放库存。
- 消息持续积压:降低入口接纳速率,扩容消费者,优先保护下单链路,暂停非核心通知和统计。
核心原则是:下游出故障时快速限流、排队或失败,不要把压力无控制地转移给数据库。
十、对账与库存恢复
高并发系统不能假设所有消息和补偿都一次成功,需要定时对账。
一种库存核算关系是:
总库存 = 可用库存 + 当前有效预占库存 + 已确认销售库存
已关闭或失败订单释放完成后,库存应从预占状态回到可用状态。计算时要确保状态互斥,不能把同一笔库存同时算成预占和已售。
对账以数据库订单、库存流水和 reservation 状态作为事实依据,检查 Redis 预占、数据库库存、超时订单、未处理消息和死信记录。发现不一致后执行幂等修复,并记录审计信息。Redis 是加速和预占层,丢失或不一致时可以依据数据库事实重建,但修复时要避开状态仍在变化中的订单。
十一、监控和容量验证
架构图本身不能证明系统能承受百万级流量。上线前需要用压测验证每一层的容量,并监控:
- 网关接纳量、限流量和拒绝原因;
- MQ 发送成功率、消费延迟和积压量;
- Redis 热 Key、命中率、命令延迟和错误率;
- 数据库连接池、锁等待、慢 SQL 和事务失败率;
- 订单成功率、重复请求比例、库存预占失败率;
- reservation 超时量、死信数量和对账差异。
容量设计要回答:每层能处理多少请求、瓶颈在哪、超过容量后怎样降级,而不是只给系统贴上"百万 QPS"的标签。
最后总结
秒杀系统的核心不是某一个中间件,而是明确每层的职责和正确性边界:
- CDN、WAF 和网关尽早拦截无效流量;
- Admission 控制系统实际接纳速率;
- MQ 缓冲突发请求;
- Redis 原子预占并通过库存分桶缓解热点;
- MySQL 唯一约束、条件更新和事务兜住最终正确性;
- Outbox 传播后续事件;
- 状态机管理订单生命周期;
- 幂等、补偿和对账保证异常后可以恢复。
我不会说"用了 Redis 就不会超卖",也不会说"用了 MQ 就能只消费一次"。更准确的设计思路是:允许请求和消息重试,用数据库约束保证最终正确,用状态机防止非法跳转,再通过补偿和对账处理无法在单次请求中完成的异常情况。
这套方案的目标不是让每个请求都成功,而是在流量远超系统处理能力时,仍然能够保护核心交易链路,并准确告诉用户请求是被拒绝、正在排队,还是已经成功。