系统是被打出来的 · 系统设计实战手册
PART 3 · 分布式深水区 · 第 12 章

秒杀架构专章
瞬时百倍流量的完整作战方案

前十一章教你逐个处理故障,这一章要求你把它们同时挡在库存之前。顶流开票进入 D-7,你第一次以技术负责人的身份签下完整作战方案。

12 · 1 值班现场D-7 10:00,老陈只问:库存层会看到多少

创业第 4 年,「秒抢」累计用户约 1000 万。公司 230 人,后端 42 人;你已经是技术负责人。七天后的 20:00,顶流场次开票。会议室里坐着网关、订单、库存、支付、DBA 与 SRE 的负责人,白板中央只有一个问题:1000 万入口请求同时到达,怎样让库存层只看见它能承受的那一小部分?

D-7 READINESS · RED · 评审于 10:00:00
[RISK] 宣发预估首波入口 1000 万请求,集中在开票后数秒
[RISK] 库存层已压测稳定容量 约 1.2 万 QPS
[GAP]   当前入口可直达订单链路,客户端失败后会重试
──────────────────────────────────────
老陈:
「别告诉我准备了多少机器。告诉我每一层挡掉多少,以及挡不住时怎么退。」

你把库存量、流量和恢复条件并排放上大盘。6 万张票意味着超过库存的购买意图终究不能成交;真正的目标不是让 1000 万请求都触碰库存,而是让被授权的一小股、可审计的请求进入交易核心。

首波入口
1000 万
▲ 数秒集中到达
可售票
6 万张
● 稀缺资源
库存稳定容量
约 1.2 万 QPS
● 压测验证
目标到库存层
约 1 万 QPS
● 留约 17% 余量

Mia 问能否“扩到每个人都排得上”。你摇头:入口与库存容量相差约三个数量级,扩容不可能替代准入。作战方案必须先是一只漏斗,再是一组隔离舱,最后才是数据库和缓存。

A的问题是目标本身不成立。 1000 万首波请求不等于 1000 万有效交易;为无票请求扩库存层既昂贵,也会把锁、数据库和支付推入不可验证区。容量应服务业务漏斗,而不是追逐入口峰值。

B不但没帮上忙,还在加码。 入口已经过载,立即重试会把一次购买意图变成多次尝试;第 7 章的重试乘法会在开票最脆弱的时刻重新上演。客户端应拿到明确排队凭证或退避结果。

C该先做的就是它:先签容量契约,再谈组件。 库存层目标 1 万 QPS 低于约 1.2 万的压测容量,漏斗每层都能拿到明确通过率、拒绝动作和负责人。这样 CDN、网关、排队与库存不是各自优化,而是在执行同一个端到端预算。

D方向没错,但不能先做。 库存扣减必须正确而高效,第 10 章已经讲过它的单一真相;但如果 1000 万请求仍能直达库存,再快的扣减也会被淹没。先定入口预算,再验证库存实现能稳定接住预算。

12 · 2 原理漏斗不是一层拒绝,是每层共同相乘

秒杀容量不是把入口峰值原样传到库存,而是让每层只把一部分请求交给下一层。静态化挡住无需进入源站的读,网关执行总量与公平性,排队层只释放库存能持续处理的节奏。

① 逐层递推 x₀ = 入口请求数; xᵢ = min( xᵢ₋₁ × 第 i 层通过率,第 i 层配额 )
  本章第一道门:候选数 = min( 去重后的合格购票意图数,候选配额 )
② 实际入队数 = 准入层按库存发出的凭证数(其余快速拒绝)
③ 到达库存层的速率 = 排队层释放速率(由库存容量决定,不由通过率决定)

注意 ① 必须逐层写,不能把所有配额攒到最后一起取最小值——上游一旦被配额截断,下游的通过率就该乘在截断之后的输出上。把 min 放在末尾,等于假装上游没被截过,算出来的数会偏大。

按作战方案逐步验算:

入口
1000 万
购买页与静态读
CDN 挡 90%
100 万
通过率 10%
网关候选配额
30 万
候选意图(已去重)
排队释放速率
1 万 QPS
按库存容量设定
实际入队 8 万
约 8 秒
80,000 ÷ 10,000/s

