Skip to content

秒杀系统设计 ​

定位:系统设计场景题的第一道分水岭。秒杀考的不是背一张架构图。考的是在库存总量不变的前提下,让 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/域名),挂也只挂自己,这是架构隔离的前提。

八、追问链 ​

  1. Redis 预扣成功但 MQ 消息丢了? 先扣后发 + 发送失败回补;事务消息/本地消息表;对账任务定时核 Redis 扣减数 = 订单成功数。
  2. Redis 扣了 1,DB 更新影响行数为 0? 异常分支:订单失败、回补 Redis、从去重 set 移除 uid;对账兜底。
  3. 为什么不用分布式锁? 锁把扣减串行化,吞吐量级不够。库存扣减只需要"原子判断+扣",Lua/条件更新是更轻的临界区。
  4. Redis 和 DB 库存不一致怎么办? Redis 是预扣账本,DB 是真账本。以 DB 为准 + 对账校准,不追求实时强一致(BASE 的典型应用)。
  5. 一人一单怎么保证集群下也成立? Redis set 去重挡量 + DB 唯一索引兜底,双层。
  6. 库存回补(关单/取消)怎么不把回补搞成超发? 回补同样走 Lua 原子 INCR + set 移除,且 DB 先改再回补 Redis。
  7. 预热失败的监控? 活动前 T-10min 校验 key 存在且值等于 DB,失败告警人工介入。没预热成功就不开抢。

九、面试官视角小结 ​

面试官在这道题里真正打量的四件事:

  • 分层思维:能不能自上而下给出漏斗,并说出每层的拦截量级——说不出来就是在背图。
  • 成本意识:知道"拒绝得越早越便宜",知道 100 万 QPS 意味着什么、DB 的真实上限在哪。
  • 一致性边界的清醒:Redis 预扣不是最终扣减,敢承认"靠异步 + 对账实现最终一致"。这比嘴硬说强一致加分得多。
  • 兜底完备性:每个组件故障都有预案、有总开关,说明做过真实系统而不是纸上谈兵。

一句话总结:读走静态化,写走削峰,扣减原子化,一致性靠对账,故障有开关。

2026-10 新增(P0 补全)。