仿 12306 高并发购票系统:抢票下单逻辑设计

大家好,我是一名刚入坑的新人博主!这是我在掘金发表的第一篇技术文章,有点小紧张~

最近在学习 仿 12306 高并发购票系统 实战项目,把自己梳理了很久的完整购票核心流程 整理出来,分享给正在做同类项目、准备面试、或者想学习高并发设计的同学。因为是第一次写文章,内容难免有不足或理解不到位的地方,欢迎大佬们在评论区指正、交流、一起探讨! 你们的每一条留言我都会认真看,一起进步~

下面直接进入正文,全程干货、无废话、纯业务逻辑讲解👇

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

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

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

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

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

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

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

补充:座位分配逻辑验证通过后,若用户选购多个座位,会采用「就近原则」分配(比如优先分配同一排、相邻的座位),提升用户体验。

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 还有哪些设计难点?评论区一起交流!

相关推荐
佩奇大王2 小时前
P8 单词分析
java·开发语言
PPPPickup2 小时前
小公司初面---java后端题目
java·开发语言·哈希算法
乄bluefox2 小时前
Redis Pipeline 实战:Spring Data Redis 批量写入最佳实践
java·redis·spring
敲代码的嘎仔2 小时前
Java后端开发——基础面试题汇总
java·开发语言·笔记·后端·学习·spring·中间件
Albert Edison2 小时前
【ProtoBuf 语法详解】enum 类型
java·linux·服务器
花间相见2 小时前
【JAVA基础01】——类和对象
java·开发语言·python
在等晚安么2 小时前
每日八股文
java·八股
lclcooky2 小时前
Spring中的IOC详解
java·后端·spring
GIOTTO情2 小时前
2026小红书投流新规下,基于Infoseek API的媒介投放自动化方案
java·linux·开发语言