Skip to content

Latest commit

 

History

History
453 lines (339 loc) · 22.4 KB

File metadata and controls

453 lines (339 loc) · 22.4 KB

案例 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 分钟超时释放座位
支付成功但回调丢失 用户扣钱,订单仍待支付 主动查单、支付对账 幂等补推订单状态,继续出票
支付回调重复 订单可能被重复推进 回调幂等键、支付单唯一索引 已处理直接返回成功
出票服务短暂故障 用户已支付但没拿到票 已支付未出票订单扫描 进入待出票,恢复后补发
库存释放和支付回调撞车 可能误关已支付订单 状态版本冲突、异常状态告警 条件更新 + 对账修复

抢票系统的成熟度,往往不是看成功路径有多漂亮,而是看这些坏情况能不能被系统自己发现、自己推进、自己修回来。


📌 拿模板验证这次推演

本案例不是重写票务模板,而是把模板里最危险的一条链路拿出来细推。

可复用模板 本案例复用什么 本案例重点补什么
在线票务 / 抢票 虚拟等候室、锁座、超时释放、防超卖 用具体数字推导为什么必须挡流量、为什么锁座比直接扣票更合适
电商平台 商品 / 订单 / 库存 / 支付的基本关系 把「普通下单」压缩成「有限库存开售」这个极端场景
支付系统 幂等、状态机、对账、补偿 讲支付成功但出票失败时,票务侧如何恢复
通知 / 推送系统 出票通知、失败重试、限频 本案例不展开通知系统,只把它当出票后的异步能力

读法建议:先读本章,再回看 在线票务 / 抢票模板。你会更容易看懂模板里的「虚拟等候室」为什么是灵魂部件,而不是一个可有可无的排队页面。


🎯 随堂检验


本案例小结

  • 旧架构不是错,约束变了才需要演进。 小活动下单体足够合理;顶流开售时,入口洪峰和有限库存把它逼到边界。
  • 先算入口账,再画架构图。 100 万预约、10 秒 30 万点击、用户平均重试 2 次,会把入口推到约 60,000 req/s,这直接逼出虚拟等候室。
  • **抢票要拆成三段:**抢资格、锁库存、收钱出票。三段分开后,每一段才能限流、超时、重试、补偿。
  • 锁座预占是拿复杂度换正确性。 它比支付后扣票复杂,但能避免「钱扣了没票」,也能通过超时释放避免长期占票。
  • 支付成功不是结束,而是状态推进的开始。 回调会迟到、重复、丢失;没有幂等、状态机、对账补偿,系统迟早需要人工救火。

承上启下:这一章把 在线票务 / 抢票模板 的主链路拆开走了一遍。下一章案例可以继续沿着同一方法,换一个具体项目:不是背模板,而是先看旧约束,再看触发信号,最后看架构如何被一步步逼出来。


相关链接