1000 万 → 100 万 → 30 万。CDN 挡住 90%,剩 100 万次请求;网关这一层的动作要按顺序说清楚,否则算式就不成立:先按账号与设备去重(同一账号的重复点击只留一个),再按预先拍板的候选配额截断到 30 万个合格购票意图。注意配额是设定出来的(定成库存的 5 倍),不是乘出来的;30% 只是事后回看的有效放行率(30 万 ÷ 100 万)。顺序不能反:去重不是保数量的操作,先取 30% 再去重,剩下的唯一意图通常不足 30 万。下文一律说「意图」,不说「人」——一个人可能发出多次请求,也可能被风控合并。而票只有 6 万张,供需比 5:1。

这 30 万只是候选,不等于 30 万个都进队列。准入层还有第二道门:按库存发凭证——发约 8 万张(6 万库存的 1.3 倍,留出弃单余量),其余约 22 万当场收到「未获得本轮资格」。真正入队的 8 万,以 1 万 QPS 释放,约 8 秒排空。

这里有一个容易被会议 PPT 抹掉的关键转折:前几层削减的是「请求数」,而库存层关心的是「速率」——百分比相乘不会把次数变成 QPS。入队的 8 万个意图究竟是 1 万 QPS 还是 5 千 QPS,取决于它们被摊在几秒里,而这恰恰是排队层自己决定的。所以排队层的参数不是「通过率」,而是释放速率:按库存约 1.2 万 QPS 的压测容量留出余量,定在 1 万 QPS(水位约 83%),8 万个入队意图约 8 秒放完。(若不设第二道门、让 30 万全部入队,同样的释放速率要排 30 秒——那正是下面 D2 里 A 方案的代价。)

把「次」和「每秒」混着乘,是容量方案里最常见也最贵的一类错误:它会让一个看起来收敛的漏斗,在开票当天以你没算过的速率抵达数据库。漏斗的前几层管「有多少合格意图能继续」,最后一层管「它们以多快的速度进门」——后者才是数据库真正感受到的东西。

这里还藏着一个常见误读:事后说「网关有效放行率 30%」,分母是 CDN 后的 100 万,不是原始的 1000 万——对原始入口而言这个比例只有 3%。每层都必须记录自己的输入、输出、单位与分母,否则会议里的百分比无法拼成一条可执行链。更要紧的是别把观测值当控制值:网关真正的控制参数是「候选配额 30 万」,30% 是配额除以当时到达量算出来的结果;到达量一变,这个百分比就变,而配额不该跟着变。

「秒抢」顶流开票 · 端到端流量漏斗
用户入口
1000 万请求
资格与静态读混合
→
CDN / 静态化
挡 90%
剩 100 万
→
网关 / 风控
去重 → 候选配额
截断至 30 万
→
排队 / 凭证
候选 30 万 → 发凭证 8 万
按 1 万 QPS 释放,约 8 秒
→
库存原子扣减
约 1 万 QPS
幂等 · 单一真相
→
订单 / 支付
异步落库
对账与补偿
缓存层
热点详情
互斥回源 · 预热
消息层
削峰填谷
积压水位 · 死信
隔离层
熔断 · 舱壁
重试预算
观测层
每层到达/通过/拒绝
库存与订单对账
每一层都要给出:输入量 · 通过率 · 拒绝语义 · 失败开关 · 自动回滚 · 负责人。漏斗只画箭头不写预算,等于没有作战方案。

全景图的重点不是组件数量,而是入口请求在每一箭头上都有明确去向:静态读不进源站,无资格请求不进排队,无凭证请求不进库存,扣减失败不进支付,订单落库失败进入补偿而不是让用户盲目重试。

