第 14 章 / 共 57 章 · 约 9 分钟
案例 12:票务抢购系统(类 Ticketmaster)
先花 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. 数据模型
| 表 | 关键字段 |
|---|---|
| events | event_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 |
| holds | hold_id, user_id, event_id, expires_at, status |
| bookings | booking_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+) | 从公平和故障出发:防机器人、热点分片、支付回调迟到时的自动补偿、读写两侧不同的一致性要求,并说明哪些问题交给排队解决而不是硬扛 |