票务是「秒杀 + 选座」的复合题:热门演唱会的门票在开票瞬间被几十万人同时抢,这和普通秒杀一样要削峰限流;但票务又比秒杀多一层难度——座位是有拓扑关系的资源。买一张票要指定具体座位,买两张要连座,退票后座位要能重新释放,同一场次不同价位区还要分别控量。本文按照系统设计面试的标准答题结构,设计一个生产级的票务选座与秒杀系统。
一句话:票务的难点不在「抢」,而在「选」——把二维座位图映射成可原子操作的库存单元,用「锁座 + 超时释放 + 唯一约束」三件套同时解决超卖与体验。
一、需求澄清与量级估算
1.1 需求澄清
面试官给出题目「设计一个票务选座与秒杀系统」后,先通过提问明确边界:
- 座位模式:对号入座(演唱会、电影、高铁)还是不对号(音乐节站票)?对号入座才需要座位图与锁座,本文以此为主。
- 选座粒度:只支持系统自动分配,还是允许用户点选具体座位?允许点选会引入「用户锁定座位」这个长事务。
- 连座要求:买 N 张是否必须相邻?连座是选座算法的核心约束,必须显式澄清。
- 场次与价位:一个演出有多个场次,每个场次有多个价位区(内场/看台/山顶),价位区之间库存独立。
- 抢票节奏:是否「开票瞬间集中抢」?如果是,必须有虚拟排队;如果是常态化售卖(如高铁票),则更强调查询性能。
- 退改签:是否支持退票、改签?退票会把座位释放回库存池,涉及回补与再售的一致性。
- 防刷:是否要实名限购(一证一票)、设备指纹、人机验证?票务是黄牛重灾区。
1.2 量级估算
以一场 5 万人体育馆的演唱会为例:
座位数: 5 万座/场次
场次: 1 场(首轮开票)
峰值抢票人数: 50 万同时在线
开票瞬时 QPS: 50 万 / 30 秒 ≈ 1.6 万 QPS(抢票请求)
查询 QPS(座位图): 座位图静态化 + CDN,回源 < 1000 QPS
锁座 QPS: 实际成单约 5 万 / 10 分钟 ≈ 83 QPS(锁座远小于抢票请求)
下单支付转化: 锁座 → 支付成功约 70%
存储:
座位状态: 5 万 × 多场次,热数据全内存(Redis)
订单: 5 万单/场次,冷热分离
座位图元数据: 静态,可 CDN 缓存
关键结论:抢票请求量(1.6 万 QPS)远大于实际锁座量(83 QPS)。系统的核心任务不是扛住所有请求去锁座,而是在入口就把绝大部分请求挡掉或排队,让真正到达库存层的请求量降到数据库能承受的量级。这就是虚拟排队存在的意义。
二、高层架构设计
用户 ──▶ CDN(座位图静态资源 / 排队页)
│
▼
┌────────────────┐
│ 接入层 │ 人机验证 / 设备指纹 / 实名校验
│ (WAF + 网关) │ 限流:单 IP / 单用户 / 全站
└───────┬────────┘
▼
┌────────────────┐
│ 虚拟排队服务 │ 发放排队令牌,按令牌放行
│ (Redis ZSet) │ 放行速率 = 后端可承受速率
└───────┬────────┘
▼
┌────────────────┐
│ 抢票/锁座服务 │ 校验令牌 → 锁座 → 生成待支付订单
│ (Lua 原子脚本) │
└───────┬────────┘
▼
┌────────────────┐ ┌──────────────────┐
│ Redis 座位库存 │◀──────▶│ 延迟队列 │
│ (Bitmap/Hash) │ │ (订单超时释放) │
└───────┬────────┘ └────────┬─────────┘
▼ ▼
┌────────────────┐ ┌──────────────────┐
│ 订单服务 │ │ 支付服务 │
│ MySQL 唯一约束 │ │ (回调幂等) │
└────────────────┘ └──────────────────┘
四条关键链路:
- 座位图查询:静态化到 CDN,只把「已售/已锁」状态用增量接口实时拉取,避免每次全量回源。
- 抢票请求:过网关限流与人机验证后进入虚拟排队,拿到令牌的请求才允许调用锁座接口。
- 锁座:Redis 原子脚本完成「检查座位空闲 + 占用 + 生成订单号」,成功后再异步落库。
- 超时释放:待支付订单进延迟队列,15 分钟未支付则释放座位、回补库存。
三、核心组件设计
3.1 座位库存建模与锁座
座位图的建模:一个场次的座位可以抽象成「价位区 → 排 → 座」的三级结构。存储上,最省内存的方式是用 Bitmap:把座位按 区-排-座 线性编号,每一位表示一个座位。
座位线性编号:index = base(zone) + (row - 1) * seats_per_row + (seat - 1)
Bitmap 布局(每场次一个 bitmap,5 万座 ≈ 6250 字节):
bit = 0 空闲
bit = 1 已售/已锁
操作:
SETBIT seat:show:1001 <index> 1 # 占用(需先 GETBIT 检查,用 Lua 保证原子)
GETBIT seat:show:1001 <index> # 查询单座
BITFIELD seat:show:1001 GET u64 0 # 批量取整排 64 座的状态
Bitmap 的三大好处:内存极小(5 万座仅 6KB)、批量查询快(BITFIELD 一次取一排 64 座)、原子性好(配合 Lua 可一次检查多座并全部占用)。
锁座的原子脚本(多座一次锁定,全部成功或全部失败):
-- KEYS[1] = seat:show:<id> ARGV[1] = 座位数 n ARGV[2..n+1] = 各座位 index
local n = tonumber(ARGV[1])
-- 第一遍:检查所有座位是否都空闲
for i = 1, n do
if redis.call('GETBIT', KEYS[1], tonumber(ARGV[i+1])) == 1 then
return 0 -- 任一座位被占,整体失败
end
end
-- 第二遍:全部占用
for i = 1, n do
redis.call('SETBIT', KEYS[1], tonumber(ARGV[i+1]), 1)
end
-- 记录占用归属,便于超时释放时精确回补
local order_no = ARGV[n+2]
for i = 1, n do
redis.call('SET', 'seat:owner:' .. KEYS[1] .. ':' .. ARGV[i+1], order_no, 'EX', 900)
end
return 1
注意两个细节:
- 两遍扫描:必须先全检查再全占用,否则出现「第一座占用成功、第二座失败」的半锁定状态,回滚很麻烦。
- 占用归属:给每个被占座位记一个带 TTL 的
seat:owner:*键,超时自动过期,同时保留「哪个订单占的座」用于精确释放(避免误放别人的座)。
连座算法:用户买 2 张要求相邻时,要在目标价位区内找「连续的空位段」。用位运算实现:
def find_consecutive(bitmap_int, row_bits, need):
"""在单排的整数位图里找 need 个连续 0(空闲)的位置"""
mask = (1 << need) - 1
for shift in range(row_bits - need + 1):
if (bitmap_int >> shift) & mask == 0:
return shift # 返回起始座偏移
return -1 # 本排无连座
工程上会放宽策略:先在本排找严格连座,找不到则允许「同排不相邻」「同区跨排」降级,并在 UI 上明确提示,避免用户反复刷新。
锁座与数据库的关系:Redis 是锁座的第一道闸门,但不能只信 Redis。真正的兜底是数据库层的唯一约束:
CREATE TABLE seat_occupancy (
show_id BIGINT NOT NULL,
seat_index INT NOT NULL,
order_no VARCHAR(32) NOT NULL,
status TINYINT NOT NULL, -- 1=锁定 2=已售 0=已释放
expire_at DATETIME NOT NULL,
PRIMARY KEY (show_id, seat_index), -- 物理上杜绝一 seat 两订单
UNIQUE KEY uk_order_seat (order_no, seat_index),
INDEX idx_expire (status, expire_at)
);
PRIMARY KEY (show_id, seat_index) 是防超卖的终极防线:即使 Redis 因故障重复放行,数据库的唯一约束也会让第二次插入失败,应用层捕获 DuplicateKeyException 后回滚 Redis 占用。这个思路与 电商库存系统设计
里的「Redis 预扣 + DB 唯一约束兜底」完全一致。
3.2 虚拟排队与多级限流
50 万人抢 5 万张票,如果全部请求打到锁座服务,Redis 与 DB 会被打穿。虚拟排队的思路是:把瞬时并发转成受控的匀速放行。
排队令牌设计:
1. 用户点「立即抢票」→ 服务端生成排队号
ZADD queue:show:1001 <timestamp_ms> <user_id>
返回前端:你的排位 = ZRANK,预计等待 = 排位 / 放行速率
2. 前端轮询排队状态(或 WebSocket 推送)
ZRANK queue:show:1001 <user_id> → 动态前进
3. 排队服务按固定速率放行:
每秒从队头弹出 R 个用户,写入「已放行集合」
ZPOPMIN queue:show:1001 R
SADD passed:show:1001 <user_id>(带 TTL)
放行速率 R = 后端安全 QPS(如 500/s)
4. 用户拿到放行资格后调锁座接口,服务端校验 SISMEMBER passed:show:1001
放行速率 R 是关键参数,它由后端实测承载能力决定。做容量规划时先压测「锁座接口在 P99 < 100ms 时的最大 QPS」,再取 70% 作为 R 留出余量。
多级限流(层层收口,越靠前越便宜):
第 1 层 CDN/WAF: 封禁恶意 IP、CC 攻击,静态资源不回源
第 2 层 网关限流: 单用户 5 次/秒,单 IP 20 次/秒,全站 2 万/秒
第 3 层 人机验证: 滑块/点选验证码,过滤脚本刷票
第 4 层 虚拟排队: 令牌桶匀速放行到后端
第 5 层 业务限流: 一证一票、单场次限购 4 张
限流算法的选择(令牌桶 vs 漏桶 vs 滑动窗口)在 API 网关系统设计 里有完整推导,票务场景推荐令牌桶:允许突发(用户手速快时连点几下不误伤),又能限制长期速率。
排队公平性:要防「黄牛用大量账号同时排队占位」。措施包括:排队号绑定实名身份、同身份证只保留最早的排队号、检测同一设备指纹的多账号排队并合并。这些与 秒杀系统设计 里的防刷体系同源,区别是票务的限购粒度更细(按演出、按场次、按价位)。
3.3 防超卖与幂等
超卖的三个层次:
- Redis 层超卖:并发
GETBIT后SETBIT之间存在竞态。必须用 Lua 脚本把「检查 + 占用」合成原子操作(见 3.1)。 - 数据库层超卖:Redis 与 DB 双写时,若 Redis 成功、DB 失败(或反过来),状态会漂移。用唯一约束兜底 + 对账任务修复。
- 业务层超卖:用户重复提交(网络重试、快速双击)导致同一用户占用多个座位。用幂等键解决。
幂等设计:客户端每次请求带一个 request_id(前端生成 UUID),服务端在 Redis 里做「一次性消费」:
-- KEYS[1] = idem:<request_id> ARGV[1] = 结果缓存内容
-- 返回 1 表示首次处理,返回 0 表示重复请求
if redis.call('SET', KEYS[1], 'PENDING', 'NX', 'EX', 600) then
return 1
end
return 0
重复请求直接返回第一次的结果(幂等键 TTL 内把结果写回同一个 key,重复请求读它),保证「同一 request_id 只锁一次座、只生成一个订单」。这套模式在 幂等设计模式 里有更系统的归纳。
订单号生成:订单号要全局唯一、趋势递增(便于分库分表与按时间范围查询),常见做法是「雪花算法」或「时间戳 + 分片号 + 序列号」。不要用 UUID 做主键——无序主键会导致 B+ 树页分裂,写入性能下降。
订单号 = 时间戳(41bit) | 机房(5bit) | 机器(5bit) | 序列号(12bit)
→ 单机每毫秒 4096 个,足够票务场景
3.4 订单超时释放与回补
锁座之后用户可能不付款。座位必须能「超时自动释放」,否则会被白白占住。
方案一:延迟队列(推荐)
订单创建时投递一条延迟消息(15 分钟),到期消费:
投递:delay_queue.publish(order_no, delay=900s)
消费:
1. 查订单状态,若已是 PAID → 丢弃(幂等)
2. 若仍是 PENDING → 释放座位:
a. 校验 seat:owner:* 确实属于该订单(防止误放已转卖/已重锁的座)
b. 清除 bitmap 对应位 + 删除 owner 键
c. 更新订单状态为 CLOSED,写一条释放事件
延迟队列可以用 Redis ZSet(score = 到期时间戳,定时扫描)、RabbitMQ 延迟插件、或 RocketMQ 定时消息。核心要求是至少一次投递 + 消费端幂等——重复消费绝不能把已支付的座位释放掉,所以第 1 步的状态检查必不可少。
方案二:定时扫表(兜底)
延迟队列可能丢消息,必须有一个定时任务每 30 秒扫一次:
SELECT order_no FROM ticket_order
WHERE status = 'PENDING' AND expire_at < NOW()
LIMIT 1000;
扫到的订单走与延迟队列相同的释放逻辑。两道防线保证「没有任何座位会被永久占住」。
回补的并发安全:释放座位时要小心「释放与重新锁定」的竞态——用户 A 的订单刚超时释放,用户 B 恰好在这瞬间锁座成功,如果释放逻辑把 B 的占用清掉就出错了。用 owner 校验解决:释放前确认 seat:owner:<show>:<index> == order_no,不是自己的就跳过。这个校验与释放动作也要放进同一个 Lua 脚本里原子执行。
-- 精确释放:只释放确实属于该订单的座位
local owner = redis.call('GET', KEYS[2])
if owner == ARGV[2] then
redis.call('SETBIT', KEYS[1], tonumber(ARGV[1]), 0)
redis.call('DEL', KEYS[2])
return 1
end
return 0 -- 已被他人占用或已过期,跳过
退票场景:用户主动退票比超时释放复杂——涉及退款(调 支付系统
)、座位回补、限购额度返还。建议用状态机 + 事件驱动:PAID → REFUNDING → REFUNDED → SEAT_RELEASED,每步独立幂等,任一步失败可重试而不产生副作用。
四、深入权衡
1. 全内存 vs 落库时机:座位状态全放 Redis(快但会丢),还是每步都同步落库(慢但可靠)?生产做法是「Redis 为准 + 异步落库 + 定时对账」。Redis 若崩溃,从数据库的 seat_occupancy 重建 bitmap;对账任务比对两者差异并告警。
2. 排队体验 vs 系统安全:严格排队(完全按序放行)最安全但用户体验差(要等很久);先到先得(不限流)体验好但系统会被打穿。折中方案是「小规模分批放行 + 前端透明展示排位与预估等待」,让用户有预期就不容易流失。
3. 连座的严格程度:严格连座(必须相邻)会显著降低库存利用率——一个 5 连空位来了 2 张的需求,如果允许拆开卖能卖出更多票。实际策略是「优先满足连座,但允许在相邻区段拆分,并在售罄前 24 小时放开拆座」。
4. 锁座 TTL 的长短:TTL 太短(如 5 分钟),用户还没输完支付信息座位就没了,体验差且投诉多;TTL 太长(如 30 分钟),座位周转率低,热门场次会「假售罄」。通常取 10~15 分钟,并在支付页倒计时提醒。
5. 一致性与可用性:锁座是典型的 CP 场景——宁可让少量请求失败重试,也不能超卖。所以 Redis 与 DB 的写入都走「强校验 + 失败回滚」,不追求最终一致的弱保证。这一点与 分布式锁 的设计取向相同:锁的价值在于正确性,不在于吞吐。
五、总结
票务选座与秒杀系统的设计可以浓缩成四条主线:
- 库存建模:用 Bitmap 把二维座位图压成一维位数组,5 万座仅 6KB,配合 Lua 脚本实现多座原子锁定。
- 入口收口:虚拟排队把 1.6 万 QPS 的抢票洪峰降到后端可承受的几百 QPS,放行速率由压测结果反推。
- 防超卖三件套:Redis 原子锁座(性能)、数据库唯一约束(正确性)、幂等键(重复请求),三层缺一不可。
- 超时释放双保险:延迟队列负责及时释放,定时扫表负责兜底,owner 校验防止误放他人座位。
延伸阅读:整体削峰分层与防刷体系见 秒杀系统设计 ;库存预扣与一致性方案见 电商库存系统设计 ;座位锁定在数据库层的建模与唯一约束兜底见 票务系统实现 ;支付回调的幂等处理见 支付系统设计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。