层 正常契约 红线 触发动作 恢复条件
CDN / 静态化 挡住约 90% 详情与资源请求 回源率 > 15% 延长安全静态内容、关闭个性化 回源率连续五分钟低于 10%
网关 / 风控 去重后按候选配额截断至 30 万(事后有效放行率约 30%) 候选意图超过 30 万,或发出凭证超过 8 万 收紧令牌、按用户与设备拒绝 下游 P99 与错误预算恢复
排队 / 凭证 向库存释放约 1 万 QPS 队龄或积压超过用户预算 停止发新凭证、快速告知售罄概率 消费速率持续高于生产速率
库存 原子扣减、幂等、拒绝负库存 容量水位 > 0.85 或对账差异 收紧上游配额、暂停新扣减 水位回落且库存/订单对平
订单 / 支付 异步建单、支付有重试预算 积压单调增长或渠道熔断 降级非核心读、切渠道、停止盲重试 积压可在承诺时间内追平
秒杀架构不是让系统接住百倍流量,而是让百倍流量在抵达稀缺资源前,经过可解释、可观测、可回滚的层层选择。

这只漏斗不是凭空出现:第 6 章提供网关限流与降级,第 8 章提供热点场次独立通道,第 9 章用消息队列削峰,第 10 章守住库存真相,第 11 章给重复请求加幂等护栏。第 16 章会把这些预案放进生产演练,检验箭头遇到真实故障时是否仍然成立。

12 · 3 病历卡十三张旧病历,变成十三道防线

本章不新增编号。作战方案要做的是把前半书的病历卡钉到具体层:哪一层负责挡、看什么形状、失败后开什么开关。下面 13 张卡覆盖从入口到订单落库的主要故障面。

病历卡 No.01连接池耗尽▁▂▅███ 库存与订单层防线:按下游容量限制总连接与并发,获取连接超时快速失败;网关预算不允许把等待搬进连接池。查看定义 →
病历卡 No.02主从复制延迟▁▁▃▁▁█▁ 支付后查询防线:刚创建的订单走会话粘性或读主,页面显示确认中,不把副本暂时不可见说成丢单。查看定义 →
病历卡 No.03缓存与 DB 双写不一致▁▁▁▁▁ 场次配置层防线:DB 为权威源,价格与开售状态变更后幂等失效缓存,并对版本做抽样对账。查看定义 →
病历卡 No.04缓存穿透▁▂▂▃▃▄▄ 入口与缓存层防线:场次 ID、资格参数先校验,非法 key 被过滤;不存在的数据缓存短时空值。查看定义 →
病历卡 No.05缓存击穿▁▁▁█▁▁▁ 热点详情层防线:开票场次提前预热,逻辑过期配互斥回源,让一个 key 的失效只产生一次 DB 查询。查看定义 →
病历卡 No.06缓存雪崩███▁▁▁▁ 缓存集群防线:TTL 抖动、高可用与预热并用;缓存异常时收紧网关配额,先保护 DB 再慢放量。查看定义 →
病历卡 No.07重试风暴▁▂▄█████ 调用链防线:支付编排是重试责任层,其他层不叠加尝试;端到端预算配 backoff 与 jitter。查看定义 →
病历卡 No.08惊群效应▁▁█▁▁█▁ 排队与恢复防线:凭证过期、客户端退避和恢复放量都加随机抖动,避免整点集体醒来。查看定义 →
病历卡 No.09级联故障███▇▅▃▁ 服务隔离防线:库存、订单、支付各有舱壁和熔断;任一依赖变慢时,非核心功能降级,不借走交易资源。查看定义 →
病历卡 No.10热点分片▁▁▁█▁▁ 热点场次防线:顶流场次独立通道并在场次内二次打散,告警最热/平均而非集群平均。查看定义 →
病历卡 No.11消息积压▁▂▃▄▅▆▇ 订单消息层防线:同时监控积压量、增长速率和预计追平时间;追不平时停止发新凭证。查看定义 →
病历卡 No.12消息丢失与重复▁▁▁▁▁ 落库与对账防线:采用 at-least-once 配业务幂等,死信与库存/订单对账共同发现无症状差异。查看定义 →
病历卡 No.13分布式锁失效▁▁█▁▁ 库存互斥防线:优先使用原子扣减;确需锁时校验 owner、设置合理租期,并以数据库约束兜底。查看定义 →

病历卡不是复习清单,而是上线门禁。任一防线若没有指标、开关、负责人和恢复条件,就不能在 D-1 战备会上被标成绿色。

