从入口拦截到最终对账:我如何设计一套高并发秒杀系统

设计秒杀系统时,我们很容易先想到 Redis、MQ、分库分表,甚至马上开始画一张很复杂的架构图。

但我觉得,真正应该先回答的问题是:

面对百万级瞬时请求,系统怎样避免让百万请求都进入数据库?

秒杀的成功订单通常远少于请求量。架构设计的重点不是让所有请求都被处理,而是尽早过滤无效请求、控制进入核心链路的流量,并保证库存和订单最终正确。

下面是我对一套高并发秒杀系统的设计梳理。这里的百万级流量是设计目标,不代表已经完成对应规模的线上部署或压测。

一、先明确系统目标

我先把业务目标拆成四项:

  1. 不能超卖;
  2. 同一用户不能重复购买;
  3. 峰值流量不能压垮数据库;
  4. 用户可以异步查询秒杀结果,不要求每个请求都同步完成下单。

这几个目标之间存在取舍。例如,为了保护系统,入口可以限流;为了应对突发流量,可以让用户排队;但这些机制都意味着部分请求会被拒绝或等待。系统需要把"请求已受理"和"订单创建成功"区分开。

二、整体架构

复制代码
用户请求
  ↓
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 脚本原子完成一组检查和操作:

  1. 检查活动是否有效;
  2. 检查当前请求是否已处理;
  3. 检查对应库存桶是否还有可用库存;
  4. 扣减 Redis 中的可用库存;
  5. 创建带有 reservationId 的预占记录。

通过一次原子脚本执行,可以避免多个消费者同时读到同一份可用库存再重复扣减。预占记录需要有状态和有效期,避免消费者宕机后库存一直被占住。

为什么需要库存分桶

Redis Cluster 按 Key 分片。同一个 Key 始终由同一个分片处理,因此把一个爆款 SKU 的库存全部放在一个 Key 上,可能让这个 Key 成为热点;增加 Redis 节点并不会自动拆分单 Key 的访问压力。

可以把库存拆成多个独立桶:

css 复制代码
seckill:stock:{activityId}:{skuId}:{bucketId}

例如将总库存分配到多个桶中,让不同 Key 有机会分布到不同 Redis 分片。桶的数量和库存分配需要根据集群规模和压测结果确定。

这里还要处理一个细节:如果用户只被路由到一个库存桶,而这个桶提前卖完、其他桶还有库存,就可能出现"局部售罄"。因此路由策略需要支持有限的备用桶尝试,或者通过合理的库存配额和路由机制降低库存分布不均。分桶解决的是热点问题,不会自动解决库存调度问题。

Redis 预占的目的是快速筛选和控制并发,不能单独作为最终的防超卖保证。

六、MySQL 最终确认:把业务正确性落在事务和约束上

Redis 预占成功后,消费者进入 MySQL 事务完成最终确认。事务内可以包括:

  1. 根据活动和用户创建订单;
  2. 使用条件更新扣减数据库库存;
  3. 更新预占记录状态;
  4. 写入 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 就能只消费一次"。更准确的设计思路是:允许请求和消息重试,用数据库约束保证最终正确,用状态机防止非法跳转,再通过补偿和对账处理无法在单次请求中完成的异常情况。

这套方案的目标不是让每个请求都成功,而是在流量远超系统处理能力时,仍然能够保护核心交易链路,并准确告诉用户请求是被拒绝、正在排队,还是已经成功。

相关推荐
薛定谔的悦1 小时前
从论文公式到 BMS 固件:FreeRTOS 与 EKF SOC 的工程结合
后端
Jeromefromhk1 小时前
把服务器当代码管
后端
闽林致远1 小时前
C++ 插件化后台服务架构实践
后端
用户125758524361 小时前
GoFrame Vue3开源后台管理系统推荐:数据库与Worker要求怎么验
数据库·后端·go
用户54817845909501 小时前
鉴权认证技术选型:主流方案对比与落地建议
后端
核桃er1 小时前
SpringBoot 整合 MyBatis 读取 MySQL 数据——完整开发实录
后端
小卿噢1 小时前
50 万个定时器,四种实现差了 115 倍
后端
用户54817845909501 小时前
CDN 实战:如何用 4 步让网站提速 10 倍
后端
MaxHua1 小时前
达梦 DTS 迁移 MySQL 报错 - 3236:此列列表已索引 解决方案
数据库·后端