票务预订平台看起来像电商,直到你注意到残酷的转折:一场热门演唱会开售时,数百万人在同一瞬间试图买几千个特定座位。那个组合——极小、争抢的库存加巨大并发尖峰——使两个需求相撞。你必须绝不把同一座位卖两次(库存强一致),且必须挺过 thundering herd 而不倒下。设计围绕带恰当锁的座位持有(hold),和一个虚拟候车室(virtual waiting room)来驯服尖峰。它与我们的酒店预订设计共享库存锁定核心。
- 绝不超卖——预订库存需要强一致;座位认领必须是原子操作,而非读后写竞态。
- 临时持有——选座把它置于
held状态几分钟、用户付款期间;若不付,持有过期、座位返回。 - 原子锁定座位——条件更新(
available → held仅当仍可用)、SELECT ... FOR UPDATE或 Redis 锁;谁赢谁得座位,其他人被告知它没了。 - 虚拟候车室——对热门开售,排队用户并以受控批次放入,使预订后端看到稳定负载、而非海啸。
- 按一致性需求拆分路径——浏览事件/座位图读多且可缓存(AP);预订路径强一致(CP)。
- 持有 + 支付 = 一个 saga——持有座位、收款、确认;支付失败或超时则释放持有。
难的部分是在大量争抢下把每个座位恰好卖一次。把座位建模为状态机(available → held → booked)并用原子条件更新或行锁认领它,使并发买家不能都赢。结账期间持有座位几分钟,超时释放。用一个以批次放入用户的虚拟候车室吸收开售尖峰。缓存读多的浏览路径;让预订路径强一致;并把 hold→pay→confirm 编排为一个 saga。
数百万 ─▶ ┌────────────────┐ 批次放入 ┌──────────────┐
开售时 │ 虚拟候车室 │─────────────────▶│ 预订 │
│ (队列) │ │ 服务 │
└────────────────┘ └──────┬───────┘
原子认领 │
浏览(缓存,AP) ───▶ 读副本 / CDN ▼
┌──────────────────┐
│ 库存 DB │ 座位:
│ (强一致, CP) │ available
└──────────────────┘ → held → booked
hold→pay→confirm (saga) ─▶ 支付服务
第 1 步 — 澄清需求
功能:浏览事件;看一个事件带可用性的座位图;预留(持有)一个或多个特定座位;完成购买;释放未付款的持有。非功能:无双重预订 / 无超卖(头条正确性需求)、预订路径强一致、浏览的极高可用和吞吐,和吸收开售时极端流量尖峰的能力。公平性也要紧(混乱的自由争抢挫败用户并招来机器人),这促成候车室。假设对号入座(reserved seating)(特定座位),比无座(一个简单计数器)更难。
第 2 步 — 容量估算
稳态流量适中,但开售是病态的:一个体育场巡演可能把 5 万座位放售,而2–10 百万人在第一分钟同时刷新。所以读(浏览/座位图)路径必须以突发处理数百万 QPS,而写(预订)路径处理一个相对小但激烈争抢的量——数千用户为每个剩余座位争夺。这种不对称是全部故事:用缓存扩展读,用锁定和一个队列保护小的一致写路径。
第 3 步 — 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 个同时请求只有一个成功。选项:
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 步 — 数据模型
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 步 — 一致性:拆分路径
系统的不同部分有相反需求,所以区别对待它们:
- 浏览路径(事件列表、座位图)——读多且容忍轻微陈旧;从缓存、读副本和 CDN 服务(AP)。座位图落后一两秒没问题;预订时的原子认领才是真正的真相来源。
- 预订路径(hold → pay → confirm)——必须强一致(CP):一个座位恰好被认领一次。这条路径针对权威库存存储用原子操作运行,接受更低可用/吞吐以换正确性。
这是一个直接的 CAP 式拆分(见一致性与共识):在无害处可用且近似,在钱和超卖攸关处一致且权威。
第 11 步 — 支付集成
确认一个预订跨库存存储和一个外部支付提供商,所以它被编排为一个 saga(见我们的支付系统设计):持有座位 → 收款(幂等地)→ 成功时把座位转换到 booked 并记录预订;支付失败或超时时,跑补偿动作并释放持有使座位返回库存。持有的过期是安全网——即便一个步骤丢失,座位不会永远卡在 held。
第 12 步 — 扩展与热门事件热点
浏览路径用缓存、副本和 CDN 横向扩展。预订路径按事件分片,跨许多事件工作良好——但单个重磅开售把所有争抢集中在一个事件的座位(热点)。缓解:候车室给进入那个分片的并发设上限;座位能在事件内分区(按区)以分散锁争抢;一个内存层(Redis)能罩住最热的座位状态检查。关键地,争抢被座位数限制——只有这么多座位可争——所以一旦候车室节流到达,后端的活就有限且可处理。
第 13 步 — 关键取舍
- 乐观 vs 悲观锁定。乐观条件更新避免持有的锁、在争抢下更好扩展(输家就重试);悲观
FOR UPDATE更易推理但在热门座位上能硬串行化。 - 持有时长。更长持有对买家更友好但锁住库存、热门售卖期间减慢回流;更短持有让座位流动但冒真实购买超时风险。
- 一致性 vs 可用性。浏览是 AP(缓存,可能陈旧);预订是 CP(权威,无超卖)。混淆它们要么超卖、要么使浏览脆弱。
- 候车室公平性。队列强加顺序并节流负载但加延迟和一整个子系统;这是优雅挺过开售尖峰的代价。
Ticketmaster 是穿一件外套的两个问题:把每个座位恰好卖一次(由座位状态机加原子条件认领解决),和挺过数百万同时买家(由把到达节流进一致预订路径的虚拟候车室解决)。按一致性需求拆分架构——缓存浏览路径、保护预订路径——并把 hold→pay→confirm 编排为一个 saga,以持有过期作安全网。
怎么防止双重预订?用原子条件更新(SET held WHERE status='available')或行锁认领座位,使许多并发请求只有一个赢。
为什么临时持有?结账花时间;持有为座位预留几分钟(状态 available→held→booked)且若未付过期回 available,使座位既不双卖也不泄漏。
怎么处理数百万的开售尖峰?虚拟候车室排队用户并以批次放入,把到达率与预订后端的安全处理率解耦。
一致性边界在哪?浏览/座位图缓存且 AP;预订路径强一致(CP)——原子认领是单一真相来源。
支付怎么嵌入?hold → pay(幂等)→ confirm,作为一个 saga;失败或超时时持有被释放,以过期作兜底。