春运期间 12306 的 QPS 动辄几十万,“抢票不超卖、座位不重复、响应不卡顿” 是核心痛点。本文基于仿 12306 实战项目,拆解高并发购票系统的 6 大核心环节 —— 从前置校验到弹性令牌桶限流,从 Lua 原子锁座到 MQ 异步落库,再到 Canal 兜底数据一致性,全程围绕 “高性能 + 高可用 + 数据准确” 三大目标展开,看完就能理解大厂高并发场景的核心设计思路。

一、整体设计思路:先快后慢,异步解耦

12306 购票的核心矛盾是:用户要 “快响应”,系统要 “数据准”,高并发要 “不崩溃” 。因此整体流程遵循「先拦截无效请求→再控并发流量→原子锁定核心资源→快速响应用户→异步兜底数据→闭环收尾」的思路,6 个核心环节环环相扣,既保证性能,又守住数据准确性底线。

二、核心环节拆解:从请求进入到订单闭环

2.1 第一道防线:前置校验 —— 筛掉所有无效请求

高并发系统的核心原则是:能在早期拦截的请求,绝不放到核心流程。购票请求的前置校验采用「责任链模式」分层校验,全程无锁、无复杂计算,快速过滤无效请求:

  1. 参数完整性校验:车次 ID、乘车人、出发 / 到达站点等核心参数不能为空,空参数直接返回 “请求参数异常”,避免无效请求占用后续资源;
  2. 参数有效性校验:校验车次 ID 是否存在、起止站点是否在该车次运行区间内(比如 G1 次只走北京 - 上海,不能买北京 - 广州);
  3. 业务规则校验:核心是 “防重复购票”—— 同一乘客不能购买同一车次的票,也不能购买当天时间冲突的车次票(比如同一人买了 G1 08:00 北京→上海,就不能再买 G2 09:00 北京→n京)。

2.2 流量闸门:弹性令牌桶限流 —— 控住几十万 TPS 的并发

flowchart TD 
A[开始购票请求] -->B[前置校验] 
B --> C[从 Redis 弹性令牌桶获取令牌] 
C --> D{令牌获取成功?} 
D -->|是| E[Lua 原子扣减令牌] 
E --> F[进入座位锁定流程] 
F --> G{锁座时还有票?} 
G -->|是| H[继续流程] 
G -->|否| I[进入候补队列] 
D -->|否| J[返回暂无余票]

前置校验通过后,需解决 “高并发冲垮系统” 的问题,同时避免 “令牌被占但票没卖出” 的资源浪费,因此基于 Redis Hash 设计弹性令牌桶

2.2.1 令牌桶核心设计

  • 令牌桶 Key 设计:出发站_终点站_座位类型(如北京_上海_二等座);
  • 令牌桶 Value 设计:对应余票数量×1.2(适度超发,应对用户取消购票、支付超时导致的少卖票问题);
  • 令牌获取规则:用户购票时,系统先根据购票数量从令牌桶尝试获取对应令牌,获取成功则用 Lua 脚本原子扣减令牌(先校验余量,满足再扣减),扣减成功进入下一步;获取失败直接返回失败。

2.2.2 超发令牌的兜底处理

超发令牌后必然出现 “用户拿到令牌但实际无票” 的情况,需做好兜底,避免用户体验差:

  1. 当用户拿到令牌,但 Lua 脚本校验无票时,返回「候补购票」选项(而非直接 “无票”);
  2. 将用户请求加入「候补队列」,当有座位释放(取消 / 超时)时,按队列顺序自动触发购票流程;
  3. 同步推送通知:“已进入候补队列,有票会自动为您购票”,兼顾体验和转化。

2.3 核心环节:Lua 原子锁座 —— 保证座位不重复抢占

限流通过后,进入最核心的 “座位锁定” 环节 —— 座位是稀缺资源,必须保证并发下唯一分配,因此把「座位获取 + 锁定」全部放到 Redis Lua 脚本中原子执行,Java 程序仅负责传递参数和接收结果:

2.3.1 Lua 脚本核心逻辑

  1. 总量校验:先查询 Redis 中train:stock:{车次}:{座位类型}(如train:stock:G1:二等座),若值为 0,直接返回 “无票”;

  2. 筛选 + 校验偏好座位

    • 调用SMEMBERS train:free_seats:{车次}:{座位类型}:{偏好位}(如train:free_seats:G1:二等座:A),获取空闲偏好座位列表;
    • 列表非空则半随机选一个座位号(如 01 车 01A),为空则触发随机选座;
    • 查询 Redis Hashtrain:seat_info:{车次}中该座位状态,双重校验是否为 “free”;
  3. 锁定座位 + 返回结果

    • 校验通过后,原子更新座位状态为locked:temp(临时锁定,仅 Redis 层面),并扣减train:stock总量;
    • Java 程序接收座位号后,给该座位加 Redisson 分布式锁(保证业务层面排他性)。

2.4 体验优化:快速响应用户 —— 先给结果,再处理数据

