news 2026/10/18 21:28:31

机票+火车票聚合预订系统实践:多源数据一致性与稳定性架构复盘

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
机票+火车票聚合预订系统实践:多源数据一致性与稳定性架构复盘

做机票+火车票聚合查询与预订系统,难的不是把航班和车次拼到一个列表里,而是要把两套完全不同的数据源、交易链路和业务规则,揉成一个对用户“无感”的出行产品。我复盘了从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分钟供应商接口卡死自动触发取消并退款,通知用户重新选择

做这个系统,我个人最大的体会是:聚合类项目的复杂度不在代码本身,而在对“不确定性”的管理。供应商的价格会变、库存会变、接口会挂,系统每一处设计都得有对应的兜底方案。缓存可以提升性能,但必须考虑数据一致性;并发可以提升效率,但必须防止重复和超卖;异步可以提升吞吐,但必须处理消息丢失和重复消费。这些听起来都是老生常谈,真正在业务里踩过一遍才知道分量。

如果后面再做二期,我会把精力放在推荐和预测上。基于历史查询和预订数据,主动给用户推荐“未来几周可能涨价的班次”或者“更优的中转组合”,让查询从被动等待变成主动服务。但那是后话了,当前版本把查得全、订得稳这条基础链路跑扎实,已经能解决绝大多数用户的真实出行需求。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 18:13:40

aarch64 Linux上Eclipse 2021-12 R C/C++开发环境部署与调试指南

简介:面向 C/C 开发者的 Eclipse IDE 2021-12 稳定版(代号 R),专为 64 位 Linux aarch64 架构设计,基于 GTK 图形界面,预装 CDT 工具链并集成 GCC/Clang 编译、GDB 调试、Git 版本控制、性能分析等核心功能…

作者头像 李华
网站建设 2026/10/17 18:48:28

轻量可靠UDP协议UEC:面向边缘设备的通信新范式

1. 项目概述:为什么边缘设备需要一个“不挑食”的UDP协议?最近在给某高校物联网实验室做边缘网关通信模块优化时,反复被一个问题卡住:几十台部署在工厂车间、农业大棚、城市井盖下的嵌入式设备,用标准UDP发心跳包&…

作者头像 李华
网站建设 2026/10/17 14:43:13

AnyPS5:PS5非官方能力拓展的系统级方法论

项目标题:“AnyPS5”这个名称一出现,我就下意识多看了两眼——不是因为它是某个新发布的硬件产品,也不是某家大厂的官方代号,而是因为它精准踩中了当前玩家圈里一个真实、高频、且长期被模糊处理的需求:“我手里的PS5&…

作者头像 李华
网站建设 2026/10/17 15:38:45

SSM+Vue社区医疗保健监控系统实战:从设计到部署全解析

作为每年都要带十几届毕设的老兵,看到这个课题名第一反应就是:这题我会。社区医疗保健监控系统,一听就是典型的“业务管理系统数据可视化”组合,SSM做后端、Vue做前端,是目前市面上需求量最大也最好落地的一套技术栈方…

作者头像 李华
网站建设 2026/10/16 18:44:24

Spring Boot+Android旅游攻略系统:全栈毕设实现与避坑指南

又到一年毕设季,每年这个时候“Java Spring Boot Android旅游攻略系统”这类题目都会被反复翻出来。这个题目热度高不是没道理的——它覆盖了后端、移动端、数据库三大块,难度适中,技术栈通用,既能锻炼完整项目能力,又…

作者头像 李华