cswork / 系统设计

第 14 章 / 共 57 章 · 约 9 分钟

案例 12:票务抢购系统(类 Ticketmaster)

本页目录
  1. 1. 澄清需求
  2. 2. 规模估算
  3. 3. API
  4. 4. 数据模型
  5. 5. 高层架构
  6. 6. 深挖
  7. 7. 常见追问
  8. 8. 易错点
  9. 9. 回答分级
  10. 10. 不同级别的期望
  11. 章节测验

先花 10 分钟自己设计:需求、核心实体、API 和高层架构,各写几行。写完或者跳过之后,下面的讲解才会展开。

先写你的思路

只存在这台设备上;登录后会存进你的账号。

演唱会开票那一刻,几百万人抢几万个座位。这道题考两件事:同一个座位绝不能卖两次;洪峰下系统不能被冲垮,还要尽量公平。

1. 澄清需求

功能需求

  • 浏览和搜索活动,查看座位图及可售状态
  • 选座后**锁座(hold)**一段时间(比如 10 分钟),期间完成支付
  • 支付成功后出票;超时或放弃则释放座位
  • 也要支持不对号入座(站票,General Admission):只扣数量

不在范围:二级市场转售、动态定价。

非功能需求

  • 库存强一致:不超卖,不一座多卖
  • 浏览和搜索高可用,可以容忍几秒延迟
  • 扛得住瞬时洪峰:开售时流量是平时的几百倍
  • 公平:先到先得,挡住机器人

2. 规模估算

  • 一场热门演出 5 万个座位,开售前一分钟有 200 万人在线
  • 如果全部直接打到下单接口,瞬时写请求几十万 QPS,而真正能成功的只有 5 万次
  • 座位图的读请求远多于写

结论:难点不在存储容量,而在短时间内对同一小批数据的激烈争抢。

3. API

GET    /v1/events?query=...             搜索活动
GET    /v1/events/{event_id}/seats      座位图 + 状态
POST   /v1/holds                        { event_id, seat_ids[] } → { hold_id, expires_at }
DELETE /v1/holds/{hold_id}              主动释放
POST   /v1/bookings                     { hold_id, payment_token },Header: Idempotency-Key
GET    /v1/bookings/{booking_id}

4. 数据模型

表关键字段
eventsevent_id, venue_id, start_time, on_sale_at
seats主键 (event_id, seat_id), section, row, price, status(AVAILABLE/HELD/SOLD), hold_id, hold_expires_at
holdshold_id, user_id, event_id, expires_at, status
bookingsbooking_id, hold_id, user_id, payment_id, status
ga_inventory主键 (event_id, bucket), remaining

按 event_id 分片:同一场次的所有座位在同一个分片,锁座就是单分片事务。代价是热门场次的全部写流量都压在这一个分片上,所以必须靠等候室按这个分片能承受的速度放行(见 6.5、6.7)。

5. 高层架构

票务抢购的高层架构
滚动到此处后加载图表…
图表可横向滚动,展开后可缩放查看。
查看图表源码
flowchart TD
  accTitle: 票务抢购的高层架构
  users["Users"] --> cdn["CDN(活动页、静态座位布局)"]
  users --> wr["Waiting Room(排队服务 + Redis):发放签名的准入令牌"]
  users --> gw["API Gateway(校验令牌、限流)"]
  gw --> evs["Event / Search Service"] --> es[("Elasticsearch")]
  gw --> book["Booking Service"]
  book --> inv[("Inventory DB(PostgreSQL,按 event 分片)")]
  book --> rc[("Redis(座位状态缓存,供座位图读取)")]
  book --> pay["Payment Service(见案例 10)"]
  sweeper["Hold Sweeper:定时清理过期 hold,刷新缓存"] --> inv
  sweeper --> rc

6. 深挖

6.1 防止一座多卖:三种并发控制

方案 A:悲观锁

BEGIN;
SELECT * FROM seats
WHERE event_id = :e AND seat_id IN (:s1, :s2)
ORDER BY seat_id FOR UPDATE;
-- 确认都可售后再 UPDATE
COMMIT;

