系统设计:在线订票系统

在线订票系统设计面试全攻略:高并发票务库存管理、秒杀与库存一致性、座位锁与订单超时释放、支付与出票闭环,涵盖从需求分析到架构设计的完整方案。

系统设计:在线订票系统

从火车票到演唱会门票,订票系统的核心是库存扣减的精确性与高并发下的稳定性。

订票系统与电商秒杀有相似之处(库存稀缺、高并发),但有其独特挑战:座位是一个有状态的二维资源,不像商品只是数量扣减。


场景分析(Scenario)

需求

  • 功能需求:浏览场次 → 选座 → 锁定座位 → 支付 → 出票
  • 用户规模:某热门演唱会 10 万张票,同时在线抢票用户 100 万+
  • QPS:开抢瞬间峰值 10 万 QPS,平时 1000 QPS
  • 一致性要求:绝对不能超卖(库存扣减必须精确)
  • 响应延迟:选座页 < 500ms,锁座 < 200ms

核心挑战

  1. 超卖问题:10 万人抢 1 万张票,如何精确扣减库存?
  2. 座位锁:用户选好座位后,需要锁定一段时间等待支付
  3. 订单超时:用户锁座后 15 分钟不支付,座位要释放回库存
  4. 热点场次:顶流演唱会,库存集中在单一场次
  5. 黄牛打击:防止脚本批量抢票、刷票

服务架构(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)
);
  1. 创建订单时,同时写入 payment_tasks
  2. 定时任务扫描 pending 任务,调用支付网关
  3. 支付成功:更新订单 → 出票 → 删除任务
  4. 支付超时:释放锁 → 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 高可用
“同一用户买到不连座的票怎么办?”锁座时一次性锁定多个相邻座位
“如何防止黄牛脚本?”限流 + 验证码 + 行为检测 + 限购 + 设备指纹
“热门场次怎么削峰?”虚拟排队 + 令牌桶 + 分时段开售

与秒杀系统的对比

维度秒杀系统订票系统
库存形态纯数量有状态座位(二维)
锁定机制预扣减即可需精确锁定具体座位
超时处理简单回滚数量需释放具体座位回池
一致性要求允许少量超卖(可退)绝对不能超卖
用户体验抢不到就结束支持选座、换座、连座

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页