作者简介
Stephen,携程资深后端开发工程师,专注新技术挖掘,持续推动业务创新
Scott ,携程资深研发经理,负责订单系统架构升级和优化
一、背景
携程机票订单系统是由多个业务子系统组成,包括出票、改签、航变等等,获取订单行程信息复杂度较高。
例如:用户预订了一个包含了2个乘客的机票订单,该订单发生了航变,其中用户A选择了退票,用户B选择了改签。
业务系统需要获得该订单最新的行程信息以及行程变化轨迹,以进行展示和进一步处理。
上述例子用户的最新行程信息为:
- 乘客1:航班号9C888,SHA-PEK,已退票
- 乘客2:航班号9C999,SHA-PEK,已改签
历史的系统设计需要通过API对各业务子系统的数据进行实时的聚合和计算,如果要获取上述例子的最终行程与轨迹,需要至少调用订单、出票、改期、航变系统等,流程复杂且耗时高,并且针对一些复杂的业务场景还可能导致错匹配、漏匹配等问题。
总结下来有如下几个问题:
- 数据私有(分散),数据模型不统一
- 按照时间线进行聚合的难度大,需要动态计算,耗时长
- 数据存储周期不一致,完整性不高
- 数据分析困难,报表逻辑复杂
二、目标
总的来说,我们需要设计一个用户行程系统来满足以下要求:
- 完整准确的行程信息
信息丰富完整,并保证更新及时、准确 - 使用便利
一站式获取,使用方效率提升,方便使用方快速接入 - 性能可靠
系统性能良好,可靠性高 - 提升业务系统自动化率
提升自动化率,上线灵活 - 快速实现复杂业务流程
对于大量动态数据的分析与过滤需要快速实现并上线
三、实施方案
3.1 设计思路
Q1:系统需要提供什么样的能力?
1)提供准确的用户最新行程信息
用户和相关的业务系统需要及时和方便的获取到完整、准确行程信息
2)输出历史行程变化轨迹
对于退票等场景,需要了解用户完整的行程变化轨迹,以便于自动化处理相关数据
3)通过行程信息进行模糊匹配
对于航变场景,航司通知某个具体航班发生了变化,系统需要通过这些信息匹配到对应的订单并进行后续的处理
Q2:如何确保信息的丰富和准确?
1)在丰富性方面,可以接入大量的数据源并提供便捷的接入方式,及时有效的采集数据,提升系统数据的完整性
2)在准确性方面,可以采用主动 + 被动等方式,多维度的对数据进行校验、修复,提升数据的准确性
Q3:如何提升系统的稳定性和可扩展性?
1)通过分布式缓存、结构化并发等技术提升系统的性能与稳定性
2)通过数据库的sharding、数据仓库的赋能等方式提供在线和离线的数据处理能力,进一步扩充数据的应用场景
3.2 系统架构图
最终行程系统主要有以下几个方面组成:
1)最终行程数据通知与更新系统
即上图中的Data Collector API,通过收集各种来源,如订单库、出票系统、改签系统等的数据,更新或者落地在最终行程系统数据库中。同时在落地的时候也会进行被动 + 主动相结合的数据校验机制,保证数据的准确性。
2)最终行程查询系统,即上图中的Query API,其中包含三大功能与若干个模块
- 最终行程查询,对外输出该订单的最终行程信息,该接口流量最高,包含有缓存组件、熔断器、限流器等,保障其性能的稳定;
- 行程溯源轨迹查询,对外输出该订单下所有行程变化的历史轨迹,使用方可以通过该接口拿到这个订单的行程关系图,感知所有变化轨迹;并且整合了价格计算模块、错误数据修正模块;
- 行程匹配查询,通过给定的行程要素条件,匹配能够对应上的最终行程记录,并支持批量查询;
3)数据存储架构,通过分库提升数据库的水平扩展能力,并且结合数据仓库为业务赋能
3.3 信息丰富性
支持多种更新机制,方便接入多种类型的通知方,提升信息的丰富度,目前已经接入了出、退、改、航变、票号中心等22个数据源。
策略1: 系统主动通知,适用于对于数据新鲜度要求较高的场景,查询性能较好
策略2: 消息通知消费,适用于数据新鲜度要求不太高的场景,通过反查保证数据最终一致,方便系统解耦
策略3: 实时查询,适用于数据变化非常频繁,新鲜度要求高的场景;减少了数据冗余,但是在查询和使用上存在依赖
策略4: 动态数据的过滤通知,适用于存在规则变更,但变化维度和订单维度不同,需要扫描海量数据来获取更新记录的场景
3.4 便利度增加和业务提升
3.4.1 降低溯源接口接入复杂度
溯源轨迹接口对于行程关系图的输出形式,对于使用方的便利度影响非常大,比如如下的行程关系图。
历史的输出形式为一种无限层级的树形结构,这样的结构虽然能对向下的溯源查询以及对一变多的行程变化关系提供支持,但是对于向上的溯源查询、多变一、多变多的行程变化关系不友好,许多使用方都需要使用DFS等算法来解析数据,不够简洁易用,容易出错;并且树形结构已经不能直观的反映出类似二变一(中转变直飞)的行程变化场景,而且这样的结构还会出现数据的冗余,如下图所示:
基于以上的情况,新溯源接口选择了类似图的邻接矩阵来表述行程溯源变化关系,通过TripInfo节点来表示顶点数组,平铺出行程溯源关系图中各个节点的行程信息;通过ChangeInfo节点来表示边数组,主要描述行程变化关系。这样的描述更加通用、结构清晰并且对使用方更友好。
3.4.2 支持大量动态数据的扫描与过滤
在实际的业务场景中需要维护这样一部分数据,它会发生变化,但引起变化的规则维度与订单维度不一致,所以需要扫描海量数据来获取需要被更新的记录。同时,扫描依赖的数据可能还需要跨库才能拿到,按照现有的数据库结构实现起来非常复杂。通过调研,最终采用数仓并结合业务SDK过滤的动态数据主动更新机制,实现了业务场景主动更新与通知的功能,该流程有如下几个特点:
- 轻松整合所有依赖数据项,通过数据仓库的大数据分析能力,可以轻松整合所有依赖的数据项
- 对数据进行筛选,在数据仓库处理的流程中,添加了业务SDK的过滤机制进行数据的初筛,将海量数据进行过滤,并结合Double Check机制进行进一步的筛选,得到真正受影响的记录
- 触发消息的聚合机制,同时考虑到了业务误操作后又修改一次的情况,所以增加了消息聚合机制,聚合一段时间的消息后再真正触发数仓进行处理
该流程具有很强的通用性,通过简单替换不同的SQL语句,切换不同的SDK,就可以轻松将该流程移植到其他业务项目中,实现了功能的快速上线。
3.5 性能优化
3.5.1 提升数据库的水平扩展能力
最终行程系统在之前使用的是单库存储,但是随着数据量的不断增加,当业务信息扩充时,新增数据字段在数据库层面上变的难以操作;并且如果按照业务期望的存储时间,硬盘使用率会过高,造成了存储瓶颈。
经过调研,决定对最终行程数据库做 Sharding 处理,将数据平均分配到多个分片就可以满足存储要求并兼顾性能指标。
1)数据切分,基于最终行程数据特性,即订单号访问占比较高,同时在订单号分布均匀的前提下,最终采用了订单号对数据库总分片数取模的方式,以保证数据分布的均匀性。
2)数据兼容,对于sharding库和非sharding库双写新数据的操作,并考虑数据库存在异常的情况,需要增加异常补偿处理机制;并且对于历史存量数据,也进行了分批次的数据迁移以及补偿功能,同时为了保证数据一致性,在迁移完成后也进行了多批次的数据对比与接口对比工作,保证 Sharding 数据的准确性和可靠性。
3)查询性能,多分库的查询性能是分库存在的典型问题,对于最终行程来说,采用非订单号查询操作,分库后就涉及到多个分片的 All Shard 查询,极大地增加了数据库压力和影响查询性能。经过数据统计,分析得到特定的业务字段查询其实就涵盖了非订单号查询的大多数,从而增加其二级索引表就可以有效解决 All Shard 查询性能的问题。
3.5.2 接入Redis缓存提升系统性能
总体上采用先操作数据库,后删除缓存;先查询缓存,查询不到缓存则查询数据库,并回填缓存的方式进行处理。
1)提升新鲜度,在行程更新流程时、接收BinLog消息时、接收业务变更消息时都会将缓存删除。
2)采用分级储存查询的模式,查询时根据调用方所需的数据级别进行获取,缩小Redis获取数据的大小,减少网络开销。
3)异步回填,启用专用的线程对缓存数据进行异步回填,这样可以不拖累查询请求本身的耗时。
4)优化缓存容量,对Json序列化器定制规则,不输出值为null的字段;将序列化对象中的字段通过@JsonProperty注解取一个简短的别名,来简化Json字符串Key的大小;使用Zstd压缩算法对序列化后的数据进行压缩;通过前期调研命中率和生存时间的关系,得出达到预期命中率的最小缓存生存时间,从而进一步减少Redis的容量。
3.5.3 结构化并发在匹单接口中的探索
最终行程匹单接口允许使用方传入多组条件进行匹配,接口内部对于这多组条件采用的是for循环的方式顺序执行的,存在并发改造的空间;且匹单接口操作数据库存在多shard查询的情况,对于多shard查询,Dal底层会使用线程池并发调用,对线程的开销较大。综合上述问题,并结合近期发布的新的长期支持版本JDK21,发现了其预览功能中的结构化并发比较适用于匹单场景的优化。
1)简化多线程编程,增强可观察性。
一般而言,如果我们想要实现并发操作,需要使用异步编程的方式来实现,但是使用这样的方式对于代码阅读性和调试来说都比较差。在目前的多线程开发中,常用的方式是使用CompletableFuture的级联方式编写。与单线程的代码相比,这样的写法并不直观,并且“任务终止不干净”和“等待超过必要时间”的问题仍然存在,如果要解决这些问题还需要自己实现一系列模版代码,费力度大大增加。
而结构化并发的一大特点就是让开发人员以类似单线程的方式来编写多线程代码,他引出了一个结构化任务作用域(Scope)的概念,在这个作用域中创建并执行任务,这些任务的生命周期都由作用域来负责管理,开发人员可以不用关系细节问题。对于作用域的任务使用try-with-resources块,如果在执行中出现错误,会自动调用StructuredTaskScope的shutdown方法来终止执行,调用shutdown方法会阻止新任务的执行,同时取消正在运行中的任务。
2)使用虚拟线程解决阻塞问题。
StructuredTaskScope底层默认采用了虚拟线程进行实现,在我们原来的认知中,线程的使用都是昂贵的,而虚拟线程是JVM中Thread类的实现,它是轻量级的,当使用虚拟线程进行代码执行时,如果遇到阻塞操作,便会释放掉载体线程;并当该阻塞操作可用时,虚拟线程又将被安排在载体线程上去继续处理执行。即在虚拟线程中,阻塞不是问题,因为阻塞时底层的载体线程已经被释放了
虚拟线程和结构化并发的组合将非常强大,虚拟线程使阻塞不再是一个问题,而结构化并发为我们提供了更简单的多线程编写方案,以更直观的方式处理异步编程。
3.6 优化前后数据支撑
- 数据库QPS降低30%
- 数据库CPU平均利用率下降20%
- 平均响应时间降低40%,P95降低30%
- 减少机器线程数41%,CPU利用率降低25%,显著减少机器压力
- 快速支持了业务功能,人力成本节约至少50%以上
四、后续规划
1)易用性优化
增加行程变化订阅通知机制,进一步提升易用性。
2)可靠性与性能提升
- 细化熔断和降级的策略
- 和框架团队协作,积极推广新技术在生产系统上的规范化落地
- 探索新的数据库结构与数据库选型,提升关系链路的存储能力
3)可视化
实现整体客人行程的可视化界面,依托最终行程数据的力量,帮助业务/产品开发更快了解到订单全貌,帮助提升问题解决效率。