按 seat_id 排序加锁,避免两个事务交叉加锁造成死锁。缺点是抢购时大量事务排队等锁。

方案 B:条件更新(推荐的默认答案)

UPDATE seats
SET status = 'HELD', hold_id = :h,
    hold_expires_at = now() + interval '10 minutes'
WHERE event_id = :e AND seat_id IN (:s1, :s2)
  AND (status = 'AVAILABLE'
       OR (status = 'HELD' AND hold_expires_at < now()));

在事务里检查影响行数是否等于请求的座位数,不相等就回滚,告诉用户“部分座位已被抢”。单行更新本身是原子的,不需要显式锁,冲突请求会快速失败。

方案 C:Redis 锁 + 数据库兜底

  • SET seat:{e}:{s} {hold_id} NX PX 600000 抢锁,TTL 天然实现 10 分钟过期
  • 速度快,能把大部分冲突挡在数据库之前
  • 但 Redis 主从切换可能丢锁,所以最终写库时仍然要用条件更新,数据库才是唯一可信的数据源

面试时先给方案 B,再把 C 作为性能优化,比只说一句“加分布式锁”可靠得多。

6.2 hold 过期:惰性判断 + 后台清理

不要靠定时任务保证正确性。过期时间写在行里,判断可售时把“HELD 且已过期”视为可售(见方案 B 的 WHERE 条件)。这样即使清理任务延迟甚至挂掉,座位也不会被永久锁住。

后台 Sweeper 只负责:把过期 hold 改回 AVAILABLE、刷新座位缓存、通知候补用户。

6.3 锁座和支付的衔接

hold(10 分钟)─► 用户提交支付 ─► 支付成功 ─►
  UPDATE seats SET status = 'SOLD'
  WHERE hold_id = :h AND status = 'HELD'
    AND hold_expires_at > now()

影响行数必须等于这个 hold 的座位数。出票要用条件更新,而不是“先查 hold 还在不在、再更新”:hold 是惰性过期的,过期后座位可能已被别人重新锁走(hold_id 已经变了),也可能还没人动,两种情况都不能出票。

边界情况:

  • 支付过程中 hold 到期:进入支付时把 hold 延长几分钟,确认时再校验
  • 支付成功,但出票的条件更新影响 0 行(支付回调晚于 hold 过期,比如 PSP 处理慢):自动退款并告警。这条路径必须写在代码里,不能靠人工处理
  • 用户重复点“支付”:Idempotency-Key 保证只扣一次款、只出一张票

6.4 不对号入座:热点计数器

UPDATE ga_inventory SET remaining = remaining - :n
WHERE event_id = :e AND bucket = :b AND remaining >= :n;

如果只有一行库存,所有请求都争同一行。缓解办法:

  • 把库存拆成 N 个桶,请求随机选一个桶扣减;影响行数为 0 说明这个桶不够,换其他桶重试。判断“售罄”要确认所有桶都不够扣,不能因为随机选中的桶空了就告诉用户卖完了。临近售罄时各桶余量越来越不均,可以把余量合并回少数几个桶
  • 或者在 Redis 里用 Lua 脚本原子地“检查并扣减”,成功后异步落库,再用对账修正

6.5 洪峰:虚拟等候室

让 200 万人直接打锁座接口,数据库必然被打穿。做法:

  • 开售前用户进入等候室,拿到排队位置(可以用 Redis 有序集合);开售前就到的用户随机打乱顺序,降低刷新抢跑的优势
  • 系统按下游承受能力(比如每秒放行 2,000 人)发放签名的准入令牌,令牌有过期时间
  • 网关只放行持有有效令牌的请求访问锁座接口
  • 前端通过轮询或 SSE 显示排队进度

这样把不可控的洪峰变成可控的匀速流量。再配合验证码、设备指纹和每账号限购来对付机器人。

6.6 座位图的读流量

  • 座位布局(不变的部分)放 CDN
  • 可售状态走单独接口,从 Redis 读(例如每场一个 bitmap,一位代表一个座位),允许几秒延迟
  • 用户看到“可售”但锁座失败是正常现象,前端友好提示并刷新

