面试题:设计一个秒杀系统,怎么扛住瞬时洪峰、又怎么防超卖?
这是「每日一题」专栏的第九篇。每天一道面试题,后端 + AI 混合路线,从原理到代码一次讲清。上一篇讲了 分布式事务,今天按轮换到系统设计方向,聊一道电商场景出镜率最高、也最考验工程功底的题:设计一个秒杀系统。
题目
某电商平台要搞一场秒杀:1 万件商品,提前预告,开售瞬间几十万人同时抢。怎么设计这个系统,保证不被打挂、不超卖、用户体验不崩?
这道题和普通系统设计不一样,它的难点不在「功能」,而在「极端流量」:平时每秒几千请求的系统,秒杀瞬间 QPS 能冲到几十万。面试官想看的就三件事:能不能识别出流量特征、能不能用分层架构把压力逐层卸掉、知不知道防超卖的根本解法。
先给结论:秒杀系统设计的核心思路就八个字——流量拦截、削峰填谷。
第一步:需求澄清和容量估算
别急着画图,先把数字问清楚:
| 维度 | 需要确认 |
|---|---|
| 商品数量 | 多少件?库存多少? |
| 并发规模 | 峰值多少人同时抢?预估峰值 QPS? |
| 用户规模 | 总用户数?需要登录吗? |
| 一致性 | 超卖绝对禁止?允许扣减失败重试? |
| 功能边界 | 支付是秒杀系统负责还是下单后走正常流程? |
假设面试官给的典型数字:
库存:1 万件
参与人数:100 万人
秒杀时长:1 分钟
峰值 QPS:约 30 万(开售前 10 秒集中涌入)
平均 QPS:100 万 / 60 秒 ≈ 1.7 万
核心矛盾立刻暴露:系统只需要真实卖出 1 万件,但会有 100 万人来抢——99% 的流量是无效流量。 秒杀系统的一切设计,都是围绕「把这 99% 的流量挡在门外,只放 1% 的有效流量进去」展开的。
第二步:三个核心挑战
挑战一:瞬时洪峰
30 万 QPS 什么概念?一个普通业务系统(DB 直连)通常扛几百 QPS,加了缓存能到几千。30 万 QPS 意味着数据库、应用服务器在开售瞬间直接被打穿。所以第一原则:请求在越靠前的层被挡掉越好,永远不要让流量打到数据库。
挑战二:超卖
库存 1 万件,绝对不能卖出 10001 件。多线程并发扣库存,直接 UPDATE stock SET count = count - 1 会因为「读-改-写」竞态超卖。这是并发正确性问题,不是加台机器就能解决的。
挑战三:黄牛和刷单
脚本可以毫秒级发起请求,真人 1 秒才点一下。如果不做风控,库存会被脚本抢光。拦截手段:验证码、答题、设备指纹、IP/账号限流。
第三步:分层架构——把流量逐层卸掉
秒杀系统的架构是「漏斗」形的,每一层只放行一部分流量:
100 万人(开售瞬间涌入)
│
┌──────────▼──────────┐
│ ① 前端层:静态化 │ CDN 扛住页面请求
│ 验证码 / 答题 / 倒计时 │ 拦掉脚本,把人挡住几秒
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ ② 接入层:网关限流 │ 令牌桶 / 计数器,单机限流
│ IP / 账号维度限流 │ 每个账号限 1 次,拦黄牛
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ ③ 缓存层:Redis │ 库存预减 + Lua 原子扣减
│ 库存预热,本地缓存 │ 真正的「闸门」,只放 1 万件
└──────────┬──────────┘
│ (放行的请求才继续)
┌──────────▼──────────┐
│ ④ 削峰层:MQ │ 异步下单,削峰填谷
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ ⑤ 存储层:MySQL │ 最终扣减,落库订单
└─────────────────────┘
① 前端层:静态化 + 人机校验
- 秒杀页面纯静态化,丢到 CDN。页面本身不产生任何后端请求,由 CDN 扛住 99% 的页面流量。
- 开售前按钮置灰 + 倒计时,避免用户提前狂刷新。
- 点击后弹验证码/滑块/答题,把脚本和真人区分开。答题本身还能「拖时间」——人答题要 2
3 秒,这 23 秒就是天然的限流窗口。
② 接入层:网关限流
到了网关层,流量已经不是页面级的百万了,但仍然是几十万 QPS。这里做:
- 令牌桶限流:每个网关节点按配额放行,超出的直接返回「排队中/已抢完」。
- 账号维度限流:一个账号一次秒杀活动最多放行 1~2 个请求,重复请求直接拒绝。这一层就把「一个人刷 1000 次」的脚本流量干掉。
③ 缓存层:Redis 预减库存(核心闸门)
这是整个系统的心脏。秒杀开始前,把库存从 MySQL 预加载到 Redis:
# 秒杀开始前预热
SET seckill:stock:1001 10000
每个请求到达时,先到 Redis 扣减库存,扣成功的才继续,扣失败(库存归零)直接返回「已抢完」:
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
def try_seckill(user_id: str, sku_id: str) -> bool:
# Lua 脚本:原子地检查库存并扣减,防止超卖
lua = """
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock and stock > 0 then
redis.call('DECR', KEYS[1])
return 1
end
return 0
"""
ok = r.eval(lua, 1, f"seckill:stock:{sku_id}")
return ok == 1
为什么必须用 Lua? 因为「查库存 + 扣减」是两步操作,如果先 GET 再 DECR,两个并发请求可能同时读到库存为 1,然后都 DECR,就超卖了。Lua 脚本在 Redis 里是原子执行的,中间不会被其他命令插队——这正是防超卖的根本解法:扣减和检查必须在同一个原子操作里完成。
扣减成功的请求,写入一个「已抢到」标记,然后投递到 MQ 异步下单。
④ 削峰层:消息队列
数据库每秒只能承受几千次写,30 万 QPS 直接写库必死。所以下单异步化:
Redis 扣减成功
│
▼
投递到 MQ(seckill_order_queue)
│
▼
订单服务按自身吞吐能力消费(如每秒 2000 条)
│
▼
写订单表 + 最终扣减 MySQL 库存
MQ 的作用是削峰填谷:峰值 30 万的流量被压成消费者能承受的每秒几千,流量洪峰变成了平稳的队列积压。用户侧感知是「提交成功,请等待结果」,订单服务处理完异步通知用户。秒杀不要求同步返回订单结果,这给了削峰最大的空间。
⑤ 存储层:数据库最终扣减
MQ 消费者拿到消息后,用乐观锁做最终扣减,双保险防超卖:
-- 乐观锁:只有库存大于 0 才能扣减成功
UPDATE seckill_stock
SET count = count - 1, version = version + 1
WHERE sku_id = 1001
AND count > 0
AND version = 1; -- 或直接 count > 0
-- 影响行数为 0 说明库存已经没了
这条 SQL 把「检查 + 扣减」合二为一,数据库层面保证不会扣成负数——就算 Redis 层出了 bug,DB 层也会兜底。
第四步:热点优化——单机 Redis 的瓶颈
问题来了:所有秒杀流量都打同一个 key(seckill:stock:1001),Redis 是单线程的,单个 key 最多能扛约 10 万 QPS。30 万 QPS 打一个 key,Redis 自己也顶不住。
解法:
- 本地缓存挡一层:网关节点用本地内存维护「已售罄」标记。Redis 扣到 0 时,通知网关把该商品标记为本地售罄,后续请求在网关本地就直接拒绝,根本到不了 Redis。
- key 分片:把 1 万个库存拆成 N 个 key(如 100 个 key,每个 100 件),请求按用户 ID hash 到不同 key 上扣减。分散热点,但也引入「部分 key 售罄」的复杂性——需要知道总库存,判断逻辑变复杂。工业界通常用「本地售罄标记 + 单 key」就够。
第五步:完整时序
把整个流程串起来:
1. 用户点击抢购按钮
2. 前端验证码校验通过
3. 网关限流:令牌桶 + 账号维度校验
4. Redis Lua 原子扣减库存
├─ 失败 → 返回「已抢完」
└─ 成功 → 写已抢标记,投递 MQ
5. 订单服务消费 MQ,异步创建订单
6. 数据库乐观锁最终扣减,落库
7. 异步通知用户「抢购成功」
面试追问合集
| 追问 | 关键点 |
|---|---|
| 用户看到「抢购成功」但订单创建失败了怎么办? | 已抢标记 + 定时补偿任务重试;重试多次仍失败则释放库存、通知用户 |
| Redis 扣了库存,但 MQ 投递失败? | 用本地消息表/事务消息保证不丢;或者把「扣减 + 投递」设计成可补偿的 |
| 库存预热时 Redis 挂了? | 秒杀活动直接熔断,比带病运行强;Redis 做哨兵/集群高可用 |
| 怎么防止一个账号开多个小号? | 设备指纹、手机号实名、同设备/同 IP 限流,风控系统兜底 |
| 秒杀结束后库存没卖完? | 定时任务把 Redis 剩余库存回写 MySQL,保证最终一致 |
| 为什么不用数据库直接扛? | 单库单表写 QPS 上限约几千,30 万 QPS 直接打穿;Redis 内存操作单机可扛十万级,且 Lua 保证原子性 |
总结
| 问题 | 答案 |
|---|---|
| 秒杀系统的核心难点? | 瞬时洪峰、防超卖、防刷单 |
| 怎么扛洪峰? | 分层拦截:CDN 静态化、网关限流、Redis 闸门、MQ 削峰 |
| 怎么防超卖? | Redis Lua 原子扣减 + 数据库乐观锁双保险 |
| 为什么用 MQ? | 削峰填谷,把 30 万 QPS 压成消费者能承受的速率 |
| 单 key 热点怎么办? | 本地售罄标记 + key 分片 |
| 用户拿到成功标记但订单失败? | 补偿任务重试,失败释放库存 |
秒杀系统这道题,本质考的是**「流量分层卸载」的工程思维**:每一层只做一件事,把压力逐层消化,让最贵的资源(数据库)永远只处理最少的请求。能画出漏斗架构、讲清 Lua 原子扣减和乐观锁的双保险,这道题你就稳了。
明日预告:AI/LLM 方向 ——「LLM 推理加速之量化:GPTQ、AWQ、GGUF 到底有什么区别?量化之后精度掉多少?」