题干与适用场景
设计一个面向演唱会、体育赛事或热门展览的票务预订系统。某场活动有 50,000 个指定座位,200 万用户在开售后 5 分钟内到达,入口峰值为每秒 50,000 次请求。系统最多放行 10,000 名用户同时浏览座位图,峰值处理每秒 2,000 次占座和 1,000 次支付授权。座位占用 5 分钟;被占用但未完成购买的座位需要再次可售。
本题假设支付服务支持“先授权、后请款”,座位图 p99 低于 500 毫秒,占座 p99 低于 300 毫秒,订单状态查询 p99 低于 200 毫秒。入口规模、延迟、占用时长和支付吞吐均为面试假设,不是任何票务平台的公开基准。核心正确性要求是:同一活动的同一座位最多关联一个有效售出订单。可用性下降时可以暂停放行或拒绝占座,不能猜测库存并继续售卖。
范围包含活动浏览、虚拟等候室、座位图、成组占座、结账、出票前确认、过期释放和故障恢复。活动创建、动态定价、二级转售、退款会计和完整反机器人系统不在基础范围,但答案应说明它们与库存和支付边界的接口。当前公开的 2026 系统设计材料继续把票务预订列为并发与流量尖峰综合题;Ticketmaster 的公开资料也展示了先进入虚拟等候室、再按后端承载能力放行选座与结账的实际流程。
面试官考察点
第一个信号是能否把“排队公平”与“库存正确”分开。虚拟等候室保护源站并决定谁能进入售卖流程,却不能阻止两个已放行用户同时抢同一座位。最终排他性必须由权威库存事务保证。
第二个信号是能否区分数据库行锁与业务占用。PostgreSQL 的行锁只应存在于短事务内,并在事务结束时释放;不能在用户填写地址和付款的 5 分钟里保持连接与事务。长期业务语义应落为带 holdid 和 expiresat 的持久状态。
第三个信号是能否处理时间和重试。延迟的过期任务不能释放已被新用户重新占用或已经售出的座位;客户端超时也不能自动创建第二次占用或第二次支付。每次状态转换都要校验当前状态、所有者、版本和截止时间,并通过幂等键返回原结果。
第四个信号是支付边界是否准确。支付授权成功但本地确认失败、售出事务提交但响应丢失、请款成功但回调延迟,都会产生未知窗口。强答案会保留订单与支付操作记录,通过查询、回调和对账收敛,不把“没收到响应”当作失败。
最后看容量与验证是否服务于瓶颈。大量浏览可由 CDN、缓存和只读副本吸收;单场热门活动的座位写入仍应留在一个可事务化的权威分片。把同一活动的座位过早拆到多个写分片,会把一次三座位占用变成分布式事务。
回答前需要澄清的问题
- 是指定座位还是自由席? 指定座位需要逐座位排他状态;自由席更适合按票档维护可用数量,并用条件扣减防止负库存。
- 占座必须全部成功吗? 本题要求同一请求的 1 至 6 个座位全成或全败。若允许部分成功,用户体验、定价和释放逻辑都要改变。
- 排队采用什么公平规则? FIFO 奖励较早到达者;随机出队可降低毫秒级网络差异的影响。规则必须提前公开并保持稳定,不能在活动中途改变后再声称顺序公平。
- 支付能否分离授权与请款? 能分离时,先授权、再确认座位、最后请款;只能立即扣款时,需要为“扣款成功但座位确认失败”设计自动退款或撤销。
- 一个账户可以买多少张? 限购规则必须在占座和订单确认两个边界校验;只在页面上限制可被绕过。
- 允许多地域写同一活动吗? 基础方案让每场活动固定一个主写地域,其他地域提供浏览和排队。要求多地域同时写会引入跨地域共识或库存预分配,并改变延迟与故障语义。
- 座位图要多实时? 允许数秒陈旧时可用快照加增量推送;任何“可用”展示都只是提示,最终以占座事务为准。
- 占座到期时支付仍在认证怎么办? 需要定义一次有上限的
PAYMENT_PENDING宽限期;不能无限续占,也不能在结果未知时立即把座位卖给下一人。
30 秒回答框架
“我会先把 200 万入口流量停在按活动隔离的虚拟等候室,只按数据库、支付和出票链路的健康容量发放短期签名准入令牌。浏览与座位图走 CDN、缓存和版本化增量;占座写入活动所在的单一权威数据库分片。一个短事务按座位号排序锁定所选行,只在全部座位可用或已过期时写入同一 holdid 和数据库时间生成的 5 分钟截止时间。用户结账期间不持有数据库锁。过期队列只负责加速清理,真正释放时仍条件校验 holdid、状态和截止时间。支付用稳定操作 ID 先授权;授权成功后,事务再次验证占用并把座位、订单和 outbox 一起确认为售出,再异步请款。任何超时都进入未知状态并通过回调、主动查询和对账收敛。我会用同座位并发争抢、过期与支付竞态、丢响应、重复消息和分片故障证明不超卖。”
分步骤深入解答
第一步:把容量换算成入口控制目标
200 万用户在 300 秒内到达,平均约为每秒 6,667 人;每秒 50,000 次入口请求说明开售瞬间和客户端刷新会远高于平均值。后端只计划处理每秒 2,000 次占座和 1,000 次支付授权,因此不能让入口重试直接穿透到库存服务。
每次占座最多包含 6 个座位,所以每秒 2,000 次请求在发生冲突和回滚前,最多触发约每秒 12,000 次座位行判断。容量测试必须使用热门区域的偏斜分布,不能把这 12,000 次均匀摊到 50,000 个座位后宣称没有竞争。
虚拟等候室按 eventid 隔离,开售前可进入预排队。它记录排队批次、进入时间、账户与风控信号,并在允许进入时签发短期、签名、绑定活动与账户的 admissiontoken。售卖入口验证令牌、过期时间和一次性会话;无效请求在边缘被拒绝。放行速率依据活动分片的锁等待、占座 p99、支付错误率、当前活跃购物者和完成率动态调整。数据库变慢时先降低放行,不用更多副本掩盖写入拥塞。
Cloudflare 的公开等候室文档说明 FIFO 可按访客首次进入时的时间戳排序,随机模式则从等待者中抽取。二者是产品政策,不是数据库一致性机制。所谓 FIFO 也要写清粒度、断线重连、多个设备、时钟来源和机器人处理;不能承诺跨全球网络的绝对逐请求顺序。
第二步:分离浏览、座位视图与权威库存
活动详情、场馆静态图和价格说明放在 CDN 或缓存。座位图由版本化快照与状态增量组成:客户端先取 snapshotversion,再接收 seatid、新状态和更高版本的变更。断线或版本缺口时重新取快照。10,000 名活跃用户若每 5 秒轮询一次,会产生约每秒 2,000 次读取;增量推送可减少重复全量读取,但不能成为库存真源。
座位图允许短暂陈旧。用户看到 AVAILABLE 后,占座仍可能返回冲突。缓存不能确认售出,也不能在数据库不可用时凭旧值接受占座。这样把高可用的浏览路径与强一致的库存写路径分开。
每场活动固定到一个主写分片,分片内使用关系数据库事务。不同活动可按 event_id 水平拆分,热门活动还可独占分片或资源池。单场的 50,000 行不是容量难点;大量请求集中争抢少数座位造成的锁等待、连接耗尽与重试风暴才是瓶颈。
第三步:定义 API、数据模型和不变量
核心 API 可以保持很小:
POST /events/{eventId}/holds
{ seatIds, idempotencyKey, admissionToken }
-> { holdId, status, expiresAt, seats }
POST /holds/{holdId}/checkout
{ paymentMethodToken, idempotencyKey }
-> { orderId, paymentStatus, nextAction }
GET /orders/{orderId}
-> { orderStatus, paymentStatus, seats }最小数据模型:
SeatInventory(event_id, seat_id, price_version, status,
hold_id, hold_expires_at, order_id, version)
Hold(hold_id, event_id, account_id, status, expires_at,
idempotency_key, request_digest, created_at)
Order(order_id, hold_id, account_id, amount_minor, currency,
status, payment_operation_id, created_at)
PaymentOperation(operation_id, order_id, provider_reference,
kind, status, idempotency_key, updated_at)
Outbox(event_id, aggregate_id, event_type, payload, published_at)关键不变量包括:
一个 (event_id, seat_id) 最多关联一个有效 SOLD 订单
同一 hold 的所有座位必须属于同一 event、account 与 expires_at
只有当前 hold_id 可以把 HELD 转成 SOLD 或释放
确认订单时价格版本、票数、限购与总金额必须再次校验
同一幂等键只能描述一份不可变请求;参数不同则拒绝复用金额使用最小货币单位整数。客户端提交的总价不是权威数据;服务端从锁定的价格版本重算。admission_token 允许进入售卖流程,不代表拥有任何座位。
第四步:用短事务完成成组占座
一次请求最多选择 6 个座位。事务先按 seatid 固定排序,再用 SELECT ... FOR UPDATE 锁定对应行,降低不同请求反向加锁造成的死锁。若每行状态为 AVAILABLE,或为已过期的 HELD,就把全部行改成同一 holdid 和 expiresat = databasenow + 5 minutes,同时插入 Hold 与 outbox;任何一行已售出或仍被他人有效占用,整个事务回滚。
也可以使用带状态与截止时间谓词的条件 UPDATE,再检查更新行数是否等于请求座位数。无论采用哪种写法,判断和写入必须在同一权威事务内。不能先在 Redis 抢锁、再异步写数据库,因为锁成功与数据库写失败之间会出现双真源;也不能先读“可用”再无条件更新。
PostgreSQL 文档指出行锁只阻塞同一行的写入者或加锁者,并在事务结束时释放。用户的 5 分钟结账时间因此是数据状态,不是一个 5 分钟数据库事务。事务提交后立刻释放连接,后续每次转换再开启短事务。
幂等记录与占座结果一起提交。若服务已经占座却在回包前崩溃,客户端用同一个 idempotencyKey 重试时取得原 holdId;换一个新键只会参与新的竞争。服务还要保存请求摘要,防止同一键被换一组座位复用。
第五步:让过期语义不依赖准时任务
holdexpiresat 使用数据库时间生成。读到过期占用时可以展示为可用,但真正重占仍要在事务里核对截止时间。创建占用时发送延迟消息;定时扫描作为补偿。两者只负责尽快把状态显式改回 AVAILABLE,不是正确性的唯一来源。
释放操作必须类似:
UPDATE seat_inventory
SET status = 'AVAILABLE', hold_id = NULL, hold_expires_at = NULL
WHERE event_id = :eventId
AND hold_id = :holdId
AND status = 'HELD'
AND hold_expires_at <= database_now;延迟消息可能重复、迟到或乱序。若旧占用已经过期,座位被新 holdid 占用,旧消息的条件不匹配;若座位已变成 SOLD,状态也不匹配。只按 seatid 执行“释放”会删除新用户的合法占用。
支付附加认证可能接近占用截止时间。本题允许在开始支付授权前,把有效占用的 Hold.status 原子转换为 PAYMENT_PENDING,最多增加一次有上限的宽限期。宽限数量计入活跃库存并受单账户限制。到期仍未知时进入人工或自动补偿流程,不能通过无限续期囤票。
第六步:把支付未知结果纳入状态机
结账先用稳定的 paymentoperationid 发起授权,向支付提供方复用同一个幂等键。Stripe 的公开 API 文档说明,同一幂等键的重试会返回首次请求保存的结果;PaymentIntent 一类对象也用生命周期状态表示可能需要额外认证或异步完成的支付。
推荐顺序如下:
- 在短事务中验证占用、限购、价格与截止时间,创建
Order和授权操作;需要宽限时,同时把Hold.status转为PAYMENT_PENDING。 - 在事务外调用支付授权;超时记为
UNKNOWN,不创建第二个操作。 - 授权确认成功后,事务再次锁定座位并校验
hold_id,把全部座位改为SOLD,订单改为CONFIRMED,并写 outbox。 - 提交后异步请款;重复请款复用同一操作 ID。出票服务只消费已提交的确认事件。
- 若授权成功时占用已无法确认,撤销授权。若售出事务已提交但请款结果未知,保留座位和订单,通过支付回调、主动查询与对账收敛,不能释放后再次售卖。请款明确失败且不可重试时,在出票前以受审计的补偿事务取消订单并释放其座位。
这个顺序允许短暂出现“座位已售出、请款待确认”,但不会出现两个买家都拿到同一座位。若业务只能立即扣款,扣款成功而售出事务失败时必须创建可审计的撤销或退款补偿;这种支付能力会扩大用户与运营风险,应在回答中明确。
订单与座位状态都应单调推进。浏览器跳到“成功页”不是出票依据;经过验证的提供方响应、回调或主动查询才可驱动支付状态。回调按提供方事件 ID 去重,并只允许合法状态转换。
第七步:设计故障、扩展与降级
- 活动分片不可用: 停止该活动放行与新占座,保留等候室;浏览可继续显示“暂不可预订”,不能切到陈旧副本继续写。
- 缓存或推送失败: 客户端回退到较低频率的版本化快照;最终占座仍由数据库判断。
- 过期队列丢消息: 条件重占与补偿扫描仍能回收座位,监控过期积压和最老过期时间。
- 支付提供方变慢: 降低放行与结账并发,保留未知操作;不能自动切换提供方重扣一次。
- 服务提交后响应丢失: 幂等键返回原占用、订单或支付操作。
- 单场活动过热: 活动独占数据库资源、按活动限流并缩短非必要读取;不要把同一次多座位事务拆到多个分片。
- 地域故障: 每场活动有一个写入归属地和经过验证的故障切换。新主接管前必须确认旧主不能继续写,避免双主超卖。
虚拟等候室还要防止令牌复制、重放和绕过:令牌短期有效、签名、绑定活动与账户,服务端限制并发会话和购票数量;高风险流量进入额外验证。它能降低机器人优势,却不能单独证明“真人”或绝对公平。
第八步:用不变量和故障注入验收
功能测试之外,建立可机器检查的性质:
count(valid SOLD orders for one seat) <= 1
every CONFIRMED order owns exactly its recorded seats
every SOLD seat points to one CONFIRMED or payment-reconciling order
an expiry action changes only its own hold_id
replaying one idempotent request does not create new business state让数千个并发客户端争抢同一个座位,预期只有一个有效占用;再让它们以不同顺序争抢相同的三座位组合,验证全成全败、固定加锁顺序和死锁重试。把系统时钟推进到截止点附近,交错执行新占用、过期消息、支付授权与确认,验证旧清理任务不能释放新状态。
在以下边界逐点杀进程或丢响应:占座事务提交后、支付授权返回前后、座位确认为售出后、请款提交后、outbox 发布前后。重复投递回调和延迟消息,断开缓存、支付方和数据库主节点。最后用事件级销售量、锁等待、条件冲突率、占用到期率、未知支付年龄、队列等待与放行速率、出票对账差异判断是否通过,而不是只看接口返回 200。
高质量示范回答
“我先把需求拆成流量保护和库存正确性。200 万用户 5 分钟到达,平均约每秒 6,667 人,入口峰值还有每秒 50,000 次请求,但库存只计划承受每秒 2,000 次占座,所以我会按活动设置虚拟等候室。它在边缘吸收刷新,按活动分片和支付链路的健康度发放绑定账户的短期准入令牌。排队规则是明确的产品政策;它不负责座位排他。
活动页和静态场馆图走 CDN。座位图使用版本化快照加增量,允许数秒陈旧,最终结果以占座为准。每场活动落到一个关系数据库主写分片,不把 50,000 个座位跨分片。占座 API 接受 1 到 6 个排序后的座位号与幂等键。短事务锁定这些行,只有全部可用或已过期时,才一次写入同一 hold_id 和数据库时间生成的 5 分钟截止时间;否则全部失败。事务提交就释放数据库锁,用户结账期间只保留持久占用状态。
延迟队列和扫描器会清理过期占用,但每次释放都要匹配 hold_id、HELD 和截止时间。这样迟到的旧消息不能释放新占用或已售座位。相同的幂等键和请求摘要与占用一起保存,所以提交后丢响应只会返回原结果。
支付方面,本题假设支持授权与请款分离。我先创建稳定支付操作并授权;超时记为未知,用同一操作 ID 查询或重试。授权成功后,再在事务中核对占用、价格、限购和截止时间,把座位转为 SOLD、订单转为 CONFIRMED,同时写 outbox。提交后请款并出票。若授权成功但座位确认失败就撤销授权;若座位已确认而请款结果未知,就保留订单并通过回调、查询和对账收敛,绝不释放后再卖一次。
扩展按活动做:普通活动共享分片,热门活动独占资源;同一活动保留一个写归属地。数据库、支付或出票链路变慢时降低等候室放行,必要时暂停新占座。验收时让数千客户端抢同一座位和重叠座位组,再注入过期任务迟到、提交后崩溃、支付响应丢失、重复回调、缓存和主节点故障。只要能持续证明每座位至多一个有效售出订单、重放不新增状态、旧过期任务不改新占用,设计才算通过。”
常见错误
- 用 Redis 锁住座位,再异步写数据库 → 锁与库存写入可能一成一败,形成两个真源 → 让同一权威数据库事务完成条件判断、占用状态和幂等结果。
- 结账 5 分钟一直持有行锁 → 长事务占用连接、阻塞写入,客户端离开还会留下恢复问题 → 数据库锁只包住状态转换,结账窗口用持久
hold表示。 - 认为虚拟等候室可以防超卖 → 它只控制进入人数,已放行用户仍会争抢同一行 → 库存事务单独保证排他。
- 过期任务按座位号直接释放 → 迟到消息可能删除新占用或已售状态 → 同时匹配
hold_id、状态与截止时间。 - 座位图显示可用就直接收款 → 读模型允许陈旧,付款时座位可能已被占用 → 先取得权威占用,再进入支付。
- 支付超时立即释放并换键重试 → 首次操作可能已经成功,座位会二卖或用户被重复扣款 → 保留未知状态,复用操作 ID,并通过回调、查询和对账收敛。
- 把热门活动的每个座位随机分片 → 一次成组占座跨分片,原子性成本陡增 → 先按活动归属写分片,只有测得单活动容量不足才引入更复杂的库存分区。
- 宣称严格全球 FIFO → 网络延迟、重连、设备切换和机器人都会改变可观察顺序 → 定义队列粒度、身份、重连与风控政策,并公开可验证的公平规则。
- 只压测平均吞吐 → 开售峰值集中在少数座位,平均分布掩盖锁等待与重试风暴 → 压测热点、重叠座位组、瞬时峰值和依赖降速。
追问及应对
追问一:如果改成自由席,只需要保证一个票档不超卖呢?
把逐座位状态换成 InventoryBucket(eventid, tierid, available, held, sold, version)。占用事务用条件更新保证 available >= quantity,一次扣减数量并创建 hold;过期和确认仍按 hold_id 幂等转移计数。高冲突票档可拆成多个库存桶以分散写热点,但必须由一个配额控制面分配桶容量,且所有桶之和不能超过真实库存。用户不需要原子挑选具体座位后,数据模型更简单;支付未知与过期竞态仍然存在。
追问二:支付授权平均需要 90 秒,额外认证可能超过 5 分钟,如何避免占用被长期拖住?
在开始支付前检查剩余时间,不足时不给进入认证。进入后把占用转换为有一次上限的 PAYMENT_PENDING 宽限状态,并分别限制单账户和全场处于该状态的数量。宽限到期仍未知时停止新增动作,主动查询支付方;确认未授权才释放,确认已授权则完成或撤销。用成功率与库存周转数据决定 5 分钟和宽限,而不是允许客户端反复续期。
追问三:主写地域在开售中失联,是否立即切换到另一地域?
不能只凭超时启动第二个写主。先停止该活动放行和新占座,通过共识租约、数据库故障切换或隔离旧主证明只有一个写入者,再让新主从已确认日志位置接管。故障窗口内允许排队和只读降级,不接受无法证明唯一性的占座。恢复后对照订单、座位和支付操作,才重新提高放行速率。
追问四:怎样提高公平性并限制机器人,而不把风控当作库存正确性的依赖?
售前使用已验证账户、限购、速率限制、设备与行为信号;高风险会话增加挑战。排队令牌签名、短期有效并绑定活动与账户,重复会话按政策合并或拒绝。开售规则可选择按粗粒度进入时间的 FIFO,或对预排队用户随机分配位置。无论风控是否漏判,数据库仍执行同样的占座、限购与订单不变量;风控失败影响分配公平,不应导致超卖。
追问五:为什么不用已有的分布式锁服务保护每个座位?
如果权威库存已经在支持条件更新和短事务的关系数据库中,再增加锁服务会引入锁状态与库存状态的双写、租约过期和 fencing 边界。数据库行锁或条件更新能把排他判断与持久写入放在一个事务中,更简单。只有库存跨越数据库无法共同事务化,或临界区保护的是外部资源时,才评估分布式锁;即使使用锁,持久层仍需要版本或 fencing token 拒绝迟到持有者。