设计一个秒杀系统

本文系统设计一个高并发秒杀系统:需求澄清与量级估算、静态化与削峰分层架构、多级限流、库存扣减与一致性(Redis 预扣 + 消息队列异步落库)、防刷与安全风控,并给出架构图、数据表、限流与扣减伪代码、压测与发布预案。

秒杀是电商系统里最考验架构的场景:一件爆款商品只放 1 万件库存,开抢瞬间可能涌入上亿次请求。系统的目标不是「让所有请求都成功」,而是「让真正抢到的人拿到货、让系统在洪峰下不被打垮、让超卖与资金风险为零」。本文按照系统设计面试的标准答题结构,设计一个生产级的秒杀系统,覆盖从流量入口到库存落库的完整链路。

一句话:秒杀的本质是「把瞬时百万级请求的冲击,削成每秒几千的温和负载」——系统所有设计都围绕「削峰、限流、异步、降级」这四个词展开。

一、需求澄清与量级估算

1.1 需求澄清

面试官给出题目「设计一个秒杀系统」后,先通过提问明确边界:

  • 业务形态:仅单一爆品秒杀,还是支持多商品多场次?是否需要预约、排队、抽签等前置环节?
  • 库存语义:秒杀库存是独立预留池,还是与普通库存共享?超卖是否绝对禁止?
  • 用户视角:秒杀成功的标志是「拿到下单资格」还是「直接生成订单」?是否需要支付?
  • 防刷要求:是否需要账号风控、验证码、设备指纹、购买频次限制?
  • 一致性要求:库存扣减允许误差吗?订单状态与库存是否要求强一致?
  • 规模:多少场次、单场多少库存、峰值多少 QPS、多少日活参与?

明确假设(面向面试的合理假设):

需求项假设
业务形态单场爆品秒杀 + 多商品并行场次
库存语义独立秒杀库存池,绝对禁止超卖
成功标志抢到「购买资格」,下单与支付异步完成
防刷验证码 + 设备指纹 + 账号频控 + IP 频控
一致性库存不超卖(强约束),订单最终一致
规模单场 1 万库存、峰值 100 万 QPS、预约 1000 万人

1.2 量级估算

指标估算值推导
峰值 QPS~100 万1000 万预约 × 10% 同时开抢,集中在 1 秒内
峰值 TPS~50 万校验/限流后的真实下单请求
单场库存1 万件秒杀爆品限量
秒杀窗口5 分钟开抢后持续一段时间,但峰值集中前 10 秒
有效购买~10 万1 万库存 × 每人最多 1 件,含少量重试
接口时延P99 < 200ms秒杀接口必须极快,否则用户感知超时
数据量百万级订单/日秒杀订单与其他订单共用订单中心

一句话:100 万 QPS 是「洪水」,1 万库存是「目标」——洪峰必须被层层削掉,真实落到订单系统的只有几十万请求,这就是秒杀架构的核心约束。

二、高层架构设计

   ┌──────────────────────────┐    ┌─────────────────────────┐
   │ 用户 (App / H5 / 小程序)   │    │ 运营后台 (创建场次/库存)   │
   └─────────────┬────────────┘    └────────────┬────────────┘
                 │ 秒杀请求(重定向到静态页)       │ 场次配置/库存下发
                 ▼                              ▼
   ┌─────────────────────────────────────────────────────────┐
   │  CDN / 边缘节点:秒杀商品页、倒计时、按钮状态全部静态化   │
   │  (动静态分离,用户打开页面不经过源站)                    │
   └─────────────────────────────────────────────────────────┘
                 │ 开抢瞬间才发起「抢购」AJAX 请求
                 ▼
   ┌─────────────────────────────────────────────────────────┐
   │                接入网关层 (API Gateway 集群)              │
   │  ① 验证码校验 ② 设备/账号/IP 风控 ③ 接口级限流(令牌桶)   │
   └────────────────────────┬────────────────────────────────┘
                 │ 放行的抢购请求(约峰值 1%~5%)
                 ▼
   ┌─────────────────────────────────────────────────────────┐
   │              秒杀服务层 (Seckill Service)                  │
   │  ① 资格校验(场次/账号) ② 防重复购买                      │
   │  ③ Redis 预扣库存(Lua 原子扣减) ④ 发送下单消息            │
   └────────────────────────┬────────────────────────────────┘
                 │ 扣减成功 → 写 MQ
                 ▼
   ┌─────────────────────────────────────────────────────────┐
   │  消息队列 (Kafka/RocketMQ)  →  异步订单服务               │
   │  ① 削峰缓冲 ② 异步落库 ③ 失败重试 ④ 库存回滚             │
   └────────────────────────┬────────────────────────────────┘
                 │
                 ▼
   ┌─────────────────────────────────────────────────────────┐
   │  MySQL (订单表/库存表)  +  Redis (库存计数/资格/限流)      │
   └─────────────────────────────────────────────────────────┘

