秒杀是电商系统里最考验架构的场景:一件爆款商品只放 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 (库存计数/资格/限流) │
└─────────────────────────────────────────────────────────┘
整体拆为五层:
- 静态化层:秒杀页、倒计时、按钮状态全部推送到 CDN,开抢前绝大多数流量根本不进源站。
- 接入网关层:验证码、风控、限流,把百万级请求砍到服务层可承受的量。
- 秒杀服务层:无状态的水平扩展节点,只做「资格校验 + Redis 预扣」,内存级完成核心决策。
- 异步下单层:MQ 削峰,订单异步落库,失败重试与库存回滚。
- 存储层: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 双保险防超卖、异步下单换吞吐,是三个最关键的工程决策。最终系统对外表现是:开抢瞬间页面不崩、真正抢到的人一个不落、库存一个不多卖——这正是秒杀架构的全部意义。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。