12 · 5 权衡让用户排队,还是尽快告诉他没抢到

超过库存处理能力的请求不能继续向下。长队列给用户“还有机会”的体验,快速随机拒绝保护系统并缩短等待,短队列加准入凭证则折中两者。三个方案都成立,但公平性、资源占用与产品承诺不同。

决策点 D2

30 万个合格购票意图到达排队准入层,票只有 6 万张。你让多少个进队列?

A · 全部排队:先来后到容易解释,用户能看见位置,库存释放节奏也可控。 账单:超长队列占状态与连接,位置不等于可买到票;若预计等待超过开票窗口,继续排只是在延迟失败。适合供给会持续补充、等待时间可预测的场景。

B · 快速随机拒绝:系统成本低,用户迅速得到结果,超量流量不会形成积压。 账单:随机公平难向用户解释,网络与设备差异可能影响机会;拒绝后的客户端还要被禁止立即重试。适合库存极少、开票窗口短、系统存活优先的场景。

C · 短队列 + 凭证:只维护库存层能在用户预算内消化的队列,凭证让准入可审计,超龄请求及时退出。 账单:凭证签发、防伪、过期与多端同步增加复杂度;队列长度和超时必须随消费能力动态校准。适合高价值秒杀且需要兼顾公平、体验和容量的场景。

「秒抢」的选择:采用 C。只给约 8 万个合格购票意图发短期凭证(约为 6 万库存的 1.3 倍,留出弃单余量),其余约 22 万当场返回「未获得本轮资格」,不进队列空耗;客户端按凭证轮询,不自行重试。理由是 6 万张票无法让全部 30 万个候选意图都进入长队,而可审计凭证比纯随机拒绝更容易处理公平与风控争议。

条件翻转

假设产品把公平性规则改了:不再是「先到先得」,而是实名抽签——30 万个合格购票意图全部先登记,开票后统一摇号抽出 6 万个中签名额,与谁先点进来无关。你在 D2 的选择还成立吗?

翻转变量是「决胜规则」——到达顺序还是随机摇号。D2 的队列、凭证、超时,本质上都是在给「谁先到」这件事赋予意义:位置代表先后,凭证代表「你比后面 22 万人更早」。一旦规则改成摇号,到达顺序和中签概率不再相关,A 这个方案立刻不成立——用户排的每一分钟队都是纯粹的沉没成本,系统还要为一个不再决定结果的顺序去维护连接和状态,纯浪费。B 才是对的:既然顺序不影响结果,就不该用「先筛掉 22 万」的方式制造顺序假象,而应该把全部合格意图都当场确认收到、后台批量摇号——这样最快,用户体验也最诚实。C 是这道题最容易踩的陷阱:它嘴上说「抽签」,做法却是先用到达顺序砍掉 22 万人的资格,只在剩下的 8 万里摇号——这不是随机,是给「先到先得」换了个更好听的名字,公平性承诺和实际机制对不上。

规则翻了,但账没有翻:入口限流(第 6 章)、热点场次独立通道(第 8 章)、消息队列削峰(第 9 章)、库存真相(第 10 章)、幂等护栏(第 11 章)——摇号制下这五道防线一个都不能少,这里要把两件事分开,否则前后会打架:登记是一次轻量写入(记下「这个实名用户想要」),它可以全量接住 30 万——这正是摇号制相对排队制的优势;但中签之后进入下单与支付链路的那 6 万,走的仍是原来那条重路径,入口限流、热点通道、削峰、库存真相、幂等护栏一道都不能少。实名去重照样要靠幂等键做,交易侧的容量预算一分钱没省。翻的只是「谁能进最后这 6 万」的判定方式,不是「系统能不能扛住 30 万」这道题。

12 · 6 复盘七天后,库存层只看见它该看见的

回放从 D-7 到开票的正确顺序——先定契约,再压测,随后演练失败,最后才面对真实流量:

D-7 10:30

冻结漏斗预算。确认「1000 万次 → 100 万次 → 30 万个候选意图 → 发凭证 8 万」的逐层目标,以及排队层 1 万 QPS 的释放速率;每层登记 owner、红线和降级开关;释放速率低于压测容量。

