跳到正文

票务预订系统设计:座位预留、虚拟排队与杜绝重复售票

设计一个能扛住大型开售的票务后端:虚拟等候室、会过期的座位预留、不会重复售票的预订流程,以及幂等支付。

· 创始人兼开发者

发布于

粉丝到达 CDN 上的等候室,等候室用签名令牌把他们放行到 API 网关和预订服务。预订服务在 Postgres 中预留座位,在 Redis 中记录排队位置,并调用支付服务商。一个清理函数释放过期的预留;一个队列把订单送往出票投递。

一个体育场有 60,000 个座位。一场大型巡演开票时,数百万人会在同一分钟涌入,都想要同样那几千个好座位。2022 年 11 月,Ticketmaster 报告称,Eras Tour 预售期间系统请求总数达到 35 亿次,是其此前峰值的四倍。存储 60,000 行数据微不足道。难的是公平地裁决数百万个争抢这些行的请求,并且绝不把同一个座位卖两次。

本文为这类开售设计一个票务预订系统:需求、流量形态、虚拟等候室、带过期时间的座位预留、预订事务、支付、热门座位和机器人,并配有一张架构图。

需求

功能需求:

  • 浏览活动和带有余票状态的座位图。
  • 选择座位,并在付款期间预留它们。
  • 付款、收到门票,如果没有完成付款则释放座位。

非功能需求:

  • 绝不把同一个座位卖两次。这是唯一不能妥协的规则。
  • 公平对待每个人:按到达顺序或公开的随机抽签,而不是看谁刷新得最快。
  • 在开售的最初几分钟里,扛住比平时高一到两个数量级的流量。
  • 快速而明确地失败:迅速给出“售罄”,胜过一直转圈的页面。

开售的流量形态

假设有 60,000 个座位,开售后十分钟内有 200 万人到达。这相当于每秒约 3,300 个新到达者,每个座位对应 30 多个人。再多的容量也无法让每个人都买到票。系统真正的任务是:让可持续数量的人同时购票,迅速拒绝其余的人,并在极端争抢下保持库存正确。

因此设计分为两部分:一道吸收人潮的闸门,以及一个只会看到它能处理的购票者数量的预订核心。

虚拟等候室

在预订流程前面放一个队列:

  1. 开售前到达的粉丝,会在所有提前到达的人中获得一个随机位置,所以在 9:59 打开一百个标签页毫无用处。之后到达的人按顺序排队。
  2. 等候室是由 CDN 提供的静态页面,因此数百万个等待中的浏览器几乎不产生成本。
  3. 粉丝按批次放行,速率根据实测的预订处理能力来调整,例如每分钟 2,000 人。
  4. 每个被放行的粉丝都会获得一个与其会话绑定、带签名的短期令牌。预订 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 分配座位,这样并发请求会拿到不同的行,而不是在同样的行上排队。
  • 按区域对库存分区,这样不同区域锁定不同的行,也可以放在不同的数据库分片上。
  • 用较短的过期时间缓存座位图,并接受它可能落后几秒。决定余票状态的是预留,而不是座位图。

不重复扣款的支付

  1. 创建一个与预留关联、处于 pending 状态的订单。
  2. 使用由订单 ID 派生的幂等键调用支付服务商。以 Stripe 为例,幂等键最长 255 个字符,使用相同键重试的请求会返回原始结果,而不会再次扣款。
  3. 支付成功后,在同一个数据库事务中把座位标记为已售、关联到订单,并把订单标记为已支付。
  4. 监听支付服务商的 webhook,以捕获支付已成功、但你的服务从未收到响应的情况。

预留过期不能与支付发生竞争。支付开始时延长预留,或者把有待处理订单的座位排除在过期之外。否则,一个座位可能过期、被卖给别人,然后它的第一笔支付却成功了。支付网关集成图详细介绍了 webhook 和对账。

释放过期的预留

预留既会惰性地过期(通过上面的 held_until < now() 条件),也会主动地过期(通过一个每隔几秒运行一次的清理任务,把过期的预留标记为可用,让座位图跟上)。不要把过期机制建立在 Redis 的键空间通知之上:Redis 在文档中说明其 Pub/Sub 是“发后即忘”的,断开连接的订阅者会错过事件。

机器人与滥用

等候室是主要的防线,因为它让速度变得无关紧要。再加上按账户的购买上限,按账户、IP 和设备的限流,高需求活动在开售前的预先登记,以及等候室中的人机验证。限流本身见限流器系统设计。

票务预订图

粉丝到达 CDN 上的等候室,等候室用签名令牌把他们放行到 API 网关和预订服务。预订服务在 Postgres 中预留座位,在 Redis 中记录排队位置,并调用支付服务商。一个清理函数释放过期的预留;一个队列把订单送往出票投递。

11 个组件 · 11 个连接

在 Archboard 中打开
图表内容
  • 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 中构建

  1. 使用 Google 或 GitHub 登录,并新建一张图。
  2. 为粉丝添加一个 C4 模型节点包中的 人员,为等候室添加一个云节点包中的 CDN 节点。
  3. 为预订服务添加云节点包中的一个 API 网关 和一个 计算实例 节点。
  4. 为排队状态添加一个数据节点包中的 Redis 节点,为座位、预留和订单添加一个 Postgres 节点。
  5. 为支付服务商添加一个 C4 模型节点包中的 外部系统,为预留清理任务添加一个云节点包中的 函数,为订单事件添加一个 队列,并为出票投递再添加一个 计算实例 节点。
  6. 在连线上标注令牌、预留规则和幂等键,并添加一个基础节点包中的 Markdown 块,写上这些数字。

这个示例共用 11 个图表节点,在 Free 套餐的限制之内。

常见问题

票务系统如何防止重复预订? 在座位行上使用条件更新,使得只有一个请求能把座位从可用变为预留,并在门票表上设置唯一约束作为最后的保障。缓存和队列能降低负载,但最终由数据库裁决。

座位预留应该持续多久? 足够让人从容付款,又足够短,让被放弃的座位能很快回流。五到十分钟是常见的范围。支付开始时延长预留,这样过期就不会与成功的扣款发生竞争。

什么是虚拟等候室? 放在预订流程前面的一个队列,以系统能够承受的速率放行用户。它通常是 CDN 上的静态页面,提前到达的人随机分配位置,之后的人按顺序排队。

座位预留应该放在 Redis 还是数据库中? 数据库应当是事实来源,因为 Redis 的复制是异步的,故障转移可能丢失键。只要数据库仍然强制保证唯一性,Redis 作为前置的快速过滤器效果很好。

如果支付在预留过期之后才成功,该怎么处理? 通过在支付期间延长预留来避免这种情况。如果仍然发生,就自动退款或提供同等的座位,并利用支付服务商的 webhook 来发现这种情况。

免费开始 — 1 个席位,无需信用卡。

相关文章

← 返回博客

一分钟打开画布

使用 Google 或 GitHub 登录。单席位免费,团队加入后再升级。