Appearance
秒杀系统设计
定位:系统设计场景题的第一道分水岭。秒杀考的不是背一张架构图。考的是在库存总量不变的前提下,让 100 万流量里只有几万条摸到 Redis、几千条摸到 DB。这考验逐层削减能力。答好它的标志:每一层都能说清"这层拦掉多少、凭什么拦、拦错了怎么办"。方案文档层面的通用套路见《如何出一个技术方案》。
一、先量化再设计
开口画图前先问规模(不问规模直接画,本身就是减分项):
假设:库存 10 万件,预约用户 500 万,开抢瞬间峰值 100 万 QPS
目标:不超卖、不少卖、核心链路不挂,用户侧秒级反馈
非目标:不保证每个请求都成功——被拦掉的请求本来就是"炮灰"两个关键认知:
- 100 万 QPS 直接打 MySQL(单机几千写 QPS)必死,必须在上游把流量削到万级以下。
- 秒杀本质是读多写少 + 突发尖峰:刷详情页是读(百万级),真扣库存是写(≤10 万)。读走静态化,写走削峰,两条路分开治理。
二、核心架构:流量漏斗逐层削峰
100 万 QPS
│
┌───────────▼───────────┐
① │ CDN / 页面静态化 │ 详情、图片、价格全部静态化;
│ 未开始→只回倒计时 │ 库存状态走独立轻接口(可缓存 1s)
└───────────┬───────────┘ 拦掉 ~70%
30 万 QPS
┌───────────▼───────────┐
② │ 网关:限流 + 准入 │ 答题/验证码、用户级限流、黑名单
│ 超阈值直接返回"已抢完"│ 拦掉 ~90%
└───────────┬───────────┘
3 万 QPS
┌───────────▼───────────┐
③ │ Redis 库存预扣 │ Lua 原子扣减 + 一人一单去重,
│ (活动前预热好的) │ 扣失败即终止请求
└───────────┬───────────┘ 拦到 ≈ 库存数
≈ 10 万次成功预扣
┌───────────▼───────────┐
④ │ MQ 异步下单 │ 发"预扣成功"消息即返回下单成功,
│ (RocketMQ/Kafka) │ 消费端匀速落库,削平写尖峰
└───────────┬───────────┘
匀速几千 QPS
┌───────────▼───────────┐
⑤ │ DB 真扣库存 + 订单 │ 条件更新/乐观锁兜底,防超卖最后防线
└───────────────────────┘每一层的哲学:越早拒绝越便宜。CDN 拒绝一个请求成本近乎零;拖到 DB 才拒绝,代价是连接数 + 行锁 + 回滚,还会连累正常交易链路。
三、逐层设计要点
1)CDN 与页面静态化
- 秒杀页做成纯静态推到 CDN:商品名、图片、价格、规则全不走源站。动态的只剩"是否开抢/是否售罄"一个小接口,且允许网关缓存 1s。售罄信息 1s 的误差,换来开抢后 99% 的读不再打源站。
- 按钮可点时间前端 + 服务端双重校验:前端校验只是体验,服务端校验才是安全边界。改本地时间、直接刷接口都拦得住。
2)网关限流与准入
- 接口维度总闸(令牌桶/滑动窗口,见《限流算法与工程实践》)+ 用户维度限购 + IP 维度防代理刷。
- 答题/验证码:把"同一毫秒到达"的流量在时间上摊开(人答题有快慢),顺带做人机识别。
3)库存预热到 Redis
- 活动开始前定时任务把
stock:{skuId}加载进 Redis 并校验(DB 值 = Redis 值),开抢后 0 次 DB 读。 - 售罄后该 key 变成纯热点读 → 见第五节热点 key。
4)MQ 异步下单
- 预扣成功 → 发消息 → 立即返回"抢购成功,订单处理中",用户轮询/推送拿最终结果。同步链路只到 Redis 为止。
- 消费端以 DB 能承受的速率匀速落库,这是时间换空间的削峰。10 万订单 30 秒内写完和 3 秒内写完,用户无感。
- 消息不丢:发送失败本地消息表重试;消费幂等(订单号唯一索引)。最终一致性的理论背景见《什么是分布式事务?》。
5)DB 层兜底扣减
- Redis 只是预扣,DB 条件更新才是真相之源(第四节)。
四、超卖怎么防
超卖根因:"查库存 → 判断 → 扣减"三步不是原子的,并发下多个请求都读到 stock=1。
1)Redis 层:Lua 原子预扣
Redis 单线程执行 Lua 脚本期间不插入其他命令,天然原子:
lua
-- KEYS[1]=库存key KEYS[2]=已购用户set ARGV[1]=uid
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return -1 -- 重复请求,直接拒
end
local stock = tonumber(redis.call('GET', KEYS[1]) or '-1')
if stock <= 0 then return 0 end -- 已售罄
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
return 1 -- 预扣成功一个脚本同时解决:原子扣减、一人一单、售罄快速失败。
2)DB 层:条件更新(CAS 思想)
sql
-- 版本号乐观锁:冲突多时重试风暴,适合后台类低并发场景
UPDATE seckill_stock SET stock = stock - 1, version = version + 1
WHERE sku_id = #{skuId} AND version = #{version};
-- 条件扣减(推荐):条件原子,无需重试,影响行数=0 即失败
UPDATE seckill_stock SET stock = stock - 1
WHERE sku_id = #{skuId} AND stock > 0;- InnoDB 行锁保证同一行的 UPDATE 串行,
stock > 0是数据库对超卖的最终裁决。 - 一人一单在 DB 侧用
uk(uid, sku_id)唯一索引兜底——应用层去重挡量,数据库约束兜底。这是防超卖/防重复的通用双层结构。
五、热点 key
10 万 QPS 全打在 stock:{skuId} 一个 key 上,落在单个 Redis 分片,分片 CPU 先死。三板斧:
- 库存分片:把 10 万拆成 10 个 key 各存 1 万,请求随机路由到一个分片扣。某分片扣完再去别的分片"借"(二次路由),把单 key 压力摊成 1/10。
- 本地售罄标记:任一分片返回售罄后,应用节点本地置
soldOut=true。后续请求不再碰 Redis,直接返回已抢完(误差是秒级,秒杀完全可接受)。 - 读路径:商品信息这类热点读用多级缓存,穿透/击穿/雪崩三件套见《缓存穿透-击穿-雪崩》。
六、防刷与黄牛
- 入口:答题/滑块验证码(摊开流量 + 人机区分)。隐藏真实下单 URL——开抢前才下发一次性 token 的动态接口,防止脚本提前循环打接口。
- 身份:账号限购(uid 去重)、设备指纹、手机号黑名单;同 IP 请求频率限制(注意校内网/运营商 NAT 误伤)。
- 行为:黄牛特征——下单到支付间隔极短、收货地址聚集、退款率高。风控异步评分,命中则取消订单回补库存。
- 原则:防刷是概率游戏,靠多层过滤叠加,不追求单层 100%。
七、降级预案
| 故障 | 预案 |
|---|---|
| Redis 挂 | 熔断秒杀入口返回"活动太火爆";小库存场景可降级纯 DB 条件更新硬扛 |
| MQ 积压超阈值 | 暂停接单入口(已预扣的慢慢消化),消费端扩容 |
| 支付超时未付 | 订单 15 分钟自动关单,库存回补(同样走原子操作 + 去重 set 移除) |
| 静态页故障 | CDN 回源开关切备用源站 |
| 终极兜底 | 活动总开关一键下线,爆炸半径只限本次秒杀 |
秒杀服务独立部署(独立服务/DB/域名),挂也只挂自己,这是架构隔离的前提。
八、追问链
- Redis 预扣成功但 MQ 消息丢了? 先扣后发 + 发送失败回补;事务消息/本地消息表;对账任务定时核
Redis 扣减数 = 订单成功数。 - Redis 扣了 1,DB 更新影响行数为 0? 异常分支:订单失败、回补 Redis、从去重 set 移除 uid;对账兜底。
- 为什么不用分布式锁? 锁把扣减串行化,吞吐量级不够。库存扣减只需要"原子判断+扣",Lua/条件更新是更轻的临界区。
- Redis 和 DB 库存不一致怎么办? Redis 是预扣账本,DB 是真账本。以 DB 为准 + 对账校准,不追求实时强一致(BASE 的典型应用)。
- 一人一单怎么保证集群下也成立? Redis set 去重挡量 + DB 唯一索引兜底,双层。
- 库存回补(关单/取消)怎么不把回补搞成超发? 回补同样走 Lua 原子 INCR + set 移除,且 DB 先改再回补 Redis。
- 预热失败的监控? 活动前 T-10min 校验 key 存在且值等于 DB,失败告警人工介入。没预热成功就不开抢。
九、面试官视角小结
面试官在这道题里真正打量的四件事:
- 分层思维:能不能自上而下给出漏斗,并说出每层的拦截量级——说不出来就是在背图。
- 成本意识:知道"拒绝得越早越便宜",知道 100 万 QPS 意味着什么、DB 的真实上限在哪。
- 一致性边界的清醒:Redis 预扣不是最终扣减,敢承认"靠异步 + 对账实现最终一致"。这比嘴硬说强一致加分得多。
- 兜底完备性:每个组件故障都有预案、有总开关,说明做过真实系统而不是纸上谈兵。
一句话总结:读走静态化,写走削峰,扣减原子化,一致性靠对账,故障有开关。
2026-10 新增(P0 补全)。