魔物公寓
45.13M · 2026-03-13
春运期间 12306 的 QPS 动辄几十万,“抢票不超卖、座位不重复、响应不卡顿” 是核心痛点。本文基于仿 12306 实战项目,拆解高并发购票系统的 6 大核心环节 —— 从前置校验到弹性令牌桶限流,从 Lua 原子锁座到 MQ 异步落库,再到 Canal 兜底数据一致性,全程围绕 “高性能 + 高可用 + 数据准确” 三大目标展开,看完就能理解大厂高并发场景的核心设计思路。
12306 购票的核心矛盾是:用户要 “快响应”,系统要 “数据准”,高并发要 “不崩溃” 。因此整体流程遵循「先拦截无效请求→再控并发流量→原子锁定核心资源→快速响应用户→异步兜底数据→闭环收尾」的思路,6 个核心环节环环相扣,既保证性能,又守住数据准确性底线。
高并发系统的核心原则是:能在早期拦截的请求,绝不放到核心流程。购票请求的前置校验采用「责任链模式」分层校验,全程无锁、无复杂计算,快速过滤无效请求:
flowchart TD
A[开始购票请求] -->B[前置校验]
B --> C[从 Redis 弹性令牌桶获取令牌]
C --> D{令牌获取成功?}
D -->|是| E[Lua 原子扣减令牌]
E --> F[进入座位锁定流程]
F --> G{锁座时还有票?}
G -->|是| H[继续流程]
G -->|否| I[进入候补队列]
D -->|否| J[返回暂无余票]
前置校验通过后,需解决 “高并发冲垮系统” 的问题,同时避免 “令牌被占但票没卖出” 的资源浪费,因此基于 Redis Hash 设计弹性令牌桶:
出发站_终点站_座位类型(如北京_上海_二等座);对应余票数量×1.2(适度超发,应对用户取消购票、支付超时导致的少卖票问题);超发令牌后必然出现 “用户拿到令牌但实际无票” 的情况,需做好兜底,避免用户体验差:
限流通过后,进入最核心的 “座位锁定” 环节 —— 座位是稀缺资源,必须保证并发下唯一分配,因此把「座位获取 + 锁定」全部放到 Redis Lua 脚本中原子执行,Java 程序仅负责传递参数和接收结果:
总量校验:先查询 Redis 中train:stock:{车次}:{座位类型}(如train:stock:G1:二等座),若值为 0,直接返回 “无票”;
筛选 + 校验偏好座位:
SMEMBERS train:free_seats:{车次}:{座位类型}:{偏好位}(如train:free_seats:G1:二等座:A),获取空闲偏好座位列表;train:seat_info:{车次}中该座位状态,双重校验是否为 “free”;锁定座位 + 返回结果:
locked:temp(临时锁定,仅 Redis 层面),并扣减train:stock总量;用户最反感 “点击抢票后转圈等半天”,因此核心设计是「牺牲实时落库,换取用户体验」:
order:temp:{用户ID}:{订单临时ID},存储用户 ID、车次、座位号、锁定时间、10 分钟支付超时时间;主流程返回用户后,通过 “线程池 + RocketMQ” 异步处理 DB 落库,避免主流程阻塞:
发送 MQ 消息:主线程将 “创建正式订单” 任务提交至自定义线程池,线程池向 RocketMQ 发送消息(带唯一标识防重复消费、开启延迟重试保证投递);
MQ 消费端执行 DB 操作:消费端消息后,通过 500ms 短事务(避免占用 DB 连接)原子完成「创建待支付正式订单 + 更新座位表状态为锁定(关联订单 ID)」;若操作失败,消息重新入队重试(默认 16 次),仍失败则进入死信队列;
Canal 异步更新 Redis:
locked:temp升级为locked:confirmed(确认锁定,DB 已落地),标记预订单 “已落库” 或直接删除;很多同学会疑惑:Lua 已经把 Redis 座位标为 locked,为什么 Canal 还要再更一次?核心是分层防御 + 数据一致性兜底:
| 标记时机 | 状态值 | 核心作用 |
|---|---|---|
| Lua 脚本锁定时 | locked:temp | 业务层临时锁定,防止并发抢座 |
| Canal Binlog 后 | locked:confirmed | 数据层确认锁定,校准 Redis 与 DB 状态 |
举两个极端例子更易理解:
locked:temp成功,但 MQ 发送失败导致 DB 没落库→Canal 不会确认锁定,超时检查机制发现 “Redis locked 但无 DB 订单”,主动把 Redis 状态改回 free,座位重新可售;locked:temp失败,但 MQ 落库成功→Canal 发现 DB 是 locked 但 Redis 是 free,主动把 Redis 改成locked:confirmed,避免重复抢座。座位锁定不是终点,必须通过 “支付确认” 或 “超时释放” 完成闭环,避免座位被长期占用:
train:free_seats的记录,更新train:seat_info状态为 “sold”。下单时发送 RocketMQ 延迟消息(10 分钟延迟),触发超时检查;
消费端校验订单状态:若仍为 “待支付”,执行兜底释放:
train:free_seats集合,状态改回 free,令牌桶总量加回;这套设计不仅适用于 12306,还能迁移到电商秒杀、抢券、预约等高并发场景 —— 核心逻辑都是 “稀缺资源的原子分配 + 高性能响应 + 数据最终一致”。
如果觉得本文对你有帮助,欢迎点攒 + 收藏 + 关注~你觉得 12306 还有哪些设计难点?评论区一起交流!