大家好,我是一名刚入坑的新人博主!这是我在掘金发表的第一篇技术文章,有点小紧张~
最近在学习 仿 12306 高并发购票系统 实战项目,把自己梳理了很久的完整购票核心流程 整理出来,分享给正在做同类项目、准备面试、或者想学习高并发设计的同学。因为是第一次写文章,内容难免有不足或理解不到位的地方,欢迎大佬们在评论区指正、交流、一起探讨! 你们的每一条留言我都会认真看,一起进步~
下面直接进入正文,全程干货、无废话、纯业务逻辑讲解👇
春运期间 12306 的 QPS 动辄几十万,"抢票不超卖、座位不重复、响应不卡顿" 是核心痛点。本文基于仿 12306 实战项目,拆解高并发购票系统的 6 大核心环节 ------ 从前置校验到弹性令牌桶限流,从 Lua 原子锁座到 MQ 异步落库,再到 Canal 兜底数据一致性,全程围绕 "高性能 + 高可用 + 数据准确" 三大目标展开,看完就能理解大厂高并发场景的核心设计思路。
一、整体设计思路:先快后慢,异步解耦
12306 购票的核心矛盾是:用户要 "快响应",系统要 "数据准",高并发要 "不崩溃" 。因此整体流程遵循「先拦截无效请求→再控并发流量→原子锁定核心资源→快速响应用户→异步兜底数据→闭环收尾」的思路,6 个核心环节环环相扣,既保证性能,又守住数据准确性底线。
二、核心环节拆解:从请求进入到订单闭环
2.1 第一道防线:前置校验 ------ 筛掉所有无效请求
高并发系统的核心原则是:能在早期拦截的请求,绝不放到核心流程。购票请求的前置校验采用「责任链模式」分层校验,全程无锁、无复杂计算,快速过滤无效请求:
- 参数完整性校验:车次 ID、乘车人、出发 / 到达站点等核心参数不能为空,空参数直接返回 "请求参数异常",避免无效请求占用后续资源;
- 参数有效性校验:校验车次 ID 是否存在、起止站点是否在该车次运行区间内(比如 G1 次只走北京 - 上海,不能买北京 - 广州);
- 业务规则校验:核心是 "防重复购票"------ 同一乘客不能购买同一车次的票,也不能购买当天时间冲突的车次票(比如同一人买了 G1 08:00 北京→上海,就不能再买 G2 09:00 北京→南京)。
补充:座位分配逻辑验证通过后,若用户选购多个座位,会采用「就近原则」分配(比如优先分配同一排、相邻的座位),提升用户体验。
2.2 流量闸门:弹性令牌桶限流 ------ 控住几十万 TPS 的并发
前置校验通过后,需解决 "高并发冲垮系统" 的问题,同时避免 "令牌被占但票没卖出" 的资源浪费,因此基于 Redis Hash 设计弹性令牌桶:
2.2.1 令牌桶核心设计
- 令牌桶 Key 设计:
出发站_终点站_座位类型(如北京_上海_二等座); - 令牌桶 Value 设计:
对应余票数量×1.2(适度超发,应对用户取消购票、支付超时导致的少卖票问题); - 令牌获取规则:用户购票时,系统先根据购票数量从令牌桶尝试获取对应令牌,获取成功则用 Lua 脚本原子扣减令牌(先校验余量,满足再扣减),扣减成功进入下一步;获取失败直接返回失败。
2.2.2 超发令牌的兜底处理
超发令牌后必然出现 "用户拿到令牌但实际无票" 的情况,需做好兜底,避免用户体验差:
- 当用户拿到令牌,但 Lua 脚本校验无票时,返回「候补购票」选项(而非直接 "无票");
- 将用户请求加入「候补队列」,当有座位释放(取消 / 超时)时,按队列顺序自动触发购票流程;
- 同步推送通知:"已进入候补队列,有票会自动为您购票",兼顾体验和转化。
2.3 核心环节:Lua 原子锁座 ------ 保证座位不重复抢占
限流通过后,进入最核心的 "座位锁定" 环节 ------ 座位是稀缺资源,必须保证并发下唯一分配,因此把「座位获取 + 锁定」全部放到 Redis Lua 脚本中原子执行,Java 程序仅负责传递参数和接收结果:
2.3.1 Lua 脚本核心逻辑
-
总量校验 :先查询 Redis 中
train:stock:{车次}:{座位类型}(如train:stock:G1:二等座),若值为 0,直接返回 "无票"; -
筛选 + 校验偏好座位:
- 调用
SMEMBERS train:free_seats:{车次}:{座位类型}:{偏好位}(如train:free_seats:G1:二等座:A),获取空闲偏好座位列表; - 列表非空则半随机选一个座位号(如 01 车 01A),为空则触发随机选座;
- 查询 Redis Hash
train:seat_info:{车次}中该座位状态,双重校验是否为 "free";
- 调用
-
锁定座位 + 返回结果:
- 校验通过后,原子更新座位状态为
locked:temp(临时锁定,仅 Redis 层面),并扣减train:stock总量; - Java 程序接收座位号后,给该座位加 Redisson 分布式锁(保证业务层面排他性)。
- 校验通过后,原子更新座位状态为
2.4 体验优化:快速响应用户 ------ 先给结果,再处理数据
用户最反感 "点击抢票后转圈等半天",因此核心设计是「牺牲实时落库,换取用户体验」:
- 创建 Redis 预订单 :在 Redis 中创建 Hash 类型的 "待支付订单",Key 为
order:temp:{用户ID}:{订单临时ID},存储用户 ID、车次、座位号、锁定时间、10 分钟支付超时时间; - 立即返回前端:同步返回 "抢票成功,请在 10 分钟内完成支付,座位号:01 车 01A",不等待 DB 操作,响应时间控制在 100ms 内。
2.5 数据兜底:MQ 异步落库 ------ 解耦主流程,保证数据可靠
主流程返回用户后,通过 "线程池 + RocketMQ" 异步处理 DB 落库,避免主流程阻塞:
2.5.1 异步落库核心步骤
-
发送 MQ 消息:主线程将 "创建正式订单" 任务提交至自定义线程池,线程池向 RocketMQ 发送消息(带唯一标识防重复消费、开启延迟重试保证投递);
-
MQ 消费端执行 DB 操作:消费端监听消息后,通过 500ms 短事务(避免占用 DB 连接)原子完成「创建待支付正式订单 + 更新座位表状态为锁定(关联订单 ID)」;若操作失败,消息重新入队重试(默认 16 次),仍失败则进入死信队列;
-
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 支付成功流程
- 支付回调接口接收到成功通知后,先更新 Redis 订单状态为 "已支付";
- 发送 MQ 消息,消费端更新 DB:订单状态→已支付,座位状态→已售出;
- 释放 Redisson 分布式锁,删除 Redis 中该座位在
train:free_seats的记录,更新train:seat_info状态为 "sold"。
2.6.2 超时 / 取消支付流程
-
下单时发送 RocketMQ 延迟消息(10 分钟延迟),触发超时检查;
-
消费端校验订单状态:若仍为 "待支付",执行兜底释放:
- DB 层面:订单→已取消,座位→空闲;
- Redis 层面:座位重新加入
train:free_seats集合,状态改回 free,令牌桶总量加回; - 释放 Redisson 分布式锁。
三、核心设计原则总结
- 分层防御:前置校验拦无效请求,令牌桶控流量,Lua 锁核心资源,层层过滤避免核心环节承压;
- 弹性限流:令牌桶适度超发(1.2 倍),既控并发峰值,又减少用户取消导致的资源浪费;
- 原子性优先:核心操作(令牌扣减、座位锁定)原子化,避免并发下数据不一致;
- 先快后慢:Redis 负责高性能读写(快速响应用户),DB 负责持久化(异步兜底);
- 数据一致性兜底:以 DB 为最终基准,Canal 校准 Redis 状态,避免极端场景下的状态不一致;
- 闭环思维:所有锁定操作均有超时释放 / 失败回滚机制,避免资源泄漏。
四、延伸思考
- 弹性令牌桶的 1.2 倍系数可动态调整:基于历史取消率实时计算(取消率高则系数调高,反之调低);
- 可引入 Seata 分布式事务,处理 "支付成功但 DB 更新失败" 的极端场景;
- Redis Cluster 可做令牌桶分片,应对超大规模车次的限流需求。
写在最后
这套设计不仅适用于 12306,还能迁移到电商秒杀、抢券、预约等高并发场景 ------ 核心逻辑都是 "稀缺资源的原子分配 + 高性能响应 + 数据最终一致"。
如果觉得本文对你有帮助,欢迎点赞 + 收藏 + 关注~你觉得 12306 还有哪些设计难点?评论区一起交流!