座位图只是近似状态,最终以锁座时数据库的条件更新为准。这是典型的“读最终一致、写强一致”。

6.7 热点分片

按 event_id 分片意味着一场热门演出全压在一个分片上。主要靠等候室限流;必要时把超大场次按区域(section)拆到多个分片,并限制一次锁座只能在同一区域内,依然是单分片事务。

7. 常见追问

  • 为什么不全部用 Redis 管库存? 可以作为前置过滤,但持久性和故障切换语义不够,订单和库存的终态要落在支持事务的数据库
  • 两人同时选同一个座位,谁赢? 条件更新先成功的那个赢;另一个影响行数为 0,立即失败
  • hold 设多长? 太长会被犹豫或恶意用户占座,太短来不及付款。常见 5~10 分钟,并限制每人可锁座位数
  • 怎么保证公平? 等候室 + 令牌 + 限购 + 反机器人。绝对公平做不到,目标是可解释的相对公平

8. 易错点

  • 先 SELECT 检查、再无条件 UPDATE,中间没有加锁(检查和写入之间有竞态)
  • 只靠 Redis 锁,数据库层不兜底
  • 依赖定时任务保证 hold 过期的正确性
  • 不考虑洪峰,所有请求直达数据库

9. 回答分级

防止一座多卖

  • Bad:先 SELECT 查状态,再无条件 UPDATE;或者只靠 Redis 锁,数据库不再校验。
  • Good:用条件更新锁座,受影响行数必须等于请求的座位数,否则回滚;hold 的过期时间存在行上,过期即视为可售。
  • Great:对比悲观锁、条件更新、Redis 预过滤三种方案,说明 Redis 故障切换可能丢锁,所以数据库才是真相;不对号入座的库存拆成多个桶避免热点行,所有桶都不够时才报售罄。

从锁座到支付

  • Bad:正确性依赖定时任务清理过期的 hold;支付成功就直接出票。
  • Good:出票用条件更新,确认 hold 仍然有效、没有过期;下单接口带幂等键,重复点击只扣一次款、只出一张票。
  • Great:处理支付回调晚于 hold 过期:条件更新影响 0 行时自动退款并告警;开始支付时延长 hold,确认时再检查;把 hold 时长和每人限购放在一起权衡占座和支付失败。

洪峰与公平

  • Bad:开售瞬间的流量直接打到数据库,没有排队,也不防机器人。
  • Good:开售前进入虚拟等候室,按下游能承受的速度发放带签名、会过期的准入令牌,网关只放行持有令牌的请求。
  • Great:开售前到达的用户随机打乱顺序,削弱刷新抢跑;用验证码、设备指纹和每账号限额防机器人;座位图走 CDN 和 Redis,允许几秒延迟;超大场馆按分区分片;把公平的目标定为“可解释”而不是“完美”。

10. 不同级别的期望

级别期望
Mid(L4)在提示下用条件更新或加锁防止超卖,给出 hold、支付、出票的流程
Senior(L5)讲清 hold 的惰性过期、锁座和支付的衔接与幂等,提出虚拟等候室并估算它的放行速率
Staff(L6+)从公平和故障出发:防机器人、热点分片、支付回调迟到时的自动补偿、读写两侧不同的一致性要求,并说明哪些问题交给排队解决而不是硬扛

章节测验

6 道选择题
  1. 下面的流程有什么问题:先 SELECT 确认座位是 AVAILABLE,再执行 UPDATE seats SET status='HELD' WHERE seat_id = ?
  2. 使用悲观锁时,为什么要按 seat_id 排序后再 SELECT ... FOR UPDATE?
  3. 关于 hold 过期,最稳妥的设计是?
  4. 只用 Redis SET NX 锁座、不在数据库层做条件更新,主要风险是什么?
  5. 站票库存只有一行,所有请求争抢扣减,常见的优化是?
  6. 虚拟等候室主要解决什么问题?

章节讨论

正在加载讨论…