系统设计:在线订票系统
从火车票到演唱会门票,订票系统的核心是库存扣减的精确性与高并发下的稳定性。
订票系统与电商秒杀有相似之处(库存稀缺、高并发),但有其独特挑战:座位是一个有状态的二维资源,不像商品只是数量扣减。
场景分析(Scenario)
需求
- 功能需求:浏览场次 → 选座 → 锁定座位 → 支付 → 出票
- 用户规模:某热门演唱会 10 万张票,同时在线抢票用户 100 万+
- QPS:开抢瞬间峰值 10 万 QPS,平时 1000 QPS
- 一致性要求:绝对不能超卖(库存扣减必须精确)
- 响应延迟:选座页 < 500ms,锁座 < 200ms
核心挑战
- 超卖问题:10 万人抢 1 万张票,如何精确扣减库存?
- 座位锁:用户选好座位后,需要锁定一段时间等待支付
- 订单超时:用户锁座后 15 分钟不支付,座位要释放回库存
- 热点场次:顶流演唱会,库存集中在单一场次
- 黄牛打击:防止脚本批量抢票、刷票
服务架构(Service)
User ──▶ CDN ──▶ Gateway ──▶ [限流/降级]
│
┌──────────┼──────────┬──────────┐
▼ ▼ ▼ ▼
Session Ticket Order Payment
Service Service Service Service
│ │ │ │
▼ ▼ ▼ ▼
Redis Redis MySQL MySQL
(Seat Map) (Inventory) (Orders) (Payments)
核心服务拆分
| 服务 | 职责 |
|---|---|
| Session Service | 用户会话管理、排队令牌发放 |
| Ticket Service | 场次查询、座位图展示、库存查询 |
| Lock Service | 座位锁定、锁续期、锁释放 |
| Order Service | 订单创建、状态管理、超时处理 |
| Payment Service | 支付对接、支付状态回调 |
| Notification Service | 出票通知、短信/邮件/推送 |
存储设计(Storage)
数据模型
-- 场次信息
CREATE TABLE events (
event_id BIGINT PRIMARY KEY,
name VARCHAR(255),
venue_id BIGINT,
start_time TIMESTAMP,
status ENUM('upcoming', 'selling', 'sold_out', 'ended')
);
-- 座位图(静态数据,可提前加载到缓存)
CREATE TABLE seats (
event_id BIGINT,
seat_id VARCHAR(20), -- 如 "A-12-03"
section VARCHAR(10), -- 区域
row_num INT,
col_num INT,
price DECIMAL(10,2),
status ENUM('available', 'locked', 'sold', 'unavailable'),
PRIMARY KEY (event_id, seat_id)
);
-- 订单
CREATE TABLE orders (
order_id BIGINT PRIMARY KEY,
user_id BIGINT,
event_id BIGINT,
total_amount DECIMAL(12,2),
status ENUM('pending', 'paid', 'expired', 'cancelled', 'refunded'),
lock_token VARCHAR(64),
created_at TIMESTAMP,
expires_at TIMESTAMP
);
-- 订单座位关联
CREATE TABLE order_seats (
order_id BIGINT,
event_id BIGINT,
seat_id VARCHAR(20),
price DECIMAL(10,2),
PRIMARY KEY (event_id, seat_id), -- 唯一约束防超卖
FOREIGN KEY (order_id) REFERENCES orders(order_id)
);
座位状态机
Available ──(锁座)──▶ Locked ──(支付成功)──▶ Sold
│
└──(超时/取消)──▶ Available
核心问题:如何防止超卖?
方案一:数据库乐观锁
-- 扣减库存前先检查版本
UPDATE seats
SET status = 'locked', version = version + 1
WHERE event_id = ? AND seat_id = ?
AND status = 'available' AND version = ?;
-- 检查 affected_rows,0 表示已被抢占
问题:高并发下大量冲突,重试成本高。
方案二:Redis + Lua 原子扣减(推荐)
-- lock_seat.lua
local seat_key = KEYS[1]
local lock_key = KEYS[2]
local user_id = ARGV[1]
local ttl = ARGV[2]
-- 检查座位是否可用
if redis.call('HEXISTS', seat_key, 'status') == 0
or redis.call('HGET', seat_key, 'status') ~= 'available' then
return 0 -- 已被占用
end
-- 原子锁定
redis.call('HSET', seat_key, 'status', 'locked')
redis.call('HSET', seat_key, 'user_id', user_id)
redis.call('HSET', seat_key, 'lock_time', redis.call('TIME')[1])
-- 设置锁过期时间
redis.call('SET', lock_key, user_id, 'EX', ttl)
return 1 -- 成功
优势:Redis 单线程,Lua 脚本原子执行,不会出现竞态条件。
方案三:分段库存(应对超大并发)
将 10 万张票分成 100 个库存桶,每个桶 1000 张:
- 用户请求随机路由到某个桶
- 每个桶独立扣减,降低热点
- 某个桶卖完后,自动分配到其他桶
def get_bucket(event_id, user_id):
"""一致性哈希选择库存桶"""
import hashlib
hash_val = int(hashlib.md5(f"{event_id}:{user_id}".encode()).hexdigest(), 16)
return hash_val % BUCKET_COUNT
座位锁定机制
锁座流程
用户选座 ──▶ Redis 原子锁定 ──▶ 创建预订单 ──▶ 返回 15 分钟倒计时
│
└── 失败 ──▶ 座位已被抢占,提示重新选择
锁续期(Watchdog)
用户支付过程中,锁可能过期:
- 前端每 30 秒发送心跳续期请求
- 后端延长 Redis lock_key 的 TTL
- 如果用户主动离开页面,停止续期,等待 TTL 到期自动释放
超时释放
import schedule
import time
def release_expired_locks():
"""定时任务:扫描到期的锁并释放座位"""
# 1. 从 Redis 获取所有过期的 lock_key
# 2. 对应的 seat_key 状态改回 available
# 3. 删除 lock_key
pass
# 每分钟执行一次
schedule.every(1).minutes.do(release_expired_locks)
更优方案:使用 Redis Keyspace Notification 监听过期事件,立即释放座位。
关键难点:支付与出票闭环
时序图
用户 订单服务 支付服务 出票服务
│ │ │ │
│──支付请求───▶│ │ │
│ │──调用支付───▶│ │
│ │ │──支付处理───▶│
│ │ │◀──回调──────│
│ │◀─支付结果───│ │
│ │ │ │
│◀─出票成功───│ │ │
│ │ │ │
支付状态一致性
采用本地消息表 + 补偿机制:
-- 支付任务表(本地消息表)
CREATE TABLE payment_tasks (
task_id BIGINT PRIMARY KEY,
order_id BIGINT,
status ENUM('pending', 'processing', 'success', 'failed'),
retry_count INT DEFAULT 0,
next_retry_time TIMESTAMP,
UNIQUE KEY (order_id)
);
- 创建订单时,同时写入 payment_tasks
- 定时任务扫描 pending 任务,调用支付网关
- 支付成功:更新订单 → 出票 → 删除任务
- 支付超时:释放锁 → cancel 订单 → 删除任务
防黄牛策略
| 层级 | 措施 | 实现 |
|---|---|---|
| 接入层 | 限流 + 验证码 | Nginx limit_req + 阿里云验证码 |
| 应用层 | 用户行为检测 | 检测点击频率、请求模式(脚本特征) |
| 业务层 | 限购 + 实名 | 每用户每场次最多 4 张,实名认证 |
| 数据层 | 设备指纹 + IP 限制 | 同设备/同 IP 限制购买次数 |
面试答题框架
订票系统面试回答结构
阶段一:需求澄清(2 分钟)
- 订的是什么票?(火车票/机票/演唱会)
- 是否支持选座?是否需要连座?
- 支付窗口时长?退款策略?
阶段二:核心难点:库存与超卖(5 分钟)
- Redis + Lua 原子锁座
- 唯一索引防超卖(最后一道防线)
- 分段库存应对超大并发
阶段三:座位锁定与超时(3 分钟)
- 锁状态机:available → locked → sold / available
- 续期与 watchdog 机制
- 超时释放:定时任务 or Keyspace Notification
阶段四:支付闭环(3 分钟)
- 本地消息表保证最终一致性
- 支付回调幂等性处理
- 异常补偿:支付成功但出票失败怎么办?
阶段五:扩展性(2 分钟)
- 读多写少:座位图 CDN 缓存
- 热点场次:分段库存、排队机制
- 多场馆:按 venue_id 分库分表
常见追问
| 追问 | 回答要点 |
|---|---|
| “怎么保证不超卖?” | Lua 原子操作 + 数据库唯一约束双重保障 |
| “锁过期了用户还没付完钱怎么办?” | 前端心跳续期;支付前二次确认座位状态 |
| “如果 Redis 挂了怎么办?” | 降级到数据库乐观锁;Redis Cluster 高可用 |
| “同一用户买到不连座的票怎么办?” | 锁座时一次性锁定多个相邻座位 |
| “如何防止黄牛脚本?” | 限流 + 验证码 + 行为检测 + 限购 + 设备指纹 |
| “热门场次怎么削峰?” | 虚拟排队 + 令牌桶 + 分时段开售 |
与秒杀系统的对比
| 维度 | 秒杀系统 | 订票系统 |
|---|---|---|
| 库存形态 | 纯数量 | 有状态座位(二维) |
| 锁定机制 | 预扣减即可 | 需精确锁定具体座位 |
| 超时处理 | 简单回滚数量 | 需释放具体座位回池 |
| 一致性要求 | 允许少量超卖(可退) | 绝对不能超卖 |
| 用户体验 | 抢不到就结束 | 支持选座、换座、连座 |
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。