整体拆为五层:

  1. 静态化层:秒杀页、倒计时、按钮状态全部推送到 CDN,开抢前绝大多数流量根本不进源站。
  2. 接入网关层:验证码、风控、限流,把百万级请求砍到服务层可承受的量。
  3. 秒杀服务层:无状态的水平扩展节点,只做「资格校验 + Redis 预扣」,内存级完成核心决策。
  4. 异步下单层:MQ 削峰,订单异步落库,失败重试与库存回滚。
  5. 存储层:Redis 管计数与资格(热),MySQL 管订单与库存(冷)。

2.1 核心思想:层层削峰

用户请求 100万 QPS
   │  CDN 静态化:拦截 90% 的「刷页面」流量
   ▼
接入网关 10万 QPS (读/点按钮/验证码)
   │  限流 + 风控:拦截 90% 的无效与恶意请求
   ▼
秒杀服务 1万 QPS (真实抢购)
   │  Redis 预扣:1万库存很快扣完,后续请求直接返回「已抢完」
   ▼
消息队列 5千 TPS (资格发放)
   │  异步消费,按数据库承受能力落库
   ▼
订单系统 500 TPS (最终落库)

一句话:每一层都「牺牲一部分请求、保住核心决策」,最终落到数据库的请求是数据库完全能承受的量——秒杀的成功不是让更多请求成功,而是让该成功的请求一个不少地成功。

三、核心组件设计

3.1 商品页静态化

秒杀页的访问量占洪峰流量的绝大部分,但页面内容是固定的。把页面静态化并推送到 CDN:

静态化内容:
  - 商品图、标题、价格、参数 → HTML/图片推 CDN
  - 倒计时 → 由 CDN 边缘脚本本地计算,不回源
  - 按钮状态 → 抢购前置灰,开抢瞬间由本地 JS 放行

动态接口(少量):
  - 开抢后的「抢购」AJAX(走网关)
  - 秒杀结果查询(轮询/长轮询,低频)

一句话:100 万 QPS 里可能有 90 万是「刷新页面」——把这些流量用 CDN 静态化吃掉,源站压力直接降一个数量级。

3.2 多级限流

限流要放在最前面,且要分场景:

接入网关层(每接口独立配额):
  令牌桶:capacity = 网关集群能力,rate = 服务层可承载
  对「抢购」接口限流,对「查结果」接口放宽

服务层兜底:
  单用户维度:同一账号 1 秒最多 3 次抢购
  单 IP 维度:同一 IP 1 秒最多 N 次(风控联动)
  总量维度:全局限流,超过设定 QPS 直接返回 429
;; 伪代码:网关层令牌桶限流
(defn rate-limit! [key bucket-rate bucket-capacity]
  (let [token (redis-eval "local c=redis.call('get',KEYS[1]);
                           if not c or tonumber(c)<ARGV[2] then
                             redis.call('incr',KEYS[1]); return 1
                           else return 0 end"
                          key bucket-capacity)]
    (= token 1)))

;; 抢购接口:预取令牌,失败则返回繁忙
(if (rate-limit! "rl:seckill:global" 10000 50000)
  (handle-seckill req)
  (resp {:code 429 :msg "请求过于频繁"}))

要点:限流是「三层」——网关总量限流挡住洪峰、服务层接口限流按业务配额、单用户单 IP 限流防刷;任何一层单独都不够,合在一起才稳。

3.3 库存扣减与一致性

库存是秒杀的「硬通货」,扣减必须在极端并发下仍然不超卖。核心做法是 Redis 原子预扣 + 异步落库:

扣减流程:
  1. 抢购请求 → 资格校验(场次、账号、重复购买)通过
  2. Redis Lua 脚本原子执行:EXISTS 资格 → 库存 DECR > 0 → 记录资格
     (DECR 原子性保证并发安全,不会超卖)
  3. DECR 成功 → 写入 MQ(userId, itemId, stockId)
  4. 消费者异步:校验库存流水 → 生成订单 → 回写库存快照
  5. 库存扣到 0 → 后续请求直接返回「已抢完」

防超卖双保险:
  - Redis 预扣是「准入闸门」:总量 = 库存总数,DECR 不可能扣出负数
  - MySQL 落库是「最终校验」:UPDATE stock SET qty=qty-1 WHERE item=? AND qty>0
    影响行数为 0 则回滚,保证数据库层面也不超卖
CREATE TABLE seckill_stock (
  item_id    BIGINT PRIMARY KEY,
  total_qty  INT,            -- 秒杀库存总量
  sold_qty   INT,            -- 已售数量(异步落库后累加)
  version    INT             -- 乐观锁
);

