票务预订平台看起来像电商,直到你注意到残酷的转折:一场热门演唱会开售时,数百万人在同一瞬间试图买几千个特定座位。那个组合——极小、争抢的库存加巨大并发尖峰——使两个需求相撞。你必须绝不把同一座位卖两次(库存强一致),且必须挺过 thundering herd 而不倒下。设计围绕带恰当锁的座位持有(hold),和一个虚拟候车室(virtual waiting room)来驯服尖峰。它与我们的酒店预订设计共享库存锁定核心。

⚡ 速览要点
  • 绝不超卖——预订库存需要强一致;座位认领必须是原子操作,而非读后写竞态。
  • 临时持有——选座把它置于 held 状态几分钟、用户付款期间;若不付,持有过期、座位返回。
  • 原子锁定座位——条件更新(available → held 仅当仍可用)、SELECT ... FOR UPDATE 或 Redis 锁;谁赢谁得座位,其他人被告知它没了。
  • 虚拟候车室——对热门开售,排队用户并以受控批次放入,使预订后端看到稳定负载、而非海啸。
  • 按一致性需求拆分路径——浏览事件/座位图读多且可缓存(AP);预订路径强一致(CP)。
  • 持有 + 支付 = 一个 saga——持有座位、收款、确认;支付失败或超时则释放持有。
tldr

难的部分是在大量争抢下把每个座位恰好卖一次。把座位建模为状态机(available → held → booked)并用原子条件更新或行锁认领它,使并发买家不能都赢。结账期间持有座位几分钟,超时释放。用一个以批次放入用户的虚拟候车室吸收开售尖峰。缓存读多的浏览路径;让预订路径强一致;并把 hold→pay→confirm 编排为一个 saga。

booking flow
 数百万 ─▶ ┌────────────────┐ 批次放入       ┌──────────────┐
  开售时   │ 虚拟候车室     │─────────────────▶│   预订       │
           │ (队列)         │                  │   服务       │
           └────────────────┘                  └──────┬───────┘
                                     原子认领          │
   浏览(缓存,AP) ───▶ 读副本 / CDN                   ▼
                                          ┌──────────────────┐
                                          │  库存 DB         │ 座位:
                                          │ (强一致, CP)     │ available
                                          └──────────────────┘ → held → booked
                                hold→pay→confirm (saga) ─▶ 支付服务

第 1 步 — 澄清需求

功能:浏览事件;看一个事件带可用性的座位图;预留(持有)一个或多个特定座位;完成购买;释放未付款的持有。非功能:无双重预订 / 无超卖(头条正确性需求)、预订路径强一致、浏览的极高可用和吞吐,和吸收开售时极端流量尖峰的能力。公平性也要紧(混乱的自由争抢挫败用户并招来机器人),这促成候车室。假设对号入座(reserved seating)(特定座位),比无座(一个简单计数器)更难。

第 2 步 — 容量估算

稳态流量适中,但开售是病态的:一个体育场巡演可能把 5 万座位放售,而2–10 百万人在第一分钟同时刷新。所以读(浏览/座位图)路径必须以突发处理数百万 QPS,而写(预订)路径处理一个相对小但激烈争抢的量——数千用户为每个剩余座位争夺。这种不对称是全部故事:用缓存扩展读,用锁定和一个队列保护小的一致写路径。

第 3 步 — API 设计

core API
GET  /events/{id}                       # 事件详情
GET  /events/{id}/seats                 # 座位图 + 可用性(缓存)
POST /reservations  {event_id, seat_ids}# 放一个 HOLD(返回 hold + 过期)
POST /bookings      {hold_id, payment}  # 确认 + 付款 → booked
DELETE /reservations/{hold_id}          # 提前释放

第 4 步 — 核心挑战:无双重预订

根本危险是竞态:两个用户看到座位 12A 可用,都点"预留",而朴素的读后写("检查它空闲,然后标占用")让两者都成功——超卖。修法是把认领座位做成一个只有一个并发请求能赢的原子、条件操作。这是我们 DDIA 事务笔记覆盖的同一丢失更新 / check-then-act 问题;座位是争抢资源,数据库(或一个锁)必须串行化对它的访问。

第 5 步 — 座位持有(预留状态机)

购买花时间(选座、输入支付),所以你不能在点击时简单标座位已售——但你也不能让它在结账中途对其他人可用。答案是一个临时持有(temporary hold):选座把它移到一个为那个用户保留的短窗口(如 5–10 分钟)的 held 状态。座位是一个小状态机:

状态含义转换
available任何人能认领→ held(预留时)
held为一个用户预留,带过期→ booked(已付)/ → available(超时)
booked已售——终态(退款是单独流程)

第 6 步 — 并发控制:原子认领座位

hold 转换必须原子,使 N 个同时请求只有一个成功。选项:

atomic conditional claim (optimistic)
UPDATE seats
   SET status = 'held', held_by = :user, hold_expires = now()+interval '8 min'
 WHERE seat_id = :seat
   AND status = 'available';          # 这个守卫使它原子

