# 案例 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)