CREATE TABLE seckill_order (
  order_id    BIGINT PRIMARY KEY,
  user_id     BIGINT,
  item_id     BIGINT,
  status      TINYINT,       -- 0待支付 1已支付 2已取消
  create_time DATETIME,
  UNIQUE KEY uk_user_item (user_id, item_id)  -- 一人一单防重复
);
;; 伪代码:Redis Lua 原子扣减(核心)
;; KEYS[1]=库存key KEYS[2]=用户资格key ARGV[1]=userId
(defn seckill-deduct! [stock-key user-key user-id]
  (redis-eval
    "if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then
       return -2                                    -- 已抢过
     end
     local stock = redis.call('get', KEYS[1])
     if not stock or tonumber(stock) <= 0 then
       return -1                                    -- 已抢完
     end
     redis.call('decr', KEYS[1])
     redis.call('sadd', KEYS[2], ARGV[1])
     return 1")                                     -- 抢购成功
  stock-key user-key user-id)

结论:DECR 是天然原子的,配合 SISMEMBER 去重和 SADD 记录资格,把「扣库存 + 记资格」放进同一个 Lua 脚本,一次 RTT 完成——既保证不超卖,又保证一人一单。

3.4 异步下单与库存回滚

Redis 预扣成功不等于订单成功,异步落库环节要考虑失败补偿:

消息内容:{userId, itemId, stockToken, expireAt}
消费逻辑:
  ① 幂等校验(按 stockToken 查订单,已存在则跳过)
  ② 创建订单(状态=待支付)
  ③ 累加 sold_qty,校验与 Redis 预扣量一致
  ④ 失败(DB 异常/超时)→ 重试;超过重试上限 → 进入死信队列
  ⑤ 超时未支付 → 释放资格:Redis 回补库存 + 删除资格 + 关闭订单

一句话:预扣是「临时占用」,支付成功才「真正成交」,超时未支付要回补库存——秒杀库存的释放链路和扣减链路一样重要,否则会出现「有人抢到却不付,货卖不完」的尴尬。

3.5 防刷与安全

秒杀的黄牛与脚本是最大威胁,防刷要分层:

层级手段目标
客户端滑块/点选验证码拦截脚本自动化
网关设备指纹、IP 频控拦截批量机器人
账号注册时间、实名、历史购买拦截羊毛党小号
服务层一人一单唯一索引兜底防重复
风控链路:
  抢购请求 → 设备指纹识别(UA/设备ID/行为轨迹)
  → 账号画像(注册时长/信用分/历史异常)
  → 频控(账号 N 秒内次数 + IP 聚合次数)
  → 黑名单(命中即拒绝,秒级生效)

动态验证码:
  开抢前提前弹出验证码,避免开抢瞬间验证码服务被打爆
  验证码服务独立部署、独立限流

要点:防刷不是「拦得越狠越好」,而是「拦得住脚本、放得下真人」——验证码在开抢前发放、设备指纹在网关层判定、账号画像在服务层判定,误杀率要持续监控。

四、深入权衡

4.1 Redis 预扣 vs 数据库直接扣减

方案并发能力一致性复杂度适用
MySQL 行锁扣减低,锁竞争强一致低库存量小、QPS 低
MySQL 乐观锁中强一致,冲突重试中万级 QPS 以下
Redis 预扣 + 异步落库高,内存原子最终一致高秒杀/高并发扣减

结论:秒杀场景必须 Redis 预扣——数据库行锁在百万并发下会被锁竞争打穿;Redis 的 DECR 原子性天然防超卖,配合 MySQL 落库校验双保险,一致性满足业务要求。

4.2 同步下单 vs 异步下单

同步下单让用户「立即看到订单」,但把数据库写放大到洪峰;异步下单牺牲一点「即时反馈」,换来数据库从容落库:

同步:抢购 → 扣库存 → 建订单 → 返回(一次请求全完成)
  优点:体验直接;缺点:DB 承受全部洪峰,容易雪崩

异步:抢购 → Redis 扣库存 → 返回「已抢到,请稍候」 → MQ → 落库
  优点:DB 压力平稳;缺点:结果有秒级延迟

一句话:秒杀的结果展示是「稍后查询」也能接受(用户本来就要去「我的订单」看),所以用异步换吞吐是划算的——把数据库从「被洪峰打死」变成「稳定匀速消费」。

4.3 库存预扣的比例

Redis 预扣把库存全量放在内存,存在「扣了不付」的虚占。权衡手段:

方案A:全额预扣,超时未支付回补
  优点:规则简单,绝不超卖;缺点:退货释放有延迟

方案B:预扣 80% + 数据库兜底 20%
  优点:减少虚占;缺点:两套扣减逻辑,一致性复杂

方案C:预约制 + 限时支付
  优点:把秒杀拆成「抢资格 + 限期支付」,库存压力分散

结论:生产上常用方案 A 加上「支付倒计时释放」;若库存紧张且对体验敏感,可叠加方案 C 的预约排队,把瞬时洪峰进一步摊平。

五、总结

秒杀系统是「以削峰为主线」的架构:CDN 静态化吃掉浏览流量,网关限流与风控砍掉无效与恶意请求,Redis Lua 原子预扣在内存里完成核心的「不超卖 + 一人一单」决策,消息队列把下单压力平移到数据库能承受的匀速负载,超时未支付再回补库存形成闭环。三层限流防刷、Redis 与 MySQL 双保险防超卖、异步下单换吞吐,是三个最关键的工程决策。最终系统对外表现是:开抢瞬间页面不崩、真正抢到的人一个不落、库存一个不多卖——这正是秒杀架构的全部意义。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「design」更多文章

  1. 设计一个 API 网关系统
  2. 设计一个短视频系统
  3. 设计一个分布式缓存系统