# rows_affected = 1 → 你得到了座位
# rows_affected = 0 → 别人赢了;告诉用户"座位没了"
机制怎么做取舍
条件 UPDATE(乐观)仅当 status='available' 时更新简单,无持有的锁;输家就重试另一座位
SELECT ... FOR UPDATE(悲观)锁住行,然后更新清晰,但高争抢下持 DB 锁
Redis 分布式锁每座位 key 带 TTL 加锁把热争抢从 DB 卸下;需小心正确性

乐观条件更新通常是最干净的答案:它无需长持锁,且争抢下除一个更新者外全都简单地得到 rows_affected = 0 并被告知座位被占。WHERE status='available' 守卫是使整件事安全的东西。

第 7 步 — 持有过期

用户放弃结账时持有必须被释放,否则座位泄漏、事件看似假性售罄。组合机制(与过期链接或粘贴同一模式):惰性——当任何人读或试图认领一个 hold_expires 已过的座位时,把它当再次可用;和定时——一个后台清扫器周期性把过期 held 座位翻回 available。一些设计用每持有一个带 TTL 的 Redis key,使过期自动。持有窗口是个调优旋钮:长到能从容付款,短到使废弃座位在热门开售期间快速回流。

第 8 步 — 数据模型

schema
events (event_id, name, venue_id, starts_at, onsale_at)
seats  (seat_id, event_id, section, row, number,
        status,          # available | held | booked
        held_by, hold_expires)        # 为清扫器建索引
bookings (booking_id, user_id, event_id, seat_ids[], payment_id, ts)

一个 ACID 关系数据库是库存的天然契合,因为原子条件认领依赖事务保证。按 event_id 分片使每个事件的座位住一起——方便,但意味单个热门事件是一个分片的问题(第 11 步)。

第 9 步 — 驯服尖峰:虚拟候车室

没有库存数据库能在同一秒承受 5 百万用户的直接打击。标准解法——也是 Ticketmaster 实际做的——是一个虚拟候车室。开售开始时,进来的用户被置于一个队列(常是带位置 token 的 Redis 支撑列表)并显示一个"你在排队"页。系统随后以受控批次把用户放入真正的预订流程,批次大小匹配后端能安全处理的量。这把一个无界尖峰转成一个稳定、有界的流——预订路径从不见到超过它能串行化的并发,用户得到公平、有序的体验而非崩溃的自由争抢。

为什么这是关键动作

候车室把到达率处理率解耦。没有它,你得让预订后端吸收对几千行的数百万并发锁尝试——锁争抢熔毁的配方。有它,后端总在它的安全包络内运作,背压被推到一个便宜的队列。它也钝化机器人并给你一个公平/反滥用控制点。

第 10 步 — 一致性:拆分路径

系统的不同部分有相反需求,所以区别对待它们:

这是一个直接的 CAP 式拆分(见一致性与共识):在无害处可用且近似,在钱和超卖攸关处一致且权威。

第 11 步 — 支付集成

确认一个预订跨库存存储和一个外部支付提供商,所以它被编排为一个 saga(见我们的支付系统设计):持有座位 → 收款(幂等地)→ 成功时把座位转换到 booked 并记录预订;支付失败或超时时,跑补偿动作并释放持有使座位返回库存。持有的过期是安全网——即便一个步骤丢失,座位不会永远卡在 held

第 12 步 — 扩展与热门事件热点

浏览路径用缓存、副本和 CDN 横向扩展。预订路径按事件分片,跨许多事件工作良好——但单个重磅开售把所有争抢集中在一个事件的座位(热点)。缓解:候车室给进入那个分片的并发设上限;座位能在事件内分区(按区)以分散锁争抢;一个内存层(Redis)能罩住最热的座位状态检查。关键地,争抢被座位数限制——只有这么多座位可争——所以一旦候车室节流到达,后端的活就有限且可处理。

第 13 步 — 关键取舍

总结

Ticketmaster 是穿一件外套的两个问题:把每个座位恰好卖一次(由座位状态机加原子条件认领解决),和挺过数百万同时买家(由把到达节流进一致预订路径的虚拟候车室解决)。按一致性需求拆分架构——缓存浏览路径、保护预订路径——并把 hold→pay→confirm 编排为一个 saga,以持有过期作安全网。

🎯 面试速答

怎么防止双重预订?用原子条件更新(SET held WHERE status='available')或行锁认领座位,使许多并发请求只有一个赢。
为什么临时持有?结账花时间;持有为座位预留几分钟(状态 available→held→booked)且若未付过期回 available,使座位既不双卖也不泄漏。
怎么处理数百万的开售尖峰?虚拟候车室排队用户并以批次放入,把到达率与预订后端的安全处理率解耦。
一致性边界在哪?浏览/座位图缓存且 AP;预订路径强一致(CP)——原子认领是单一真相来源。
支付怎么嵌入?hold → pay(幂等)→ confirm,作为一个 saga;失败或超时时持有被释放,以过期作兜底。

← 上一篇
设计支付系统