做机票+火车票聚合查询与预订系统,难的不是把航班和车次拼到一个列表里,而是要把两套完全不同的数据源、交易链路和业务规则,揉成一个对用户“无感”的出行产品。我复盘了从0到1搭建这套系统的全过程,包含技术选型、接口适配、缓存设计、订单状态机以及上线后踩过的那些坑,这篇内容适合正在做或准备做出行类聚合平台的开发者,也适合对“多数据源一致性”这类问题感兴趣的架构师。
这个项目做下来,最大的体感是:聚合系统70%的工作量都在处理“不确定性”。供应商接口不稳定、返回字段不一致、价格频繁变动、库存实时变化,任何一环出问题,用户看到的就是“查不到票”或者“下单失败”。所以这篇文章的核心不只是讲架构,更想分享我在实际工程里怎么用一套相对稳的方式,把这些不确定性兜住。
1. 先想清楚:聚合系统到底在解决什么问题
1.1 用户视角的真实找票场景
很多人会觉得,聚合查询不就是把两个渠道的接口结果拼在一起吗?真不是。我举个例子:用户要从A城去B城,他的真实需求是“我怎么走最合适”,而不是“我要买机票”或者“我要买火车票”。他可能早上打开App看到一张早班高铁票,价格合适、时间合适,就买了;也可能发现高铁票没了,转而看看航班,发现机票比高铁贵不了多少,顺手就下了单。
这就暴露出两个核心问题:
第一,数据源差异巨大。机票本质上是“航线+舱位+票价规则”的组合,一个航班在不同时段、不同舱位下的价格差异可以非常大,而且包含燃油费、机建费、退改规则等附属信息。火车票则是“车次+座位等级+余票”的组合,价格相对稳定,但库存情况波动剧烈,尤其在节假日。把这两类数据放进同一个列表展示,需要抽象出一套统一的出行产品模型。
第二,用户对时效性极度敏感。机票和火车票都是强时效商品,价格可能几分钟就变一次,余票可能一眨眼就没了。系统如果只做简单的接口透传,查询慢、数据旧,用户的体验会非常差;但如果盲目加缓存,又会面临价格过期、无票显示有票的风险。
1.2 产品定义与核心链路
所以我们最初就把产品定义成四个字:查得全、订得稳。“查得全”是指一次检索能覆盖主流机票渠道和铁路渠道,并且能在同一屏展示不同交通方式的组合方案;“订得稳”是指从用户选定方案到支付出票,整条交易链路要做到状态可追踪、失败可补偿。
基于这个定义,核心链路就这么定了:查询 → 比价 → 下单 → 支付 → 出票 → 售后。后面的所有设计和开发,都是围绕这条链路展开的。
这里有个值得分享的经验:不要把“聚合”想成是技术层面的接口拼接,它本质上是交易层面的流程再造。机票和火车票的预订流程差异很大,机票是“先占座再支付、出票有延迟”,火车票是“支付后锁定座位、出票基本即时”,系统必须在这两种模式之间做适配,才能让用户感觉是在同一个平台完成全流程。
2. 整体设计与技术选型复盘
2.1 模块划分:接入、聚合、交易三层
系统整体采用三层架构,这个结构是所有后续设计的基础。
第一层是接入层,负责对接所有上游供应商。包括航司直连通道、航空分销平台接口、铁路官方售票系统,以及部分第三方比价数据源。这一层的核心职责是“翻译”——把不同供应商的协议、字段、鉴权方式,转换成系统内部的统一格式。
第二层是聚合层,负责查询编排、结果合并、去重排序、缓存管理。这一层是系统的“大脑”,用户每次搜索都会经过这里。聚合层不关心具体供应商长什么样,它只面向接入层提供的统一接口做编排。
第三层是交易层,负责下单、支付、出票、退改、订单管理。这一层和普通电商交易系统类似,但因为涉及出票这种强外部依赖,状态机的设计要复杂得多。
三层之间通过内部API通信,数据模型完全隔离。这样做的好处是:某一家供应商接口变动,只需要改接入层的适配器,聚合层和交易层完全不受影响。
2.2 后端技术栈与中间件的选择
技术选型上,我最终选了Java生态,Spring Boot作为主框架,配合MyBatis操作MySQL,Redis做缓存和分布式锁,RabbitMQ做异步消息,XXL-Job做定时任务,Nacos做配置和注册中心。
为什么选Java而不是Go或者其他语言?原因有三点:
一是团队对Java最熟,招聘和协作成本低。二是在交易类系统里,Java的生态成熟度确实是最高的,支付、订单、对账、风控都有大量现成方案可以参考。三是Spring Boot对多数据源、事务管理、消息队列的集成做得非常顺滑,能少写很多基础设施代码。
中间件的选择上,Redis和RabbitMQ是核心。Redis承担了热点数据的缓存和订单防重的分布式锁,RabbitMQ用于处理出票结果异步通知和支付回调消息。MySQL作为主存储,订单表、支付流水表、产品快照表都在这里。之所以加一层“产品快照表”,是因为用户下单时的价格和库存,必须在下单成功后固定下来,不能等出票时再回头查供应商,否则很容易出现用户付了钱、供应商说没票的情况。
2.3 统一出行产品模型怎么设计
聚合查询最难的部分,是定义统一的出行产品模型。机票和火车票的字段差异太大,比如机票有“航站楼”“舱位代码”“机型”,火车票有“席别”“铺位”“车次类型”。如果把这些字段全部平铺到一个表里,会有大量空字段,维护起来非常痛苦。
我的做法是只保留公共核心字段,把差异字段放进扩展信息JSON里。核心字段包括:
- transportType:交通方式,枚举值AIR或者RAIL
- voyageNo:航班号或者车次号
- departStation、arriveStation:出发地和到达地
- departTime、arriveTime:出发和到达时间
- cabinClass:舱位等级或者席别
- price:统一展示价
- inventory:可用库存
- supplierCode:数据来源标识
扩展信息里则各自存放特有属性,比如机票的退改规则、行李额,火车票的抢票状态、候补标识等。查询时,聚合层统一填充核心字段;用户点进详情页时,再根据transportType加载对应的扩展信息。
这个模型的好处是:前端列表页只需要关心核心字段,渲染逻辑非常简单;详情页和下单逻辑也只需要根据交通方式分流,不需要写一堆if else判断字段是否存在。
2.4 多供应商接入的适配器实践
接入层我用了适配器模式,为每一类供应商定义一个统一的Adapter接口,接口方法包括查询、验价、下单、取消、查询订单状态。每个供应商实现一套适配器,内部完成协议转换、签名、加解密、错误码映射。
这里有一个非常关键的实践:供应商的错误码必须统一映射成系统内部错误码。比如有的供应商用“1”表示成功,“0”表示失败;有的用“200”表示成功,“500”表示失败;还有的会在成功响应里包一个业务失败码。如果不在接入层做统一转换,聚合层和交易层就要面对各种千奇百怪的判断逻辑,代码会迅速腐化。
我在适配器里还做了一件事:屏蔽供应商的“伪成功”响应。有些供应商在系统繁忙时会直接返回“系统繁忙,请稍后再试”,但它把HTTP状态码设置成200。如果接入层不做拦截,聚合层会把这次异常当成一次正常的数据返回,结果就是用户看到列表里少了几趟车,还不知道为什么。所以我要求所有适配器必须做“业务成功校验”,校验不通过直接抛自定义异常。
3. 查询链路与缓存策略实操
3.1 一次聚合查询的完整流转
用户从前端发起一次查询请求后,后端大致经历这么几步:
参数校验,确认出发地、到达地、日期、乘客类型等基础信息合法。然后进入查询决策逻辑,系统会判断:这个请求应该查哪些供应商?是走缓存还是实时拉取?还是缓存和实时并行?
这一步非常关键,不是所有请求都需要实时查询。对于当日和次日的班次,价格和库存变动快,必须实时查;对于远期班次,可以优先读缓存,缓存未命中的情况下再实时查。还有一种常见场景是,缓存里有数据但已经放了较长时间,这时候直接返回缓存可能会让用户看到过期价格,所以系统会触发一个“异步刷新”动作:先返回旧数据让用户看到结果,同时后台发起实时查询去更新缓存。
查询阶段,聚合层会并发调用多个供应商的查询接口,通过线程池控制并发度,并对每个请求设置独立的超时时间。这里有一个细节:不同供应商的响应速度差异很大,有的100毫秒就返回了,有的要3秒。如果等待最慢的那个,整体响应时间会被拉到非常长。我的做法是设置一个最大容忍时间,比如1500毫秒,超过这个时间的供应商结果直接丢弃,系统在响应里标记一个“部分数据未加载”的状态,前端可以用一个“刷新重试”按钮让用户手动触发补充查询。
拿到所有结果后,聚合层进入合并环节。先把各个供应商返回的数据统一成内部模型,再按“出发时间+车次/航班号+出发到达站+座位等级”做去重。去重完成后,按照排序规则重新排序,默认是综合排序,综合价格、耗时、中转次数加权计算。最后把结果写入缓存,同时返回给前端。
3.2 缓存的分层设计与兜底策略
缓存设计是整个查询性能的命门。我采用的是两级缓存:本地缓存Caffeine加分布式缓存Redis。
本地缓存放的是热点班次的基础信息,比如车次时刻表、航班时刻表这类几乎不变的数据,缓存时间可以设置得很长,比如6小时。Redis缓存放的是查询结果,缓存时间根据出发日期动态调整:当日和次日的班次缓存120秒,三到七天的缓存300秒,远期班次缓存30分钟。
缓存key的设计也花了些心思。我的key格式是:
trip:search:{from}:{to}:{date}:{type}其中type包含交通方式、乘客类型、排序方式等信息。最后一个字段是数据的“指纹”,用于处理“不同请求序列化版本不同导致缓存击穿”的问题。比如排序规则调整后,指纹变化,旧缓存自动失效。
兜底策略方面,我着重处理了三个问题。缓存穿透:当查询返回空结果时,也会在Redis里写一个短期的空值缓存,防止恶意请求绕过缓存直接打到供应商接口。缓存雪崩:给每个key的过期时间加一个随机扰动,避免大量key在同一秒过期。缓存击穿:对高热度的key使用分布式锁,保证同时只有一个线程去回源查询。
3.3 并发编排、超时与降级
供应商接口的稳定性参差不齐,我的原则是“不能用全链路的成功率去赌单一供应商的稳定性”。为此我在聚合层引入了两个机制:并行调用与熔断降级。
并行调用使用线程池实现,每个供应商的查询任务提交到线程池后,主线程通过Future等待结果。线程池的核心参数需要根据上游接口的QPS上限来配置,不能无脑调大,否则一个高峰请求就能把供应商接口打爆。
熔断机制我参考了Sentinel的思路,但没有直接引入框架,而是自己维护了一个简单的熔断器:每个供应商适配器维护一个失败计数器,连续失败超过5次,熔断器打开,后续请求直接返回降级数据,不再实时调用该供应商。熔断器默认半开状态,每10秒放行一个探测请求,如果探测成功则关闭熔断器。这样做能有效避免单点故障影响全站查询。
降级数据从哪里来?来源于上一次成功写入缓存的查询结果。即使缓存过期了,在熔断期间也允许返回旧数据,同时在响应里加上一个“数据时间”标记,告诉前端这不是实时数据。用户能接受“稍早一点的班次信息”,但不能接受“查询直接报错”。
4. 预订交易与订单状态机设计
4.1 机票和火车票的预订流程差异
聚合查询做完之后,真正考验功力的是预订交易。机票和火车票虽然都叫“订票”,流程却有很大差异。
机票预订流程是:用户选定航班 → 填写乘机人信息 → 系统向供应商发起“占座”请求 → 占座成功生成待支付订单 → 用户支付 → 供应商出票。占座是有时间限制的,通常15到30分钟内必须支付,否则座位会自动释放。出票也不是即时的,短则几秒,长则半小时,取决于航司的处理速度。
火车票预订流程是:用户选定车次 → 选择乘车人 → 提交订单 → 锁定座位 → 支付 → 出票。火车票在没有接入官方抢票通道的情况下,锁座和支付几乎是同步完成的,出票一般在几十秒内就能完成。
这两种差异直接影响了订单模型的设计。我为机票和火车票共用一张订单主表,但在订单子表里区分流程类型。订单主表记录用户、总金额、订单状态;子表记录具体的行程信息、乘机人或乘车人名单、上游供应商订单号。这样既能复用订单管理逻辑,又能兼容两种完全不同的履约流程。
4.2 订单状态机的关键状态与流转
订单状态机是整个交易模块最核心的部分。我定义的状态包括:
- 待支付:订单已生成,等待用户付款。这个状态下系统已经向上游锁定了座位或库存。
- 支付中:用户已经发起支付,但支付结果还没确认。这是最微妙的状态,既不能立即出票,也不能超时取消。
- 已支付:支付确认成功,等待供应商出票。
- 出票中:已向供应商提交出票申请,正在等待处理结果。
- 已出票:出票成功,行程单或电子客票号已生成。
- 出票失败:供应商反馈出票失败,系统自动发起退款。
- 已取消:用户主动取消,或超时未支付系统自动取消。
- 退改中、已退款:售后链路的状态。
状态机的核心原则是:每个状态只能由特定事件触发流转,不允许跨级跳转。比如“待支付”只能流转到“支付中”、“已取消”或“已过期”,不能直接跳到“已出票”。为了保证状态流转的可靠性,所有状态变更都写入一张订单操作流水表,记录操作前状态、操作后状态、操作人、操作时间、变更原因。后期排查问题,这张流水表帮了大忙。
4.3 并发控制与幂等设计
交易场景里最怕的就是并发问题,我遇到过两个典型的坑。
第一个坑是乱扣库存。用户同时从多个入口下单同一个航班,系统如果不在下单前做二次库存校验,很容易出现超卖。这里的做法是:在创建订单前,先查一次供应商的实时库存,确认有票后,使用Redis分布式锁对该航班做“预占库存”操作。锁的key是trip:stock:{flightNo}:{date}:{cabin},获取锁成功后才允许创建订单。如果拿不到锁,说明这个舱位的库存正在被其他订单处理,直接提示用户“余票紧张,请尝试其他班次”。
第二个坑是重复下单。用户点击“提交订单”时网络抖动,前端自动重试了一次,结果生成了两笔订单。解决办法是在前端生成一个全局唯一的请求ID,后端在创建订单前先校验这个ID是否已经存在,存在则直接返回已有订单,不再重复创建。同时订单号本身也做成业务幂等键,包含时间戳、用户ID、随机数的组合,理论上即使在极端并发下也不会重复。
4.4 支付回调和出票结果处理
支付回调处理有一套严格的校验流程。收到支付平台回调后,第一件事是验签,确认请求确实来自支付平台;然后拿回调里的订单号查本地订单,核对订单状态是否还是“支付中”,如果已经是“已支付”,说明是重复通知,直接返回成功应答;接着核对金额,回调金额必须和订单金额完全一致,不一致立即发起风控告警;全部校验通过后,才把订单状态变更为“已支付”,并发送消息到MQ触发供应商出票。
出票结果的接收有两种方式:主动轮询和被动通知。多数供应商提供异步通知接口,但偶尔也会有通知丢失的情况。我的做法是“通知为主、轮询兜底”。系统里有一个定时任务,每隔5分钟扫描一次状态为“出票中”且超过10分钟没有结果的订单,主动向供应商查询出票状态。如果查询结果是出票失败,自动走退款流程,同时给用户推送短信和站内信。
退款流程也要设计成幂等的。退款请求发出后,如果网络超时导致不知道结果,不能直接重新发起退款,要先查一下退款流水状态。我在这里维护了一张退款流水表,每个订单最多有一条活跃退款记录,重复发起会被直接拦截。
5. 上线后踩过的坑:问题排查实录
5.1 价格不一致与库存超卖
上线第一天就接到用户反馈:列表里看到的价格是800元,点进详情变成900元,下单时又变成了950元。排查后发现原因有两层:第一层是缓存过期时间设置过长,供应商价格已经调整,缓存还是旧价格;第二层是机票价格本身包含复杂的计价规则,儿童价和成人价不同,不同舱位的价格也不同,列表页展示的是一个“最低价”,但用户点击后系统展示的是“默认舱位价”,两者不一致。
这个问题的解法是:列表页最低价做一个特殊标记,用户点击详情后必须重新实时请求一次“验价接口”,以验价结果为准。同时把缓存策略改成“基础班次信息长缓存+价格信息短缓存”,价格缓存只保留60秒。另外,列表页展示价格时统一标注“价格实时变动,请以下单页为准”,先把用户预期管理好。
库存超卖的案例也很有代表性。某天机票库存显示还有9张,但是用户连续下了5单,全部占了座位,实际上供应商那边只剩最后1张票了。原因是我们的库存字段是从供应商查询接口里拿到的“瞬时值”,但供应商的锁座接口并没有做原子扣减。后来我们把下单策略从“先锁本地库存再调供应商”改成了“直接调供应商锁座接口,以供应商返回值作为唯一依据”。本地库存只做展示,不做扣减。
5.2 慢查询与超时告警
有段时间系统查询成功率只有92%,大量请求超时。排查发现是两个问题叠加的结果:一是某供应商的查询接口在高峰期平均响应时间接近3秒,远超我们设置的1500毫秒最大容忍时间;二是线程池的核心线程数设置得太小,导致大量请求在线程池队列里排队,整体响应时间被拉长。
调整方案分三步:第一步,强制关停响应过慢的供应商数据源,要求对方做性能优化后才恢复接入;第二步,把线程池调成“核心线程数等于最大线程数”,避免线程池伸缩带来的额外开销;第三步,给线程池加了一个队列容量上限,超过上限的请求直接走降级逻辑,返回缓存数据,不让请求无限堆积。
这次排查看起来是性能问题,实际上反映出聚合系统一个很重要的设计原则:上游不可靠时,系统必须有“断臂求生”的能力,宁愿少返回一些数据,也不能整体卡死。
5.3 重复订单与消息丢失
重复订单问题在支付环节出现得最多。用户支付成功后,支付平台回调到达,同时用户在前端手动点击了“查询订单状态”,触发了后端主动查询,两条链路同时处理同一个订单,结果生成了两条出票请求。虽然最终订单只有一笔,但导致用户收到了两条短信,体验很差。
修复方式是引入“订单处理锁”。在订单状态变更和出票请求入口处,统一使用Redis锁,key是trip:order:process:{orderId},拿不到锁直接返回当前订单状态,不做重复操作。这个锁的粒度是订单级别的,不会影响正常并发下单,只拦截重复处理请求。
消息丢失的问题出现在MQ消费者异常重启的场景。RabbitMQ的消息在消费者执行逻辑但还没确认消费时宕机,消息会重新入队,但如果我们没有做好幂等,就会出现重复消费。解决方法是消费端做“消费唯一性校验”,消费者处理消息前先查一张“消息消费记录表”,如果这条消息已经处理过,直接确认消费,不再执行业务逻辑。
为了便于其他人排查问题,整理了一份常见问题速查表:
| 问题现象 | 可能原因 | 处理方案 |
|---|---|---|
| 列表价格与下单价格不一致 | 缓存价格过期、舱位价与最低价混淆 | 列表页价格短缓存,进入详情强制实时验价 |
| 提示有票但下单失败 | 供应商库存瞬时变化 | 下单以供应商锁座接口为准,本地库存仅作展示 |
| 查询响应时间超过2秒 | 个别供应商接口慢、线程池配置不当 | 设置最大容忍时间,超出直接降级,返回缓存数据 |
| 出现重复订单 | 支付回调与主动查询并发处理 | 引入订单处理分布式锁,入口做幂等判断 |
| 出票通知丢失 | 供应商异步通知未送达 | 定时任务主动轮询出票中订单,兜底处理 |
| MQ重复消费 | 消费者异常重启导致消息重新入队 | 维护消息消费记录表,重复消息直接确认 |
| 订单处于出票中超过30分钟 | 供应商接口卡死 | 自动触发取消并退款,通知用户重新选择 |
做这个系统,我个人最大的体会是:聚合类项目的复杂度不在代码本身,而在对“不确定性”的管理。供应商的价格会变、库存会变、接口会挂,系统每一处设计都得有对应的兜底方案。缓存可以提升性能,但必须考虑数据一致性;并发可以提升效率,但必须防止重复和超卖;异步可以提升吞吐,但必须处理消息丢失和重复消费。这些听起来都是老生常谈,真正在业务里踩过一遍才知道分量。
如果后面再做二期,我会把精力放在推荐和预测上。基于历史查询和预订数据,主动给用户推荐“未来几周可能涨价的班次”或者“更优的中转组合”,让查询从被动等待变成主动服务。但那是后话了,当前版本把查得全、订得稳这条基础链路跑扎实,已经能解决绝大多数用户的真实出行需求。