“我排队第 50 名,别人排队第 200 名,为什么他先候补成功?”——这个问题困扰了无数抢票人。答案藏在 12306 候补系统的底层设计里:席位复用、区间独立排队、事件驱动匹配。本文从五个维度拆解这套世界级高并发系统的技术架构,让你不仅”知其然”,更”知其所以然”。
一、为什么 12306 如此难做? 在深入设计之前,先理解问题的规模。
1.1 业务复杂度 铁路售票与普通电商秒杀有本质区别:
对比维度
普通电商秒杀
12306 售票
商品粒度
SKU 级别(一部手机)
区间级别(北京→济南,同一座位不同区间)
库存管理
扣减库存即可
区间叠加、席位复用
并发竞争
单 SKU 多人竞争
同一座位多个区间交叉竞争
订单关联
独立订单
关联订单(联程票、往返票)
核心差异 :一趟北京到上海的高铁,途经 10 个站点,同一个座位可以拆分成 9 段区间分别售卖。这带来的是指数级的复杂度。
1.2 规模数据 以 2024 年春运为例:
日均访问量:100 亿次+
倰值 QPS:每秒 100 万+
候补订单:单日峰值 2000 万+
车次数据:5000+ 趟车次 ,每趟车 500-2000 个座位
在这种规模下,任何不当的设计都会被瞬间放大成系统崩溃。
二、核心设计基石:席位复用 2.1 什么是席位复用? 席位复用 是指同一个座位可以分段卖给不同乘客,只要乘车区间不重叠。
举例说明:
1 2 3 4 5 6 7 8 9 车次:G1 北京→青岛 途经站点:北京 → 天津 → 济南 → 淄博 → 青岛 座位 12A 的售卖情况: - 张三:北京 → 济南(占用 北京→天津、天津→济南 两段) - 李四:济南 → 青岛(占用 济南→淄博、淄博→青岛 两段) - 王五:天津 → 淄博(占用 天津→济南、济南→淄博 两段) 结果:同一座位 12A,同时卖给 3 个人,互不冲突
这个设计极大提升了座位利用率,但技术实现难度陡增。
2.2 技术实现:Redis Bitmap 12306 使用 Redis Bitmap(位图) 存储每个座位的区间占用状态。
数据结构设计:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Key: train:{train_id}:{date}:{seat_no} Value: Bitmap,每一位代表一个站点区间的占用状态 示例: 车次 G1,日期 2024-01-20,座位 12A 途经站点:北京(0) → 天津(1) → 济南(2) → 淄博(3) → 青岛(4) Bitmap: 1 1 0 0 ↑ ↑ ↑ ↑ │ │ │ └─ 淄博→青岛 (0=空闲) │ │ └─── 济南→淄博 (0=空闲) │ └───── 天津→济南 (1=占用) └─────── 北京→天津 (1=占用) 表示:北京→济南 已售,济南→青岛 空闲
为什么用 Bitmap?
指标
Bitmap
传统方案(如 Hash)
空间占用
N 个站点仅需 N bit(几字节)
每区间存一个字段(几百字节)
查询复杂度
O(1)
O(N)
区间判断
位运算 OR,一次搞定
需遍历所有区间
适用场景
固定长度、高频查询
灵活但低效
区间可用性检查(伪代码):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 def check_interval_available (redis, train_id, date, seat_no, from_station, to_station) : """ 检查指定区间是否可用 from_station: 起始站点索引(如北京=0) to_station: 终点站点索引(如济南=2) """ key = f"train:{train_id} :{date} :{seat_no} " for i in range(from_station, to_station): bit = redis.getbit(key, i) if bit == 1 : return False return True def allocate_interval (redis, train_id, date, seat_no, from_station, to_station) : """ 分配区间:将对应 bit 位设为 1 """ key = f"train:{train_id} :{date} :{seat_no} " for i in range(from_station, to_station): redis.setbit(key, i, 1 ) return True
更高效的方式:使用位运算
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 def check_and_allocate (redis, train_id, date, seat_no, from_station, to_station) : """ 使用位运算一次性检查并分配区间 """ key = f"train:{train_id} :{date} :{seat_no} " current = redis.get(key) if current is None : current = 0 mask = (1 << to_station) - (1 << from_station) if current & mask != 0 : return False lua_script = """ local current = tonumber(redis.call('GET', KEYS[1])) or 0 local mask = tonumber(ARGV[1]) if (current & mask) == 0 then redis.call('SET', KEYS[1], current | mask) return 1 else return 0 end """ result = redis.eval(lua_script, 1 , key, mask) return result == 1
三、分布式排队机制 3.1 核心设计:按区间独立排队 很多人以为候补是整趟车排一个大队列,其实不然。
12306 的设计:每个乘车区间独立排队。
1 2 3 4 5 6 7 8 9 10 车次 G1(北京→青岛)的候补队列: 队列1:北京 → 天津(85 人排队) 队列2:北京 → 济南(320 人排队) 队列3:天津 → 济南(56 人排队) 队列4:济南 → 青岛(198 人排队) 队列5:北京 → 青岛(1275 人排队) ... 每个区间一个独立的 Sorted Set
为什么要按区间独立排队?
公平性 :北京→天津 和 北京→青岛 根本不是同一批票,放一起排队没有意义
并行处理 :不同区间队列可以并发处理,提升吞吐
精准匹配 :释放席位时,直接定位对应队列,无需遍历
这解释了一个常见疑问 :
为什么我排队 50 名,别人排队 200 名,他却先候补成功?
因为你们根本不在同一个队列。他排的是天津→济南,你排的是北京→济南。
3.2 技术实现:Redis Sorted Set 数据结构设计:
1 2 3 4 5 6 7 8 9 10 Key: backup:{train_id}:{date}:{from_station}:{to_station} Value: 有序集合,存储候补订单 ID Score: 用户提交候补的时间戳(越小越靠前) 示例: backup:G1:20240120:beijing:jinan ├─ order_001 (score: 1705678901) ← 第1名 ├─ order_002 (score: 1705678905) ← 第2名 ├─ order_003 (score: 1705678912) ← 第3名 └─ ...
核心操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 import timedef add_to_backup_queue (redis, train_id, date, from_station, to_station, order_id) : """ 加入候补队列(ZADD) 时间戳作为 Score,天然实现先到先得 """ key = f"backup:{train_id} :{date} :{from_station} :{to_station} " score = int(time.time() * 1000 ) redis.zadd(key, {order_id: score}) return True def get_queue_position (redis, train_id, date, from_station, to_station, order_id) : """ 查询排队名次(ZRANK) 返回当前排队位置(从 0 开始,+1 后为实际名次) """ key = f"backup:{train_id} :{date} :{from_station} :{to_station} " rank = redis.zrank(key, order_id) if rank is None : return -1 return rank + 1 def get_top_n_from_queue (redis, train_id, date, from_station, to_station, n=10 ) : """ 取出队列前 N 名(ZRANGE) """ key = f"backup:{train_id} :{date} :{from_station} :{to_station} " return redis.zrange(key, 0 , n - 1 ) def remove_from_queue (redis, train_id, date, from_station, to_station, order_id) : """ 候补成功或取消,移出队列(ZREM) """ key = f"backup:{train_id} :{date} :{from_station} :{to_station} " redis.zrem(key, order_id) return True
性能分析:
操作
复杂度
百万级队列耗时
ZADD(加入队列)
O(log N)
~21 次比较,微秒级
ZRANK(查询名次)
O(log N)
微秒级
ZRANGE(取前 N 名)
O(log N + M)
M 为取出数量,毫秒级
ZREM(移出队列)
O(log N)
微秒级
结论 :Sorted Set 天然适合排队场景,百万级数据依然高效。
3.3 数据持久化:MySQL 分库分表 Redis 是内存数据库,一旦宕机可能丢失数据。候补订单必须持久化到 MySQL。
分库分表策略:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 分库键:user_id % 16(按用户分 16 个库) 分表键:order_id % 128(每个库 128 张表) 表结构: CREATE TABLE backup_order_00 ( order_id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, train_id VARCHAR(20) NOT NULL, travel_date DATE NOT NULL, from_station VARCHAR(20) NOT NULL, to_station VARCHAR(20) NOT NULL, seat_type TINYINT NOT NULL, -- 座位类型:商务/一等/二等 status TINYINT DEFAULT 0, -- 状态:排队中/已兑现/已取消 queue_position INT, -- 冗余存储排队名次 create_time DATETIME, update_time DATETIME, INDEX idx_train_date (train_id, travel_date, from_station, to_station) );
Redis 与 MySQL 如何保持一致?
这是一个典型的分布式一致性问题,后文技术难点部分详解。
四、事件驱动:候补车票从哪里来? 候补的车票不是凭空产生的,全部来自系统事件触发。
4.1 四大票源
票源
触发事件
延迟
占比(估算)
用户退票
用户主动退票
秒级
40%
订单超时
抢到票后 30 分钟未支付
秒级
35%
改签释放
用户改签其他车次
秒级
15%
动态加挂
12306 官方追加车厢
分钟级
10%
4.2 事件驱动架构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 ┌──────────────────────────────────────────────────────────────────┐ │ 事件来源 │ ├──────────┬──────────┬──────────┬─────────────────────────────────┤ │ 退票事件 │ 超时事件 │ 改签事件 │ 加挂事件(定时任务触发) │ └────┬─────┴────┬─────┴────┬─────┴─────────────┬───────────────────┘ │ │ │ │ ▼ ▼ ▼ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 消息队列(Kafka/RocketMQ) │ │ │ │ Topic: ticket-released │ │ Partition: 按 train_id 分区,保证同一车次顺序处理 │ └───────────────────────────┬──────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 候补匹配服务 │ │ │ │ 1. 消费释放席位事件 │ │ 2. 查询对应区间的候补队列(Redis Sorted Set) │ │ 3. 取出队列第一名 │ │ 4. 执行兑现流程 │ └──────────────────────────────────────────────────────────────────┘
事件消息结构:
1 2 3 4 5 6 7 8 9 10 11 12 { "event_id" : "evt_20240120_123456" , "event_type" : "REFUND" , "train_id" : "G1" , "travel_date" : "2024-01-20" , "seat_no" : "12A" , "from_station" : "beijing" , "to_station" : "jinan" , "seat_type" : 2 , "release_time" : 1705678901234 , "trace_id" : "trace_abc123" }
4.3 候补匹配流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 席位释放事件触发: Step 1: 解析事件,确定释放的区间 train_id=G1, date=20240120, seat=12A, from=beijing, to=jinan Step 2: 计算该区间覆盖的所有原子区间 北京→济南 = [北京→天津, 天津→济南] Step 3: 查询这些区间的候补队列 - 队列 A:北京→天津(85 人) - 队列 B:天津→济南(56 人) - 队列 C:北京→济南(320 人)← 完全匹配,优先处理 Step 4: 尝试匹配队列 C 的第一名 - 检查席位 12A 是否满足用户需求(座位类型、是否有票) - 满足 → 执行兑现流程 - 不满足 → 尝试队列 C 的第二名 Step 5: 若队列 C 无匹配,尝试其他队列 - 队列 A + 队列 B 是否有同一座位? - 这涉及跨区间匹配,后文详解
4.4 候补兑现完整流程 席位释放后,系统需要执行一整套原子流程才能完成兑现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 ┌──────────────────────────────────────────────────────────────────────────┐ │ 候补兑现完整流程 │ ├──────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────┐ │ │ │ 席位释放事件 │ │ │ └──────┬──────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────┐│ │ │ Step 1: 查询候补队列 ││ │ │ ││ │ │ redis.zrange("backup:G1:20240120:beijing:jinan", 0, 0) ││ │ │ → 取出排队第一名 user_id ││ │ └──────────────────────────────┬──────────────────────────────────────┘│ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────┐│ │ │ Step 2: 获取分布式锁 ││ │ │ ││ │ │ lock_keys = ["lock:G1:20240120:12A:beijing:tianjin", ││ │ │ "lock:G1:20240120:12A:tianjin:jinan"] ││ │ │ redis.set(lock_key, user_id, nx=True, px=5000) ││ │ │ → 锁定所有原子区间,防止并发超卖 ││ │ └──────────────────────────────┬──────────────────────────────────────┘│ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────┐│ │ │ Step 3: 检查席位可用性(双重检查) ││ │ │ ││ │ │ bitmap_key = "train:G1:20240120:12A" ││ │ │ check_interval_available(bitmap_key, beijing, jinan) ││ │ │ → 确认席位真正可用 ││ │ └──────────────────────────────┬──────────────────────────────────────┘│ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────┐│ │ │ Step 4: MySQL 本地事务(原子操作) ││ │ │ ││ │ │ BEGIN TRANSACTION; ││ │ │ -- 4.1 标记席位占用 ││ │ │ UPDATE seat SET status='OCCUPIED' WHERE ...; ││ │ │ -- 4.2 创建订单 ││ │ │ INSERT INTO ticket_order (...) VALUES (...); ││ │ │ -- 4.3 更新候补状态 ││ │ │ UPDATE backup_order SET status='FULFILLED' WHERE ...; ││ │ │ -- 4.4 写入操作日志(用于 Redis 异步同步) ││ │ │ INSERT INTO backup_operation_log (...) VALUES (...); ││ │ │ COMMIT; ││ │ └──────────────────────────────┬──────────────────────────────────────┘│ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────┐│ │ │ Step 5: 异步更新 Redis ││ │ │ ││ │ │ -- 5.1 更新席位 Bitmap ││ │ │ redis.setbit("train:G1:20240120:12A", beijing_idx, 1) ││ │ │ redis.setbit("train:G1:20240120:12A", tianjin_idx, 1) ││ │ │ -- 5.2 从候补队列移除 ││ │ │ redis.zrem("backup:G1:20240120:beijing:jinan", user_id) ││ │ │ -- 5.3 释放分布式锁 ││ │ │ redis.del(lock_keys) ││ │ └──────────────────────────────┬──────────────────────────────────────┘│ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────┐│ │ │ Step 6: 后续处理 ││ │ │ ││ │ │ -- 6.1 冻结预付款(调用支付服务) ││ │ │ -- 6.2 发送短信通知(调用通知服务) ││ │ │ -- 6.3 更新操作日志状态 ││ │ └─────────────────────────────────────────────────────────────────────┘│ │ │ └──────────────────────────────────────────────────────────────────────────┘
流程关键点 :
步骤
关键点
失败处理
获取锁
按字典序获取,避免死锁
获取失败 → 席位已被抢占,跳过
双重检查
锁后再检查,防止并发穿透
不可用 → 释放锁,尝试下一名用户
MySQL 事务
本地事务保证原子性
失败 → 释放锁,回滚,尝试下一名
异步更新 Redis
通过操作日志保证最终一致
失败 → 定时任务补偿恢复
后续处理
异步执行,不阻塞主流程
失败 → 重试或告警人工处理
五、高并发架构设计 5.1 四级防护架构 从用户请求到数据落地,12306 构建了四级防护体系,逐级降压:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 ┌─────────────────────────────────────────────────────────────────┐ │ 用户请求 │ └─────────────────────────────┬───────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 第一级:网关层限流 │ │ │ │ 令牌桶算法:限制单 IP、单用户请求频率 │ │ 防刷策略:识别异常请求模式 │ │ 作用:挡住恶意流量,保护后端服务 │ └─────────────────────────────┬───────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 第二级:应用层本地缓存 │ │ │ │ 技术:Caffeine / Guava Cache │ │ 缓存内容: │ │ - 热门车次的区间队列长度(用户查询"排队第几名") │ │ - 热门车次的席位概览 │ │ - TTL:5-10 秒(容忍短时不一致) │ │ 效果:拦截 60%+ 的查询请求,减轻 Redis 压力 │ └─────────────────────────────┬───────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 第三级:Redis 集群缓存 │ │ │ │ 部署:主从 + 哨兵,保证高可用 │ │ 存储: │ │ - 席位 Bitmap(实时状态) │ │ - 候补 Sorted Set(排队数据) │ │ - 分布式锁(并发控制) │ │ 性能:单节点 10 万+ QPS,全内存操作 │ └─────────────────────────────┬───────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 第四级:MySQL 分库分表 │ │ │ │ 存储:订单持久化、用户数据、候补记录 │ │ 分片:按 user_id 分库,按 order_id 分表 │ │ 作用:数据兜底、Redis 故障时的降级数据源 │ └─────────────────────────────────────────────────────────────────┘
为什么需要四级?
级别
核心作用
如果缺失会怎样
网关限流
拦截恶意流量
后端被刷爆,正常用户无法访问
本地缓存
拦截高频查询
Redis 被查询请求压垮
Redis 缓存
扛住核心读写
MySQL 被高频请求打穿
MySQL 持久化
数据兜底
Redis 故障时数据丢失
5.2 限流降级策略
场景
限流策略
降级措施
用户感知
正常高峰
令牌桶 10 万 QPS
无
正常服务
超高峰
滑动窗口限流
排队等待
“系统繁忙,请稍后”
Redis 故障
熔断
查询走 MySQL,写入走 MQ
候补延迟,排队名次不可查
MySQL 故障
熔断
暂停下单,只读
“系统维护中”
六、技术难点深入 前文介绍了 12306 候补系统的核心设计,但真正让系统”稳如磐石”的,是对细节的极致处理。
6.1 难点一:分布式一致性 问题 :Redis 队列与 MySQL 订单如何保持一致?
1 2 3 4 5 6 7 8 9 10 11 场景:用户取消候补 方案一:先更新 MySQL,再删除 Redis 1. MySQL DELETE 成功 2. Redis ZREM 失败(网络抖动) → 结果:MySQL 无订单,Redis 仍在排队,用户"幽灵排队" 方案二:先删除 Redis,再更新 MySQL 1. Redis ZREM 成功 2. MySQL DELETE 失败 → 结果:Redis 已删除,MySQL 仍存在,用户想恢复排队无法恢复
解决方案 :本地消息表 + 最终一致性
1 2 3 4 5 6 7 8 9 10 11 12 13 CREATE TABLE backup_operation_log ( log_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL , operation_type VARCHAR (20 ) NOT NULL , redis_status TINYINT DEFAULT 0 , mysql_status TINYINT DEFAULT 0 , retry_count INT DEFAULT 0 , create_time DATETIME, update_time DATETIME, INDEX idx_status (redis_status, mysql_status) );
操作流程 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 def cancel_backup_order (order_id) : """取消候补订单(保证最终一致性)""" with db.transaction(): db.execute("UPDATE backup_order SET status = 'CANCELING' WHERE order_id = ?" , order_id) log_id = db.execute(""" INSERT INTO backup_operation_log (order_id, operation_type, redis_status, mysql_status) VALUES (?, 'CANCEL', 0, 0) """ , order_id) mq.send('backup_operation' , {'log_id' : log_id, 'order_id' : order_id, 'type' : 'CANCEL' }) return True def process_cancel_message (message) : """消费取消消息""" log_id = message['log_id' ] order_id = message['order_id' ] try : redis.zrem(f"backup:{train} :{date} :{from } :{to} " , order_id) db.execute("UPDATE backup_order SET status = 'CANCELED' WHERE order_id = ?" , order_id) db.execute("UPDATE backup_operation_log SET redis_status=1, mysql_status=1 WHERE log_id=?" , log_id) except Exception as e: retry_count = db.query("SELECT retry_count FROM backup_operation_log WHERE log_id=?" , log_id) if retry_count < 3 : db.execute("UPDATE backup_operation_log SET retry_count = retry_count + 1 WHERE log_id=?" , log_id) mq.send('backup_operation' , message, delay=60 ) else : alert(f"候补取消失败,log_id={log_id} , error={e} " )
定时对账任务 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 def reconcile_backup_data () : """每小时对账,确保 Redis 与 MySQL 一致""" all_queues = redis.keys("backup:*" ) for queue_key in all_queues: order_ids = redis.zrange(queue_key, 0 , -1 ) for order_id in order_ids: order = db.query("SELECT * FROM backup_order WHERE order_id = ?" , order_id) if not order or order.status == 'CANCELED' : redis.zrem(queue_key, order_id) log.info(f"清理幽灵排队:{order_id} " )
6.2 难点二:并发竞争与超卖 问题 :同一席位释放,多个区间队列如何竞争?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 场景: 车次 G1,座位 12A 张三退票:北京 → 济南 释放区间:北京→天津、天津→济南 现有候补队列: - 队列 A:北京→天津(100 人) - 队列 B:天津→济南(80 人) - 队列 C:北京→济南(200 人) 如果不加控制: 1. 队列 A 把 12A 分给用户 X(北京→天津) 2. 队列 C 把 12A 分给用户 Y(北京→济南) → X 和 Y 的区间重叠,超卖!
解决方案 :分布式锁 + 区间锁定
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 def allocate_seat_to_backup (train_id, date, seat_no, from_station, to_station, user_id) : """ 分配席位给候补用户 核心思想:锁定所有涉及的原子区间,防止并发超卖 """ atomic_intervals = get_atomic_intervals(train_id, from_station, to_station) lock_keys = sorted([ f"lock:{train_id} :{date} :{seat_no} :{f} :{t} " for f, t in atomic_intervals ]) lock_acquired = [] for key in lock_keys: success = redis.set(key, user_id, nx=True , px=5000 ) if not success: for acquired_key in lock_acquired: redis.delete(acquired_key) return False , "席位已被占用" lock_acquired.append(key) try : if not check_seat_available(train_id, date, seat_no, from_station, to_station): return False , "席位不可用" mark_seat_occupied(train_id, date, seat_no, from_station, to_station, user_id) order_id = create_ticket_order(user_id, train_id, date, seat_no, from_station, to_station) freeze_payment(user_id, order_id) redis.zrem(f"backup:{train_id} :{date} :{from_station} :{to_station} " , user_id) send_notification(user_id, f"候补成功!订单号:{order_id} " ) return True , order_id finally : for key in lock_acquired: redis.eval(""" if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end """ , 1 , key, user_id)
关键点 :
锁粒度 :锁到”原子区间”级别(相邻站点间),而非整个座位
锁顺序 :按字典序获取锁,避免死锁(A 等待 B,B 等待 A)
双重检查 :获取锁后再次检查可用性,防止并发穿透
原子释放 :使用 Lua 脚本,确保只释放自己持有的锁
6.3 难点三:跨区间匹配 问题 :候补”长区间”,释放”短区间”如何匹配?
1 2 3 4 5 6 7 8 9 10 11 场景: 用户候补:北京 → 青岛(全程) 当前释放: - 张三退票:北京 → 天津(仅第一段) - 李四退票:天津 → 济南(仅第二段) - 王五退票:济南 → 青岛(仅后两段) 能否匹配? - 如果三段都同时释放,可以合并给用户 - 但现实中,退票是离散事件,难以凑齐所有区间
方案一 :不跨区间匹配(简单,12306 当前方案)
1 2 3 4 5 6 7 8 9 10 11 规则: - 用户候补 北京→青岛,必须有 北京→青岛 的完整区间空位 - 不能由 北京→天津 + 天津→青岛 拼凑 优点: - 实现简单,逻辑清晰 - 避免复杂的跨区间锁竞争 缺点: - 用户体验稍差,需要分段候补 - 票源利用率降低
方案二 :智能跨区间匹配(复杂,提升体验)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 def smart_match_backup (train_id, date, from_station, to_station, user_id) : """ 智能匹配:尝试拼接多个短区间满足长区间需求 核心逻辑:遍历所有座位,找到在所有区间都空闲的座位 """ atomic_intervals = get_atomic_intervals(train_id, from_station, to_station) all_seats = get_all_seats(train_id, date, seat_type) for seat_no in all_seats: bitmap_key = f"train:{train_id} :{date} :{seat_no} " all_available = True for i, (f, t) in enumerate(atomic_intervals): station_idx = get_station_index(f) bit = redis.getbit(bitmap_key, station_idx) if bit == 1 : all_available = False break if all_available: return seat_no return None def on_seat_released (train_id, date, seat_no, from_station, to_station) : """ 席位释放事件触发匹配 """ related_queues = find_related_backup_queues(train_id, date, from_station, to_station) for queue_key in related_queues: candidates = redis.zrange(queue_key, 0 , 9 ) for user_id in candidates: request = get_backup_request(user_id) seat = smart_match_backup( train_id, date, request.from_station, request.to_station, user_id ) if seat: allocate_seat(seat, request.from_station, request.to_station, user_id) break
跨区间匹配的挑战 :
挑战点
说明
复杂度激增
需维护”缺票区间索引”,查询效率下降
并发竞争更复杂
多个部分匹配用户竞争同一区间
用户体验不确定
部分匹配时,用户不知道自己排的是哪个队列
实际权衡 :12306 当前采用”不跨区间匹配”策略,牺牲部分体验换取系统简洁。
6.4 难点四:热点倾斜 问题 :春运热门线路,单个区间队列百万级
1 2 3 4 5 6 7 8 9 10 11 12 场景: 春节前一周,北京→哈尔滨,候补队列 200 万人 Sorted Set 性能: - ZADD: O(log N) = log(2,000,000) ≈ 21 次操作 - ZRANK: O(log N) ≈ 21 次操作 - 单次操作微秒级,没问题 问题: 1. 频繁 ZRANK 查询排队名次,热点 Key 2. 频繁 ZRANGE 取队首用户,热点 Key 3. Redis 单线程,单 Key 成为瓶颈
解决方案 :分片队列 + 虚拟排队
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 def get_shard_key (train_id, date, from_station, to_station, user_id) : """ 将单个大队列拆分成多个小队列 按 user_id 分片,保证同一用户始终在同一个分片 """ shard_count = 32 shard_id = hash(user_id) % shard_count return f"backup:{train_id} :{date} :{from_station} :{to_station} :shard:{shard_id} " def add_to_sharded_queue (train_id, date, from_station, to_station, user_id) : """ 加入分片队列 """ shard_key = get_shard_key(train_id, date, from_station, to_station, user_id) score = int(time.time() * 1000 ) redis.zadd(shard_key, {user_id: score}) def get_queue_position_sharded (train_id, date, from_station, to_station, user_id) : """ 查询分片队列中的名次 """ shard_key = get_shard_key(train_id, date, from_station, to_station, user_id) local_rank = redis.zrank(shard_key, user_id) if local_rank is None : return -1 total_finished = sum([ redis.get(f"backup:{train_id} :{date} :{from_station} :{to_station} :shard:{i} :finished" ) for i in range(32 ) ]) return local_rank + total_finished + 1 def get_virtual_position (redis, train_id, date, from_station, to_station, user_id) : """ 不返回真实名次,而是"虚拟名次" 避免用户看到"排队 150 万名"直接放弃 """ real_rank = get_real_position(redis, train_id, date, from_station, to_station, user_id) if real_rank <= 100 : return real_rank elif real_rank <= 1000 : return f"前 1000 名" else : avg_fulfill_rate = get_historical_fulfill_rate(train_id, from_station, to_station) wait_hours = real_rank / avg_fulfill_rate return f"预计等待 {wait_hours:.1 f} 小时"
本地缓存优化 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 from cachetools import TTLCachequeue_length_cache = TTLCache(maxsize=10000 , ttl=10 ) def get_queue_length (train_id, date, from_station, to_station) : """ 获取队列长度(优先本地缓存) """ cache_key = f"{train_id} :{date} :{from_station} :{to_station} " if cache_key in queue_length_cache: return queue_length_cache[cache_key] length = redis.zcard(f"backup:{train_id} :{date} :{from_station} :{to_station} " ) queue_length_cache[cache_key] = length return length
6.5 难点五:容灾与降级 问题 :Redis 故障时如何保证候补可用?
1 2 3 4 故障场景: 1. Redis 主节点宕机,哨兵切换中(30 秒不可用) 2. Redis 集群网络分区,部分数据不可达 3. Redis 内存溢出,被系统 OOM Killer 杀掉
多级容灾设计 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ┌─────────────────────────────────────────────────────────────────┐ │ 正常流程 │ │ │ │ 用户请求 → Redis Sorted Set 排队 → 候补匹配 → 订单创建 │ │ │ └───────────────────────────┬─────────────────────────────────────┘ │ Redis 故障检测 ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 降级流程 │ │ │ │ 用户请求 → MQ 消息队列暂存 → MySQL 记录排队 → 后台任务恢复 │ │ │ │ 具体措施: │ │ 1. 新增候补请求写入 MQ(Kafka),不直接写 Redis │ │ 2. MySQL 记录候补订单(status=PENDING) │ │ 3. 后台任务监控 Redis 恢复后,将 MQ 消息同步到 Redis │ │ 4. 查询排队名次返回"系统繁忙,请稍后查询" │ │ │ └─────────────────────────────────────────────────────────────────┘
降级代码示例 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 def add_backup_order_with_fallback (train_id, date, from_station, to_station, user_id) : """ 加入候补队列(带降级) """ try : redis.zadd(f"backup:{train_id} :{date} :{from_station} :{to_station} " , {user_id: time.time()}) return True , "排队成功" except RedisError as e: log.error(f"Redis 故障,降级到 MQ: {e} " ) mq.send('backup_queue_pending' , { 'train_id' : train_id, 'date' : date, 'from' : from_station, 'to' : to_station, 'user_id' : user_id, 'timestamp' : time.time() }) db.execute(""" INSERT INTO backup_order (user_id, train_id, travel_date, from_station, to_station, status) VALUES (?, ?, ?, ?, ?, 'PENDING') """ , user_id, train_id, date, from_station, to_station) return True , "排队成功,系统繁忙,名次稍后可查" def recover_redis_from_mq () : """ 后台任务:Redis 恢复后,从 MQ 恢复排队数据 关键:使用幂等操作,防止重复添加 """ messages = mq.consume('backup_queue_pending' , batch_size=100 ) for msg in messages: try : queue_key = f"backup:{msg['train_id' ]} :{msg['date' ]} :{msg['from' ]} :{msg['to' ]} " existing_score = redis.zscore(queue_key, msg['user_id' ]) if existing_score is not None : log.info(f"用户 {msg['user_id' ]} 已在队列中,跳过恢复" ) else : redis.zadd(queue_key, {msg['user_id' ]: msg['timestamp' ]}) db.execute(""" UPDATE backup_order SET status = 'QUEUED' WHERE user_id = ? AND train_id = ? AND travel_date = ? """ , msg['user_id' ], msg['train_id' ], msg['date' ]) except RedisError as e: log.error(f"Redis 仍未恢复,稍后重试: {e} " ) break
降级流程关键点 :
关键点
说明
幂等恢复
检查用户是否已在队列,避免重复添加或 Score 被更新
状态追踪
MySQL 记录 PENDING→QUEUED 状态变化,便于监控
批量消费
每次消费 100 条,避免单条失败阻塞整批
优雅退出
Redis 仍不可用时 break,保留未处理消息供下次恢复
七、总结与思考 7.1 12306 候补系统的设计精髓
设计点
核心思想
技术实现
席位复用
同一座位分段售卖,提升利用率
Redis Bitmap
分布式排队
按区间独立排队,公平精准
Redis Sorted Set
事件驱动
四类票源触发自动兑现
Kafka + 消费者模式
高并发架构
四级防护 + 限流降级
网关 + Caffeine + Redis + MySQL
一致性保证
本地消息表 + 最终一致
MySQL 事务 + MQ
并发控制
分布式锁 + 双重检查
Redis SET NX + Lua
容灾降级
MQ 暂存 + 幂等恢复
Kafka + 定时对账
7.2 为什么官方候补比第三方靠谱?
对比维度
官方候补
第三方抢票软件
数据源
直接操作席位数据,无延迟
轮询 12306 接口,有延迟
排队公平性
时间戳排序,先到先得
无法获取真实排队数据
票源覆盖
四类票源全覆盖,包括动态加挂
只能监控部分票源
系统稳定性
高并发架构 + 容灾降级
容易被 12306 限流封禁
7.3 可借鉴的架构思想
数据结构选型决定系统上限 :Bitmap 和 Sorted Set 的选择,让百万级数据依然高效
分而治之解决规模问题 :按区间独立排队,将全局问题拆解为局部问题
事件驱动解耦复杂逻辑 :退票、超时、改签、加挂,统一为事件,简化处理
四级防护扛住高并发 :网关限流 → 本地缓存 → Redis → MySQL,逐级降压
最终一致胜过强一致 :分布式系统中,最终一致更易实现,用户体验更好
幂等设计防止重复操作 :降级恢复时先检查再添加,避免数据错乱