设计抢票系统(12306)
每年春运,12306 都是全中国最难访问的网站之一。
开售前 5 分钟,你反复刷新页面——「余票不足」。
开售那一刻,你狂点购票——「系统正忙,请稍后」。
10 分钟后,你终于进去了,票没了。
这就是抢票系统的终极难题:如何在海量并发下,保证购票公平,同时系统不崩溃?
一、问题分析
1.1 抢票 vs 秒杀
┌─────────────────────────────────────────────────────┐
│ 抢票 vs 秒杀 │
├─────────────────────────────────────────────────────┤
│ │
│ 相同点 │
│ ├── 都是高并发场景 │
│ ├── 都需要防超卖 │
│ └── 都有限流保护 │
│ │
│ 不同点 │
│ ├── 票是动态的(随时可能退票、改签) │
│ ├── 座位选择(同一车次可能有上百种座位组合) │
│ ├── 路径规划(多站上车、跨车次换乘) │
│ └── 复杂的业务规则(学生票、儿童票等) │
│ │
└─────────────────────────────────────────────────────┘1.2 核心挑战
1. 库存复杂度:
- 一列火车 1000+ 座位
- 起点到终点之间的每个车站都有余票
- 座位在 A 站卖出后,B-C 站之间就不能再卖
2. 数据一致性:
- 分布式环境下,多台服务器可能同时卖出同一张票
- 需要分布式锁或乐观锁
3. 性能要求:
- 12306 峰值 QPS 超过 50 万
- 查询和下单混在一起,互相影响二、容量估算
2.1 数据规模
节假日抢票场景:
- 参与用户:千万级
- 峰值 QPS:50 万/秒(查询 + 下单)
- 车次数量:数万列
- 座位数量:每列 1000+ 座位
数据量:
- 车次表:10 万条
- 座位库存:每车次 1000 条座位状态
- 订单表:每年数亿订单2.2 存储设计
Redis 存储(高频访问):
- 余票数量:seats:available:{trainId}:{date}
- 座位状态:seat:status:{trainId}:{carriage}:{seatNo}
MySQL 存储(持久化):
- 车次信息表
- 订单表
- 用户表三、高层设计
┌──────────────────────────────────────────────────────────────────┐
│ 用户请求 │
│ 查询余票 / 购买车票 │
└──────────────────────────────────────────────────────────────────┘
│
┌───────────▼───────────┐
│ 12306 前端 │
│ (静态资源 CDN 加速) │
└───────────┬───────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
┌────────▼────────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ 查询服务集群 │ │ 下单服务集群 │ │ 支付服务集群 │
│ (CDN + Redis) │ │ (分布式锁) │ │ (一致性要求高) │
└────────┬────────┘ └───────┬───────┘ └───────────────┘
│ │
└─────────┬─────────┘
│
┌─────────────┼─────────────┐
│ │ │
┌──────▼──────┐ ┌───▼────┐ ┌─────▼─────┐
│ Redis集群 │ │ MQ │ │ MySQL集群 │
│ (余票缓存) │ │(异步下单)│ │ (订单存储) │
└─────────────┘ └────────┘ └───────────┘四、核心设计
4.1 座位库存模型
抢票最难的是座位库存。一个座位从 A 站到 B 站,中间不能被其他人购买:
java
/**
* 座位库存模型
*
* 问题:A 站→D 站卖了,B→C 还能卖吗?
* 答案是:不能,因为 B→C 的座位被 A→D 的乘客占用了
*
* 解决方案:区段锁
* - 将座位按区段划分
* - 如果 A→D 卖了,A-D 之间的所有区段都不能再卖
*/
public class TrainSeatService {
/**
* 座位区段模型
*
* 思路:把座位拆分成多个区段
* 例如:A-B, B-C, C-D 三个区段
* 如果卖 A-D,需要锁住 A-B, B-C, C-D 三个区段
*/
public static class SeatSection {
private String trainId;
private String carriageNo; // 车厢号
private String seatNo; // 座位号
private String startStation; // 出发站
private String endStation; // 终点站
// 区段内所有区段(A→D 包含 A-B, B-C, C-D)
public List<SeatSection> getSubSections() {
List<SeatSection> subSections = new ArrayList<>();
List<Station> stations = getStationList(startStation, endStation);
for (int i = 0; i < stations.size() - 1; i++) {
SeatSection section = new SeatSection();
section.setTrainId(trainId);
section.setCarriageNo(carriageNo);
section.setSeatNo(seatNo);
section.setStartStation(stations.get(i));
section.setEndStation(stations.get(i + 1));
subSections.add(section);
}
return subSections;
}
}
/**
* 检查座位是否可用
*
* @return true 表示可用,false 表示已被占用
*/
public boolean isSeatAvailable(String trainId, String carriageNo,
String seatNo, String fromStation, String toStation) {
// 1. 获取所有区段
List<SeatSection> sections = new SeatSection()
.setTrainId(trainId)
.setCarriageNo(carriageNo)
.setSeatNo(seatNo)
.setStartStation(fromStation)
.setEndStation(toStation)
.getSubSections();
// 2. 检查每个区段是否可用
for (SeatSection section : sections) {
String key = buildSeatKey(section);
Boolean available = redis.opsForValue().get(key);
if (Boolean.FALSE.equals(available)) {
return false; // 该区段已被占用
}
}
return true;
}
/**
* 锁定座位(多区段原子操作)
*
* 使用 Lua 脚本保证原子性
*/
public boolean lockSeat(String trainId, String carriageNo, String seatNo,
String fromStation, String toStation, String orderId) {
List<SeatSection> sections = new SeatSection()
.setTrainId(trainId)
.setStartStation(fromStation)
.setEndStation(toStation)
.getSubSections();
// Lua 脚本:批量设置区段为已锁定
String luaScript = """
for i, key in ipairs(KEYS) do
local current = redis.call('GET', key)
if current and current ~= ARGV[1] then
-- 已被其他人锁定
return 0
end
end
-- 所有区段都可锁定,批量设置
for i, key in ipairs(KEYS) do
redis.call('SET', key, ARGV[1])
redis.call('EXPIRE', key, 900) -- 15分钟锁定期
end
return 1
""";
List<String> keys = sections.stream()
.map(this::buildSeatKey)
.collect(Collectors.toList());
Long result = redis.execute(
new DefaultRedisScript<>(luaScript, Long.class),
keys,
orderId
);
return result != null && result == 1;
}
/**
* 解锁座位(释放锁或完成购买)
*/
public void unlockSeat(String trainId, String carriageNo, String seatNo,
String fromStation, String toStation) {
List<SeatSection> sections = new SeatSection()
.setTrainId(trainId)
.setStartStation(fromStation)
.setEndStation(toStation)
.getSubSections();
for (SeatSection section : sections) {
String key = buildSeatKey(section);
redis.delete(key);
}
}
private String buildSeatKey(SeatSection section) {
return String.format("seat:%s:%s:%s:%s-%s",
section.getTrainId(),
section.getCarriageNo(),
section.getSeatNo(),
section.getStartStation(),
section.getEndStation()
);
}
}4.2 分布式锁下单
java
/**
* 抢票下单服务
*
* 使用分布式锁保证同一用户不会重复下单
*/
public class TicketOrderService {
private TrainSeatService seatService;
private OrderMapper orderMapper;
private RedissonClient redisson;
/**
* 抢票下单
*/
@Transactional
public OrderResult grabTicket(GrabTicketRequest request) {
String userId = request.getUserId();
String trainId = request.getTrainId();
String fromStation = request.getFromStation();
String toStation = request.getToStation();
// 1. 分布式锁:防止同一用户重复下单
String lockKey = "lock:grab:" + userId + ":" + trainId;
RLock lock = redisson.getLock(lockKey);
try {
// 尝试获取锁,最多等待 0 秒,锁定 10 秒
if (!lock.tryLock(0, 10, TimeUnit.SECONDS)) {
return OrderResult.fail("操作太频繁,请稍后");
}
// 2. 检查是否已有未支付订单
if (hasUnpaidOrder(userId, trainId)) {
return OrderResult.fail("您有待支付订单,请先处理");
}
// 3. 选择可用座位
List<SeatInfo> availableSeats = seatService.findAvailableSeats(
trainId, fromStation, toStation
);
if (availableSeats.isEmpty()) {
return OrderResult.fail("暂无余票");
}
// 4. 尝试锁定座位
SeatInfo selectedSeat = availableSeats.get(0);
String orderId = generateOrderId();
boolean locked = seatService.lockSeat(
trainId,
selectedSeat.getCarriageNo(),
selectedSeat.getSeatNo(),
fromStation,
toStation,
orderId
);
if (!locked) {
return OrderResult.fail("座位已被抢占,请重试");
}
// 5. 创建订单(待支付状态)
Order order = createOrder(request, selectedSeat, orderId);
// 6. 发送延迟消息(15分钟不支付则取消)
delayQueue.sendCancelMessage(orderId, Duration.ofMinutes(15));
return OrderResult.success(order);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return OrderResult.fail("系统繁忙");
} finally {
lock.unlock();
}
}
/**
* 支付成功,确认座位
*/
public void confirmSeat(String orderId) {
Order order = orderMapper.selectById(orderId);
order.setStatus("已支付");
orderMapper.update(order);
// 删除延迟取消消息(座位已确认,不再取消)
delayQueue.removeCancelMessage(orderId);
}
/**
* 取消订单,释放座位
*/
public void cancelOrder(String orderId) {
Order order = orderMapper.selectById(orderId);
if ("已支付".equals(order.getStatus())) {
throw new BusinessException("已支付订单无法取消");
}
// 释放座位
seatService.unlockSeat(
order.getTrainId(),
order.getCarriageNo(),
order.getSeatNo(),
order.getFromStation(),
order.getToStation()
);
order.setStatus("已取消");
orderMapper.update(order);
}
}4.3 余票查询优化
java
/**
* 余票查询服务
*
* 查询是读多写少的场景,重点优化读取性能
*/
public class TicketQueryService {
private RedisTemplate<String, Object> redis;
/**
* 查询余票数量
*
* 为什么不用精确座位查询?
* - 用户只关心有没有票,不关心具体座位
* - 精确查询太慢,需要扫描所有座位
*/
public TicketCount queryAvailableCount(String trainId, String fromStation, String toStation) {
String cacheKey = "ticket:count:" + trainId + ":" + fromStation + ":" + toStation;
// 1. 先查缓存
Integer count = (Integer) redis.opsForValue().get(cacheKey);
if (count != null) {
return new TicketCount(count, true);
}
// 2. 缓存未命中,查询数据库
count = calculateAvailableCount(trainId, fromStation, toStation);
// 3. 回填缓存(短期过期,活动期间频繁更新)
redis.opsForValue().set(cacheKey, count, Duration.ofSeconds(30));
return new TicketCount(count, false);
}
/**
* 实时查询座位详情
*/
public List<SeatInfo> querySeatDetails(String trainId, String fromStation, String toStation) {
// 查询所有可用座位
return seatService.findAvailableSeats(trainId, fromStation, toStation);
}
/**
* 计算可用余票数
*
* 核心算法:
* 1. 遍历所有座位
* 2. 检查每个座位的所有区段是否可用
* 3. 统计可用区段数量
*/
private int calculateAvailableCount(String trainId, String fromStation, String toStation) {
// 简化实现:实际需要更复杂的算法
// 计算从 fromStation 到 toStation 之间的可用座位数
List<Station> route = getRoute(trainId);
int fromIndex = route.indexOf(fromStation);
int toIndex = route.indexOf(toStation);
int totalSeats = 1000; // 假设每列火车 1000 个座位
int bookedSeats = countBookedSeats(trainId, fromIndex, toIndex);
return totalSeats - bookedSeats;
}
}4.4 排队系统
java
/**
* 抢票排队系统
*
* 场景:高峰期请求过多,超出系统处理能力
* 解决方案:让用户排队,控制并发
*/
public class TicketQueueService {
private RedisTemplate<String, Object> redis;
private static final int MAX_CONCURRENT = 10000; // 同时处理的最大请求数
/**
* 加入排队队列
*
* @return 队列位置,null 表示直接可以购买
*/
public Integer joinQueue(String userId, String trainId) {
String queueKey = "queue:seats:" + trainId;
String positionKey = "queue:position:" + userId + ":" + trainId;
// 检查是否已经在队列中
Integer existing = (Integer) redis.opsForValue().get(positionKey);
if (existing != null) {
return existing;
}
// 获取当前队列长度
Long position = redis.opsForList().rightPush(queueKey, userId);
// 计算预估等待时间(粗略估算)
int estimatedWait = (int) (position / MAX_CONCURRENT) * 2; // 每万人次约2分钟
// 设置位置和过期时间
redis.opsForValue().set(positionKey, position.intValue(), Duration.ofMinutes(30));
return position.intValue();
}
/**
* 查询排队状态
*/
public QueueStatus checkQueueStatus(String userId, String trainId) {
String queueKey = "queue:seats:" + trainId;
// 获取队列长度
Long queueSize = redis.opsForList().size(queueKey);
// 获取用户当前位置
Long position = redis.opsForList().indexOf(queueKey, userId);
if (position == null || position < 0) {
// 不在队列中,说明已经处理过
return new QueueStatus(QueueState.CAN_ORDER, 0, 0);
}
// 判断是否轮到
if (position < MAX_CONCURRENT) {
return new QueueStatus(QueueState.CAN_ORDER, position.intValue(), 0);
} else {
int estimatedWait = (int) ((position - MAX_CONCURRENT) / MAX_CONCURRENT) * 2;
return new QueueStatus(QueueState.WAITING, position.intValue(), estimatedWait);
}
}
/**
* 离开队列(下单成功后调用)
*/
public void leaveQueue(String userId, String trainId) {
String queueKey = "queue:seats:" + trainId;
redis.opsForList().remove(queueKey, 1, userId);
}
}
/**
* 队列状态
*/
public record QueueStatus(QueueState state, int position, int estimatedWaitMinutes) {}
public enum QueueState {
CAN_ORDER, // 可以下单
WAITING, // 等待中
}五、延伸问题
问题一:如何防止黄牛党?
方案:
1. 实名制购票:一人一证一票
2. 购票限制:每张身份证每天最多购买 N 张票
3. 验证码:购票时需要识别图形验证码
4. 风控系统:识别异常账号和 IP
5. 侯补购票:官方候补,挤压黄牛空间问题二:如何处理退票和改签?
退票:
1. 用户退票 → 释放座位 → 座位变为可用
2. 侯补队列中有用户 → 自动分配
改签:
1. 原座位释放
2. 目标车次检查余票
3. 锁定新座位
4. 差价退还/补缴问题三:如何保证高可用?
方案:
1. 读写分离:查询走从库,下单走主库
2. 热点隔离:抢票高峰期,临时扩容
3. 降级熔断:系统过载时,降级非核心功能
4. 多级缓存:Redis + 本地缓存 + CDN六、总结
┌─────────────────────────────────────────────────────┐
│ 抢票系统核心知识点 │
├─────────────────────────────────────────────────────┤
│ │
│ 座位模型 │
│ ├── 区段锁:解决多区段复用问题 │
│ └── Lua 脚本:批量锁的原子性 │
│ │
│ 下单流程 │
│ ├── 分布式锁:防止重复下单 │
│ ├── 座位锁定:15 分钟有效期 │
│ └── 延迟队列:超时自动释放 │
│ │
│ 性能优化 │
│ ├── 余票缓存:减少数据库查询 │
│ ├── 排队系统:控制并发压力 │
│ └── 读写分离:查询和下单分离 │
│ │
└─────────────────────────────────────────────────────┘面试加分点:
- 能解释区段锁的原理
- 能画出完整的购票流程图
- 能说出座位超卖的原因和解决方案
- 能分析 12306 为什么这么难做