用户最反感 “点击抢票后转圈等半天”,因此核心设计是「牺牲实时落库,换取用户体验」:

  1. 创建 Redis 预订单:在 Redis 中创建 Hash 类型的 “待支付订单”,Key 为order:temp:{用户ID}:{订单临时ID},存储用户 ID、车次、座位号、锁定时间、10 分钟支付超时时间;
  2. 立即返回前端:同步返回 “抢票成功,请在 10 分钟内完成支付,座位号:01 车 01A”,不等待 DB 操作,响应时间控制在 100ms 内。

2.5 数据兜底:MQ 异步落库 —— 解耦主流程,保证数据可靠

主流程返回用户后,通过 “线程池 + RocketMQ” 异步处理 DB 落库,避免主流程阻塞:

2.5.1 异步落库核心步骤

  1. 发送 MQ 消息:主线程将 “创建正式订单” 任务提交至自定义线程池,线程池向 RocketMQ 发送消息(带唯一标识防重复消费、开启延迟重试保证投递);

  2. MQ 消费端执行 DB 操作:消费端消息后,通过 500ms 短事务(避免占用 DB 连接)原子完成「创建待支付正式订单 + 更新座位表状态为锁定(关联订单 ID)」;若操作失败,消息重新入队重试(默认 16 次),仍失败则进入死信队列;

  3. Canal 异步更新 Redis

    • DB 操作成功后生成 Binlog,Canal 并解析出座位 / 订单状态变更;
    • 异步更新 Redis:将座位状态从locked:temp升级为locked:confirmed(确认锁定,DB 已落地),标记预订单 “已落库” 或直接删除;
    • Canal 配置:失败重试 3 次 + 幂等更新(以座位号 + 操作类型为唯一键),避免 Redis 更新遗漏 / 重复。

2.5.2 关键答疑:为什么要两次标记 “locked”?

很多同学会疑惑:Lua 已经把 Redis 座位标为 locked,为什么 Canal 还要再更一次?核心是分层防御 + 数据一致性兜底

标记时机状态值核心作用
Lua 脚本锁定时locked:temp业务层临时锁定,防止并发抢座
Canal Binlog 后locked:confirmed数据层确认锁定,校准 Redis 与 DB 状态

举两个极端例子更易理解

  • 例子 1(MQ 落库失败):Lua 标记locked:temp成功,但 MQ 发送失败导致 DB 没落库→Canal 不会确认锁定,超时检查机制发现 “Redis locked 但无 DB 订单”,主动把 Redis 状态改回 free,座位重新可售;
  • 例子 2(Redis 卡顿):Lua 标记locked:temp失败,但 MQ 落库成功→Canal 发现 DB 是 locked 但 Redis 是 free,主动把 Redis 改成locked:confirmed,避免重复抢座。

2.6 闭环收尾:支付 / 超时处理 —— 避免资源长期占用

座位锁定不是终点,必须通过 “支付确认” 或 “超时释放” 完成闭环,避免座位被长期占用:

2.6.1 支付成功流程

  1. 支付回调接口接收到成功通知后,先更新 Redis 订单状态为 “已支付”;
  2. 发送 MQ 消息,消费端更新 DB:订单状态→已支付,座位状态→已售出;
  3. 释放 Redisson 分布式锁,删除 Redis 中该座位在train:free_seats的记录,更新train:seat_info状态为 “sold”。

2.6.2 超时 / 取消支付流程

  1. 下单时发送 RocketMQ 延迟消息(10 分钟延迟),触发超时检查;

  2. 消费端校验订单状态:若仍为 “待支付”,执行兜底释放:

    • DB 层面:订单→已取消,座位→空闲;
    • Redis 层面:座位重新加入train:free_seats集合,状态改回 free,令牌桶总量加回;
    • 释放 Redisson 分布式锁。

三、核心设计原则总结

  1. 分层防御:前置校验拦无效请求,令牌桶控流量,Lua 锁核心资源,层层过滤避免核心环节承压;
  2. 弹性限流:令牌桶适度超发(1.2 倍),既控并发峰值,又减少用户取消导致的资源浪费;
  3. 原子性优先:核心操作(令牌扣减、座位锁定)原子化,避免并发下数据不一致;
  4. 先快后慢:Redis 负责高性能读写(快速响应用户),DB 负责持久化(异步兜底);
  5. 数据一致性兜底:以 DB 为最终基准,Canal 校准 Redis 状态,避免极端场景下的状态不一致;
  6. 闭环思维:所有锁定操作均有超时释放 / 失败回滚机制,避免资源泄漏。

四、延伸思考

  1. 弹性令牌桶的 1.2 倍系数可动态调整:基于历史取消率实时计算(取消率高则系数调高,反之调低);
  2. 可引入 Seata 分布式事务,处理 “支付成功但 DB 更新失败” 的极端场景;
  3. Redis Cluster 可做令牌桶分片,应对超大规模车次的限流需求。

写在最后

这套设计不仅适用于 12306,还能迁移到电商秒杀、抢券、预约等高并发场景 —— 核心逻辑都是 “稀缺资源的原子分配 + 高性能响应 + 数据最终一致”。

如果觉得本文对你有帮助,欢迎点攒 + 收藏 + 关注~你觉得 12306 还有哪些设计难点?评论区一起交流!

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:alixiixcom@163.com