# 案例 01 · StarArena:2 万座演唱会抢票系统
> 一句话点题:**本案例练的是「海量请求下的钱货一致性」——当 100 万人同时抢 2 万张票,架构的核心不是单纯扛流量,而是不能超卖、不能乱扣钱、出问题还能恢复。**
---
> **🧪 案例篇第 1 篇 · 本案例只练一件事**
>
> 练**有限库存 + 支付链路**下的架构判断:什么时候单体足够,什么时候必须加准入控制,什么时候库存不能再靠一条 SQL 硬扛,以及支付成功但出票失败时系统怎么把状态补回来。
>
> | 读完你应该能 | 本案例靠什么练 |
> |---|---|
> | 判断为什么抢票系统不能让所有人直冲下单接口 | 用 100 万人抢 2 万张票算一笔入口账 |
> | 说清锁座、扣库存、支付之间的取舍 | 对比「支付后扣」「下单扣」「锁座预占」 |
> | 把支付失败、回调丢失、出票失败写进架构 | 用状态机 + 幂等 + 对账补偿兜底 |
> | 看懂为什么这不是重写票务模板 | 只展开抢票主链路,通用能力回链模板 |
>
> **重要提醒:下面是教学化案例,不是任何公司的内部图纸。** 数字用于数量级推理,目的是练判断,不是复刻某个平台真实实现。
---
## 开场:为什么抢票不是普通下单
因为它足够具体,也足够容易出事。
> **StarArena** 是一个演唱会票务平台。今晚 20:00 开售一场热门演唱会,场馆 2 万座,预约提醒人数 100 万。用户希望准点进来、选票档、下单、支付、拿票;主办方希望票尽快卖完;平台最怕三件事:**超卖、扣钱没票、全站崩掉。**
如果这是普通商品,库存不够还可以补货;但演唱会座位是**天然有限库存**。2 万个座位就是 2 万个座位,卖多一张就是事故。
如果这是普通浏览高峰,页面慢一点还可以忍;但抢票的高峰是**瞬时尖刺**。大量用户会在同一秒点同一个按钮,而且他们抢的是同一批票档和座位。
所以本章不是讲「票务系统有哪些模块」,而是讲一个更尖锐的问题:
> **如何在极短时间内,把有限库存安全地卖出去,并且让座位、订单、支付、出票最终对得上。**
---
## 读前小词典
这篇会反复出现几个词,先用人话对齐一下:
| 词 | 人话解释 |
|---|---|
| QPS / req/s | 每秒有多少个请求打进来。比如 60,000 req/s 就是每秒 6 万次请求。 |
| P99 | 99% 的请求都能在这个时间内完成。P99 < 800ms,就是 100 个请求里至少 99 个要在 0.8 秒内返回。 |
| CDN | 靠近用户的静态内容缓存网络。图片、JS、活动页这类不常变的内容,尽量让 CDN 扛,不要都打到自己的服务器。 |
| 单体 | 一个应用里放了大部分功能,比如活动、座位、订单、支付回调都在一个部署单元里。 |
| 虚拟等候室 | 排队系统。先把人拦在外面,按系统能承受的速度一批批放进去。 |
| 令牌 | 一次性通行凭证。拿到令牌,才允许进入选票 / 锁座链路。 |
| 锁座 | 暂时把座位占住一小段时间。用户按时付款就正式出票;没付款就释放回去。 |
| 状态机 | 明确规定订单能从哪个状态走到哪个状态,比如「待支付 → 已支付 → 已出票」。 |
| 回调 | 别的系统事后通知你结果。比如支付平台告诉你:这个用户已经付钱了。 |
| 幂等 | 同一个请求重复来几次,结果也只算一次。支付回调很容易重复,所以必须幂等。 |
| 对账 / 补偿 | 事后把订单、支付、出票记录对一遍;发现卡住或不一致,再补一个动作把状态修回来。 |
| 限流 | 控制进入速度。系统一次只能处理 3000 个请求,就不要放 60000 个请求进去。 |
| 竞态 | 两个动作差不多同时发生,谁先谁后不稳定。比如支付回调和超时释放撞在一起。 |
---
## 一、起始状态:小活动时,单体并没有错
StarArena 第一版不是为顶流演唱会做的。早期它只卖小型 Livehouse、话剧、脱口秀。
当时的约束大概是这样:
| 维度 | 起始阶段 |
|---|---|
| 单场座位 | 300~3000 |
| 开售同时在线 | 1000~5000 |
| 峰值下单请求 | 50~200 QPS(每秒 50~200 次下单请求) |
| 团队规模 | 5~8 人 |
| 核心目标 | 快速上线,少做复杂基础设施 |
| 可接受体验 | 热门场次偶尔慢,但不能卖错票 |
这时一个普通 Web 单体完全合理:
```
用户
│
▼
Web / App
│
▼
┌────────────────────────────────────┐
│ 票务单体 │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ 活动/座位 │ │ 订单 │ │ 支付回调 │ │
│ └────────┘ └────────┘ └────────┘ │
└────────────────────────────────────┘
│
▼
┌────────────┐
│ 关系型数据库 │
│ seats/orders│
└────────────┘
│
▼
第三方支付(支付宝、微信支付、Stripe 这类外部支付平台)
```
它的好处很实在:
- **事务简单**:座位锁定、订单创建可以尽量放在同一个数据库里处理。
- **开发快**:一个团队、一个代码库、一个部署单元,沟通成本低。
- **问题好查**:小流量下,慢查询、失败订单都能人工兜住。
所以不要一上来就说「单体不行」。在旧约束下,它是合理答案。真正的问题是:**约束变了。**
---
## 二、量化假设:先算清楚洪峰有多尖
热门演唱会开售时,我们先做一笔粗算。
```
场馆座位:20,000
预约用户:1,000,000
开售前 10 秒实际点击抢票:300,000
用户平均重试次数:2 次
入口抢票请求 ≈ 300,000 × 2 ÷ 10 秒 = 60,000 req/s(每秒 6 万次请求)
真正能成功买到票的人 ≤ 20,000
注定失败或排队的人 ≥ 980,000
```
这笔账很要命:平台面对的不是「把 60,000 req/s 全部处理成功」,因为根本没有那么多票。真正的问题是:
> **绝大多数请求注定买不到票,所以不能让它们全部打进最贵、最脆弱的核心链路。**
再看核心链路预算:
| 链路 | 目标 |
|---|---|
| 活动页静态内容 | CDN 扛住,不进入核心系统 |
| 等候室排队 | 可等待,但不能丢资格 |
| 选票 / 锁座 | P99 < 800ms,失败要明确 |
| 支付跳转 | 可依赖第三方,但要状态可恢复 |
| 支付回调到出票 | 最终一致,不能靠一次同步调用赌命 |
这一步直接决定了架构重心:**先挡流量,再谈下单;先控资格,再谈库存;先保证可恢复,再谈体验顺滑。**
---
## 三、触发信号:第一个裂缝在哪里出现
小活动时的单体上了热门演唱会,很快会出现这些信号:
| 信号 | 表现 | 为什么这是架构问题 |
|---|---|---|
| 入口洪峰过尖 | 开售 10 秒内 60,000 req/s 打到抢票接口 | 这不是普通扩容能解决的,因为大部分请求本来就不该进入核心链路 |
| 热门票档变成热点 | 同一个票档 / 座位区域被反复争抢 | 库存扣减会集中到少数记录或少数分片上 |
| 数据库锁等待飙升 | 下单 P99 从几百 ms 变成数秒甚至超时 | 座位锁定和订单创建耦合太紧,热点行竞争拖垮链路 |
| 支付回调乱序 / 丢失 | 用户支付成功,订单仍显示待支付 | 第三方支付是外部系统,不能假设同步回调一定成功 |
| 人工处理失败订单爆炸 | 客服开始手工查支付、查座位、补票 | 说明系统缺少可恢复的状态机和对账能力 |
注意,这些信号不是在说「系统慢」。它们在说:**关键状态开始对不上了。**
抢票系统最危险的不是慢,而是慢的时候还把票和钱弄错。
---
## 四、核心矛盾:不能让「抢」直接变成「买」
开售按钮背后有三个完全不同的动作:
1. **争资格**:这个用户有没有资格进入购买链路?
2. **锁库存**:这张票或这个票档还能不能暂时占住?
3. **收钱出票**:钱到了以后,订单和票能不能最终对上?
早期单体把这三件事揉在一起:
```
用户点击抢票
└─▶ 查库存
└─▶ 创建订单
└─▶ 调支付
└─▶ 支付回调后出票
```
小流量下没问题。大促洪峰下,这条路会被两个事实撕开:
- **资格竞争是海量的**,但真正进入锁座的人应该很少。
- **支付是慢外部依赖**,不能把座位无限期绑在支付结果上。
所以新的架构命题变成:
> **把「抢资格」「锁库存」「收钱出票」拆成三个可控阶段,每一段都能限流、超时、重试、补偿。**
---
## 五、方案推演:票到底什么时候扣
这是本案例最重要的决策。看起来只是「库存扣减时机」,实际上决定了订单、支付、出票的整个结构。
### 方案 A:支付成功后再扣票
```
下单 → 支付 → 扣票 → 出票
```
| 优点 | 代价 |
|---|---|
| 没付款前不占库存,实现简单 | 可能出现用户支付成功但票已经没了 |
| 座位利用率高 | 支付后的失败代价极高,需要退款和客服兜底 |
这个方案适合库存充足的普通商品,不适合热门演唱会。因为抢票里「支付成功但没票」是严重事故。
### 方案 B:下单时直接扣票
```
下单成功 = 正式扣票 → 等用户支付
```
| 优点 | 代价 |
|---|---|
| 不容易超卖 | 大量用户不支付会长期占票 |
| 逻辑直观 | 黄牛可以恶意占座,库存利用率下降 |
这个方案比 A 安全,但太僵硬。抢票时很多用户会放弃支付、支付失败、网络中断,如果直接扣死库存,好票会被大量「未支付订单」占住。
### 方案 C:锁座预占 + 超时释放
```
抢到资格 → 锁座预占(15 分钟) → 创建待支付订单 → 支付成功 → 正式出票
└─ 超时未支付 → 释放座位
```
| 优点 | 代价 |
|---|---|
| 防超卖,也避免未支付订单永久占票 | 状态机复杂,必须处理超时、回调、补偿 |
| 用户体验清楚:「已锁座,请在 15 分钟内支付」 | 要有定时释放、对账、幂等回调 |
StarArena 选择方案 C。
它不是最简单的,但它匹配当前约束:**票不能超卖,支付可能失败,用户不能无限占座。**
---
## 六、关键架构决策:用 ADR 把为什么留下来
ADR 是 Architecture Decision Record,可以理解成「架构决策记录」。它不是写方案细节,而是把**当时为什么这么选、放弃了什么、什么时候要重新看**记下来。
### ADR-01:引入虚拟等候室保护核心抢票链路
- **背景**:开售前 10 秒预计入口请求约 60,000 req/s,核心锁座链路稳定容量只有几千 req/s。绝大多数用户注定买不到票。
- **选择**:所有用户先进入虚拟等候室,由等候室按令牌分批放行到选票 / 锁座链路。
- **放弃**:放弃「所有用户立即进入购买页」的即时体验。
- **换来**:核心系统容量可控,可以优雅排队,而不是让数据库和订单链路一起雪崩。
- **风险**:需要处理令牌防刷、排队公平性、刷新不丢资格。
- **复审条件**:如果活动规模下降到核心链路可直接承受,或平台核心容量提升一个数量级,重新评估等候室策略。
### ADR-02:采用锁座预占,而不是支付后扣票
- **背景**:热门场次库存有限,支付成功后无票会造成退款、投诉和信任事故。
- **选择**:用户拿到购买资格后,先锁定座位或票档库存,生成待支付订单;支付成功后转为正式出票;超时未支付自动释放。
- **放弃**:放弃单步下单的简单性,引入订单状态机和超时任务。
- **换来**:不超卖,同时避免未支付订单永久占库存。
- **风险**:超时释放与支付回调可能竞态,必须用幂等和状态条件更新保护。
- **复审条件**:如果业务从「具体座位」改成「可补货虚拟票券」,可以重新评估是否需要锁座。
### ADR-03:支付和出票走最终一致,必须有对账补偿
- **背景**:第三方支付回调可能延迟、重复、丢失;出票服务也可能短暂失败。同步调用链不能覆盖所有异常。
- **选择**:支付回调幂等推进订单状态;后台定时主动查单;对账任务比对订单、支付、出票三方状态,发现卡住就补偿。
- **放弃**:放弃「一次同步调用完成所有事」的简单心智。
- **换来**:支付成功但出票失败、回调丢失等异常都能被系统发现并恢复。
- **风险**:用户会短暂看到「待出票」状态,需要产品文案和客服工具配合。
- **复审条件**:如果支付提供方支持更强的事务担保,仍然不能取消对账,只能降低补偿频率。
---
## 七、演进后的结构与数据流
下面只画和本案例有关的抢票主链路。通用的账号、活动管理、营销、客服、通知,不在这里展开。
### 旧路径
```
用户
│
▼
票务单体
│
├─▶ 查座位/扣库存
├─▶ 创建订单
└─▶ 调支付
│
▼
支付回调
│
▼
更新订单/出票
```
问题是:入口洪峰、库存热点、支付不确定性都挤在同一条同步链路里。
### 新路径
```
用户
│
▼
┌──────────────┐
│ CDN / 活动页 │ ← 静态内容尽量不进核心系统
└──────┬───────┘
│ 点击抢票
▼
┌──────────────┐
│ 虚拟等候室 │ ← 排队、发令牌、控放行速度
└──────┬───────┘
│ 放行令牌
▼
┌──────────────┐
│ 选票 / 锁座入口 │ ← 校验令牌、限流、防刷
└──────┬───────┘
│
▼
┌──────────────┐ ┌──────────────┐
│ 座位 / 库存服务 │────▶│ 订单状态机 │
│ 锁座/释放/确认 │ │ 待支付/已支付 │
└──────┬───────┘ └──────┬───────┘
│ │
│ ▼
│ ┌──────────────┐
│ │ 第三方支付 │
│ └──────┬───────┘
│ │ 回调/主动查单
▼ ▼
┌──────────────┐ ┌──────────────┐
│ 超时释放任务 │ │ 出票服务 │
└──────────────┘ └──────┬───────┘
│
▼
┌──────────────┐
│ 对账 / 补偿任务 │
└──────────────┘
```
这张图的核心变化不是「组件变多了」,而是边界变清楚了:
- **等候室**挡住大部分无效洪峰。
- **库存服务**只管座位的锁定、释放、确认。
- **订单状态机**承认支付和出票不会一次成功。
- **对账补偿**负责把卡住的状态推回来。
### 跟一次成功抢票走到底
```
1. 用户打开活动页,静态资源从 CDN 返回。
2. 20:00 点击抢票,请求进入虚拟等候室。
3. 等候室根据容量发放一次性放行令牌。
4. 用户携带令牌进入选票 / 锁座入口。
5. 系统校验令牌、用户资格、防刷规则。
6. 库存服务尝试锁定座位或票档库存,设置 15 分钟过期时间。
7. 锁座成功后,订单状态机创建订单:待支付。
8. 用户跳转第三方支付。
9. 支付成功回调到达,订单用幂等键推进为已支付。
10. 库存预占转为正式确认,出票服务生成电子票。
11. 对账任务稍后检查订单、支付、出票三方状态是否一致。
```
这里有几个关键点:
- 放行令牌是**准入控制**,不是订单。
- 锁座是**临时占用**,不是最终出票。
- 支付回调必须**幂等**,因为第三方可能重复通知。
- 出票失败不能丢,订单应该进入「待出票 / 补偿中」,而不是假装成功。
### 再看超时未支付路径
```
1. 用户锁座成功,订单进入待支付。
2. 15 分钟内没有支付成功事件。
3. 超时任务尝试释放座位。
4. 订单状态从待支付变成已关闭。
5. 座位重新回到可售池或下一轮放票池。
```
注意这里也有竞态:用户可能在第 14 分 59 秒支付,回调第 15 分 02 秒才到。正确做法不是靠时间猜,而是用状态条件保护:
```
只有订单仍是「待支付」时,超时任务才能关闭订单。
只有订单仍是「待支付 / 支付确认中」时,支付回调才能推进已支付。
每次推进状态都要带版本号或状态条件。
```
---
## 八、坏了怎么办:故障场景与兜底
| 故障 | 直接后果 | 检测方式 | 架构兜底 |
|---|---|---|---|
| 等候室发令牌过快 | 锁座链路被打爆 | 锁座入口 P99、错误率、令牌消耗速度 | 动态降低放行速率,排队页提示等待 |
| 用户锁座后不支付 | 好座位被占住 | 待支付订单超时扫描 | 15 分钟超时释放座位 |
| 支付成功但回调丢失 | 用户扣钱,订单仍待支付 | 主动查单、支付对账 | 幂等补推订单状态,继续出票 |
| 支付回调重复 | 订单可能被重复推进 | 回调幂等键、支付单唯一索引 | 已处理直接返回成功 |
| 出票服务短暂故障 | 用户已支付但没拿到票 | 已支付未出票订单扫描 | 进入待出票,恢复后补发 |
| 库存释放和支付回调撞车 | 可能误关已支付订单 | 状态版本冲突、异常状态告警 | 条件更新 + 对账修复 |
抢票系统的成熟度,往往不是看成功路径有多漂亮,而是看这些坏情况能不能被系统自己发现、自己推进、自己修回来。
---
## 📌 拿模板验证这次推演
本案例不是重写票务模板,而是把模板里最危险的一条链路拿出来细推。
| 可复用模板 | 本案例复用什么 | 本案例重点补什么 |
|---|---|---|
| [在线票务 / 抢票](../../templates/online-ticketing/README.md) | 虚拟等候室、锁座、超时释放、防超卖 | 用具体数字推导为什么必须挡流量、为什么锁座比直接扣票更合适 |
| [电商平台](../../templates/ecommerce-platform/README.md) | 商品 / 订单 / 库存 / 支付的基本关系 | 把「普通下单」压缩成「有限库存开售」这个极端场景 |
| [支付系统](../../templates/payment-system/README.md) | 幂等、状态机、对账、补偿 | 讲支付成功但出票失败时,票务侧如何恢复 |
| [通知 / 推送系统](../../templates/notification-system/README.md) | 出票通知、失败重试、限频 | 本案例不展开通知系统,只把它当出票后的异步能力 |
> **读法建议**:先读本章,再回看 [在线票务 / 抢票模板](../../templates/online-ticketing/README.md)。你会更容易看懂模板里的「虚拟等候室」为什么是灵魂部件,而不是一个可有可无的排队页面。
---
## 🎯 随堂检验
---
## 本案例小结
- **旧架构不是错,约束变了才需要演进。** 小活动下单体足够合理;顶流开售时,入口洪峰和有限库存把它逼到边界。
- **先算入口账,再画架构图。** 100 万预约、10 秒 30 万点击、用户平均重试 2 次,会把入口推到约 60,000 req/s,这直接逼出虚拟等候室。
- **抢票要拆成三段:**抢资格、锁库存、收钱出票。三段分开后,每一段才能限流、超时、重试、补偿。
- **锁座预占是拿复杂度换正确性。** 它比支付后扣票复杂,但能避免「钱扣了没票」,也能通过超时释放避免长期占票。
- **支付成功不是结束,而是状态推进的开始。** 回调会迟到、重复、丢失;没有幂等、状态机、对账补偿,系统迟早需要人工救火。
> **承上启下**:这一章把 [在线票务 / 抢票模板](../../templates/online-ticketing/README.md) 的主链路拆开走了一遍。下一章案例可以继续沿着同一方法,换一个具体项目:不是背模板,而是先看旧约束,再看触发信号,最后看架构如何被一步步逼出来。
---
## 相关链接
- 模板对照:[在线票务 / 抢票](../../templates/online-ticketing/README.md) · [电商平台](../../templates/ecommerce-platform/README.md) · [支付系统](../../templates/payment-system/README.md) · [通知 / 推送系统](../../templates/notification-system/README.md)
- 方法论:[02 · 架构师的思考框架](../../tutorial/02-架构师的思考框架.md) · [07 · 从 0 到 1 设计一个系统](../../tutorial/07-从0到1设计一个系统.md) · [08 · 架构决策记录与演进](../../tutorial/08-架构决策记录与演进.md)
- 硬骨头:[11 · 数据一致性工程](../../tutorial/11-数据一致性工程.md) · [12 · 为失败而设计](../../tutorial/12-为失败而设计.md) · [13 · 规模化的力学](../../tutorial/13-规模化的力学.md) · [14 · 演进与拆分大型系统](../../tutorial/14-演进与拆分大型系统.md)