D-3 21:00

全链路压测。使用隔离测试数据回放百倍入口,验证 CDN 回源 9.4%、网关 29.4%(据此产生候选意图约 27.6 万,按库存发出凭证 8 万);排队层实测释放约 9,000 QPS,库存峰值约 9,000 QPS,订单对账无差异。

D-1 15:00

故障演练。依次注入缓存 miss、支付超时和一个订单消费者下线;限流、熔断、积压告警与自动回滚均触发,发现并修复一处客户端立即重试。

T0 20:00

按契约开票。入口峰值接近预估,库存层峰值约 9,700 QPS;核心 P99 420ms,库存与订单最终对平。售罄后停止发凭证,队列没有拖成亡灵流量。

壹
先预算,后选型。没有逐层输入输出,CDN、网关、MQ 和数据库只是组件清单;容量契约把它们变成一条漏斗。
貳
失败路径也要压测。正常路径能接 1 万 QPS,不代表缓存雪崩、消费者下线或支付熔断时仍能接住;预案要在故障状态下验收。
叁
公平是可审计的工程属性。凭证签发、排队位置、设备风控和拒绝原因都要留下证据,不能只靠“随机看起来公平”。

12 · 7 自测你能把百倍流量压成一条可控细流吗

Q1 · 看形状诊断

订单消息积压曲线呈 ▁▂▃▄▅▆▇,生产速率持续高于消费速率。漏斗哪一层应立即收紧?

B。单调爬升不回落是 No.11 消息积压,生产持续大于消费时要从上游减量。D 很诱人,因为积压发生在订单域,但扩大查询池不改变消息消费差额,还可能抢走消费者的数据库资源。

Q2 · 漏斗算术(注意分母与单位)

首波 1000 万次请求,CDN 后通过 10%。网关去重后按候选配额截断至 30 万个合格意图;准入层按库存发 8 万张凭证;排队层以 1 万 QPS 向库存释放。问:网关的有效放行率与实际排空时间?

B。有效放行率的分母是网关的到达量,也就是 CDN 后的 100 万:300,000 ÷ 1,000,000 = 30%。排空时间只看真正入队的 8 万张凭证:80,000 ÷ 10,000/s = 8 秒。A 是最诱人的错误项——它把分母取成了原始入口 1000 万(30 万 ÷ 1000 万 = 3%);百分比脱离分母就没有意义。C 默认「候选数 = 入队数」,漏掉了准入层这道门,30 秒是假如不设门才会发生的事。D 把入队数当成了放行数(8 万 ÷ 100 万)。

Q3 · 漏斗边界

库存稳定容量约 1.2 万 QPS,排队层当前释放 1.5 万 QPS。最正确的处理是什么?

A。释放速率必须服从已验证的持续容量,还要给长尾与故障留余量。B 很诱人,因为 1.5 万只高出 25%,但容量边缘的排队会非线性恶化,平均能扛不等于峰值可持续。

Q4 · 病历卡设防

顶流场次让一个订单分片独占大部分流量,其他分片仍空闲。漏斗里最对症的组合是?

B。这是 No.10 热点分片,隔离、打散与配额分别控制爆炸半径、分布和总量。C 是最诱人的错误项,但冷分片扩池帮不了热 key,热分片扩池还可能加重数据库争用。

🎯 面试映射 · 本章直接覆盖的高频追问

「请用 45 分钟设计一个秒杀系统。」 答题骨架:需求与库存约束 → 峰值估算 → 静态化/网关/排队漏斗 → 库存原子扣减 → 订单支付异步化 → 幂等对账 → 降级与容量验证。
「怎么防黄牛?」 答题骨架:账号/设备/行为风险信号 → 资格预校验 → 限频与凭证 → 排队公平 → 支付与收货约束 → 申诉和误伤指标。
「全链路压测怎么做?」 答题骨架:隔离测试数据 → 按漏斗逐层给量 → 同时验证正常与故障路径 → 观察到达/通过/拒绝/积压 → 自动回滚 → 对账收尾。反直觉加分点:压测要证明拒绝路径也能工作。