一个体育场有 60,000 个座位。一场大型巡演开票时,数百万人会在同一分钟涌入,都想要同样那几千个好座位。2022 年 11 月,Ticketmaster 报告称,Eras Tour 预售期间系统请求总数达到 35 亿次,是其此前峰值的四倍。存储 60,000 行数据微不足道。难的是公平地裁决数百万个争抢这些行的请求,并且绝不把同一个座位卖两次。
本文为这类开售设计一个票务预订系统:需求、流量形态、虚拟等候室、带过期时间的座位预留、预订事务、支付、热门座位和机器人,并配有一张架构图。
需求
功能需求:
- 浏览活动和带有余票状态的座位图。
- 选择座位,并在付款期间预留它们。
- 付款、收到门票,如果没有完成付款则释放座位。
非功能需求:
- 绝不把同一个座位卖两次。这是唯一不能妥协的规则。
- 公平对待每个人:按到达顺序或公开的随机抽签,而不是看谁刷新得最快。
- 在开售的最初几分钟里,扛住比平时高一到两个数量级的流量。
- 快速而明确地失败:迅速给出“售罄”,胜过一直转圈的页面。
开售的流量形态
假设有 60,000 个座位,开售后十分钟内有 200 万人到达。这相当于每秒约 3,300 个新到达者,每个座位对应 30 多个人。再多的容量也无法让每个人都买到票。系统真正的任务是:让可持续数量的人同时购票,迅速拒绝其余的人,并在极端争抢下保持库存正确。
因此设计分为两部分:一道吸收人潮的闸门,以及一个只会看到它能处理的购票者数量的预订核心。
虚拟等候室
在预订流程前面放一个队列:
- 开售前到达的粉丝,会在所有提前到达的人中获得一个随机位置,所以在 9:59 打开一百个标签页毫无用处。之后到达的人按顺序排队。
- 等候室是由 CDN 提供的静态页面,因此数百万个等待中的浏览器几乎不产生成本。
- 粉丝按批次放行,速率根据实测的预订处理能力来调整,例如每分钟 2,000 人。
- 每个被放行的粉丝都会获得一个与其会话绑定、带签名的短期令牌。预订 API 会拒绝没有有效令牌的请求。
Cloudflare Waiting Room 和 Queue-it 等托管产品可以做到这一点。自建版本则是一个以到达时间为分数的 Redis 有序集合,位置就是成员的排名:
ZADD queue:evt42 1793606400123 session:9f2c # score = arrival time in ms
ZRANK queue:evt42 session:9f2c # position, 0-based
ZPOPMIN queue:evt42 2000 # admit the next batch
对于提前到达的人群,分配随机分数而不是到达时间。无论哪种方式,队列都会把无上限的峰值变成平稳的流量,系统的其余部分正是按这个流量来设计容量的。
库存模型
对号入座需要每个座位一行:
| 列 | 示例 | 用途 |
|---|---|---|
event_id, seat_id |
evt42, S112-R7-14 |
标识;两者组合唯一 |
status |
available, held, sold |
当前状态 |
hold_id, held_until |
h_81f, 时间戳 |
谁预留了它、预留到何时 |
order_id |
ord_5521 |
售出时设置 |
不对号入座的场次更简单:不按单个座位,而是每个票价区一个计数器,用条件更新递减。
带过期时间的座位预留
预留会在购票者付款的几分钟内为其保留座位。存放预留最安全的地方,是记录销售的同一个数据库,并使用条件更新:
UPDATE seats
SET status = 'held', hold_id = $3, held_until = now() + interval '10 minutes'
WHERE event_id = $1
AND seat_id = ANY($2)
AND (status = 'available' OR (status = 'held' AND held_until < now()));
-- If fewer rows changed than seats requested, roll back: someone else got one.
两个购票者可能在同一毫秒为同一个座位发出这条语句。数据库会把对该行的更新串行化:其中一个修改了该行,另一个看到零行被修改,并得到明确的“座位刚被抢走”的答复。过期的预留在同一条语句中被回收,因此预留的过期从不依赖后台任务能否按时运行。两个请求的座位集合有重叠时,偶尔会发生死锁;数据库会检测到并中止其中一个,所以遇到死锁错误时重试一次。
在极高的规模下,有些团队会在前面加一个快速过滤器:Redis 中的 SET seat:evt42:S112-R7-14 h_81f NX PX 600000,它只在键不存在时设置该键,并带有十分钟的过期时间。这能为数据库分担负载,但 Redis 的复制是异步的,因此一次故障转移就可能丢失预留。把 Redis 当作一种优化;最终仍由数据库裁决。
最后一道防线,是门票表中 (event_id, seat_id) 上的唯一约束。即使上面的每一层都有 bug,数据库也会拒绝出第二张票。
热门座位与最佳可选座位
每个人都想要前排,于是成千上万的请求瞄准同样的几百行,而其中大多数会失败。有三种技术可以减少争抢:
- 最佳可选座位。 不要让每个人都在座位图上挑选,而是让购票者选择区域和数量,在服务端用
SELECT … FOR UPDATE SKIP LOCKED分配座位,这样并发请求会拿到不同的行,而不是在同样的行上排队。 - 按区域对库存分区,这样不同区域锁定不同的行,也可以放在不同的数据库分片上。
- 用较短的过期时间缓存座位图,并接受它可能落后几秒。决定余票状态的是预留,而不是座位图。
不重复扣款的支付
- 创建一个与预留关联、处于
pending状态的订单。 - 使用由订单 ID 派生的幂等键调用支付服务商。以 Stripe 为例,幂等键最长 255 个字符,使用相同键重试的请求会返回原始结果,而不会再次扣款。
- 支付成功后,在同一个数据库事务中把座位标记为已售、关联到订单,并把订单标记为已支付。
- 监听支付服务商的 webhook,以捕获支付已成功、但你的服务从未收到响应的情况。
预留过期不能与支付发生竞争。支付开始时延长预留,或者把有待处理订单的座位排除在过期之外。否则,一个座位可能过期、被卖给别人,然后它的第一笔支付却成功了。支付网关集成图详细介绍了 webhook 和对账。
释放过期的预留
预留既会惰性地过期(通过上面的 held_until < now() 条件),也会主动地过期(通过一个每隔几秒运行一次的清理任务,把过期的预留标记为可用,让座位图跟上)。不要把过期机制建立在 Redis 的键空间通知之上:Redis 在文档中说明其 Pub/Sub 是“发后即忘”的,断开连接的订阅者会错过事件。
机器人与滥用
等候室是主要的防线,因为它让速度变得无关紧要。再加上按账户的购买上限,按账户、IP 和设备的限流,高需求活动在开售前的预先登记,以及等候室中的人机验证。限流本身见限流器系统设计。
票务预订图
图表内容
- Fans
- Waiting room (static, on CDN)
- Queue positions + admissions
- API gateway: token check
- Booking service
- Seats, holds, orders
- Hold sweeper, every 5 s
- Payment provider
- order.confirmed events
- Ticket delivery
- arrive at 10:00
- ZADD / ZRANK
- batch + signed token
- admitted?
- hold seats, pay
- hold: conditional UPDATE, 10 min
- release expired holds
- charge + Idempotency-Key
- webhook
- order paid
- send tickets
这张图的读法如下:
- 粉丝到达 CDN 上的等候室,等候室让他们等待,并凭签名令牌分批放行。
- API 网关和预订服务只会看到被放行的粉丝。Redis 存储排队位置和放行状态。
- 座位保存在 Postgres 中。 条件更新创建预留,唯一约束保护销售。
- 支付服务商通过幂等键调用,它的 webhook 会回到预订服务。
- 清理函数释放过期的预留;订单队列驱动通过邮件和应用内进行的出票投递。
- Markdown 块列出了开售数字和预留规则。
绘制让评审者能发现竞态的预订系统
- 把闸门和核心分开画。 等候室和预订服务的扩展模型不同,负责人也不同。
- 把预留时长和唯一性规则写在图上。 这两个事实解释了整个设计。
- 把幂等键写在支付连线上。 正是它让重试变得安全。
- 画出过期路径。 座位回流的地方,正是许多重复售票 bug 藏身之处。
通用方法请参阅系统设计图示例。订单事件可以通过事务性发件箱模式发布,门票邮件则可以通过类似通知系统设计中的管道发送。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 为粉丝添加一个 C4 模型节点包中的 人员,为等候室添加一个云节点包中的 CDN 节点。
- 为预订服务添加云节点包中的一个 API 网关 和一个 计算实例 节点。
- 为排队状态添加一个数据节点包中的 Redis 节点,为座位、预留和订单添加一个 Postgres 节点。
- 为支付服务商添加一个 C4 模型节点包中的 外部系统,为预留清理任务添加一个云节点包中的 函数,为订单事件添加一个 队列,并为出票投递再添加一个 计算实例 节点。
- 在连线上标注令牌、预留规则和幂等键,并添加一个基础节点包中的 Markdown 块,写上这些数字。
这个示例共用 11 个图表节点,在 Free 套餐的限制之内。
常见问题
票务系统如何防止重复预订? 在座位行上使用条件更新,使得只有一个请求能把座位从可用变为预留,并在门票表上设置唯一约束作为最后的保障。缓存和队列能降低负载,但最终由数据库裁决。
座位预留应该持续多久? 足够让人从容付款,又足够短,让被放弃的座位能很快回流。五到十分钟是常见的范围。支付开始时延长预留,这样过期就不会与成功的扣款发生竞争。
什么是虚拟等候室? 放在预订流程前面的一个队列,以系统能够承受的速率放行用户。它通常是 CDN 上的静态页面,提前到达的人随机分配位置,之后的人按顺序排队。
座位预留应该放在 Redis 还是数据库中? 数据库应当是事实来源,因为 Redis 的复制是异步的,故障转移可能丢失键。只要数据库仍然强制保证唯一性,Redis 作为前置的快速过滤器效果很好。
如果支付在预留过期之后才成功,该怎么处理? 通过在支付期间延长预留来避免这种情况。如果仍然发生,就自动退款或提供同等的座位,并利用支付服务商的 webhook 来发现这种情况。