机票报价高并发实施的关键路径
作者介绍
张培,2017年加入去哪儿,JAVA 开发高级经理,负责机票报价搜索业务。曾负责优化报价计算引擎,在业务流量快速上涨的时期,主导全系统的数据压缩优化,提高系统 300% 的容量,对于高并发搜索系统优化有丰富的经验和心得。
机票报价承载机票主站搜索的流量请求,目前国内机票报价日搜索量达 2 亿+,国内航线数量超过 2W+,为了支撑用户在 qunar app 等渠道查询购买机票操作,报价系统作为机票搜索核心之一,力求在用户的购票流程上做到:
快速响应报价搜索请求;
合理设计报价缓存和闭环降低机票购买流程的拦截率。
要解决这两个问题,我们引出机票报价高并发实施的关键路径,分别从下面四个部分来介绍。
一、机票报价高并发关键路径依据
1、报价的由来
当用户需要在 qunar app 上预定一张成都到海口2022年03月18号的机票,选择 EU2413 航班,截取机票售卖详情页部分报价,其中一个机票售卖 290 元,经济舱 1.2 折,提示剩余座位 7 张。简单抽象一下关键信息如下图,从中提取三个基本要素:AV 库存,FD 运价,policy 调价。航班舱位库存来自 AV 库存,航班舱位售卖价格来自 FD 运价和 policy 调价。其中,AV,FD 和 policy 的具体含义如下:
AV 库存,是 availability 的缩写,来自中国航信,记录各个航线所有航班信息以及每个航班舱位库存信息,比如这个 case 中AV记录成都-海口,起飞日期2022-03-18,航班号 EU2413,舱位 D2 剩余库存为 7;
FD 运价,是 fare display 的缩写,来自中国航信,记录航线航班所有舱位运价信息,FD 中记录起飞日期2022-03-18,EU2413 航班的 D2 舱公布运价 300元;
policy,是每个代理商(渠道商:航司直销或航司授权客票代理人)对于 FD 运价的调价策略,上图所展示的价格为 290 元,因为 policy 中会对2022-03-18日期下 EU2413 航班的 D2 舱调整价格优惠 10 块钱。
这三大基本要素构成机票的舱位报价信息,所以成都-海口2022-03-18日,航班号 EU2413 舱位 D2 剩余库存为 7 个,政策价格为 290 元。当然如果需要将这报价的机票拿出来售卖,还需要经过包装,限价,营销处理,为了便于理解,在这里举例的价格是不做营销包装的价格。那么对于全国 2w 多条航线,每天数亿次的查询搜索,机票的报价计算又是如何运作的呢。
2、机票报价调用架构
机票主站搜索包含 app,www,touch 分销等来源,报价需要承接主站不同渠道的搜索请求。
RT 作为报价的缓存系统,直接处理上游主站的搜索请求,日搜索超 2 亿次,RT 系统先从缓存中查询航线所有的舱位报价,然后根据不同的渠道进行报价商品选举和实时包装。对于过期的舱位报价会实时触发计算引擎进行实时计算。
计算引擎分为政策计算引擎和直连计算引擎,政策计算引擎依赖AV航班座位数,FD 运价和调价策略 policy,计算出代理商各个航班舱位不同赛道的最低价,通过 dubbo 异步回调的方式给 RT 推送舱位报价。直连计算引擎通过代理 API 接口推送舱位报价,会依赖 AV、FD 来补充航班信息和儿童价格等信息,同样计算出代理商各个航班舱位不同赛道的最低价,通过 dubbo 异步回调的方式给 RT 推送舱位报价。
数据层中 AV 库存和 FD 运价为外部系统维护的缓存系统,在这里为 RPC 调用。policy 是代理商对机票价格调价策略的最小单元,需要处理代理商政策导入到同步到搜索计算流程。直连 api 直接对接代理商的接口。
根据上面的报价结构,提取其中的高并发关键路径和实施方案:
机票报价搜索 qps 高,响应时间要求高,闭环复杂。那么报价系统的缓存设计就很关键,舱位报价使用了两级缓存:JVM 本地缓存和 redis 二级缓存来划分冷热数据。机票报价的计算依赖 AV,FD 和 policy 等多个因素系统,导致闭环复杂,因此专门设计了缓存管理系统 CM,来管理机票舱位报价更新时间;此外代理商政策数量大,全站接近 1 亿,所以政策计算引擎中使用航线一致性 hash+ 本地缓存快速查询不同航线的政策;
全站政策量大,政策更新频繁。由于 AV,FD 的变化,代理商的更新政策调价都会导致舱位报价过期。平台报价展位有限(可以理解成超市的货架有限),代理商为了自己的报价能够胜出,会精细化运营政策,导致政策数量多,更新频繁。那么代理商如何通过机票报价系统更新政策,报价系统又是如何对上亿条政策进行高并发搜索,由此引出报价的高并发数据库 CQRS;
本地缓存使用多,报价数据量大。像北京到成都这种大航线,一次搜索的舱位报价量级 4w+,内存占用 40M。半小时压缩缓存的报价超过 30G。为了提高系统的容量,提升系统的 GC 性能,提出了报价系统的数据压缩,将 String 压缩成基本数据类型存储,将 jdk 集合替换成高性能集合,消除集合运算处理自动拆装箱操作,减少物理存储开销,从而极大提升系统性能。
二、报价搜索缓存设计
为了满足机票主站的搜索请求,保证用户订票查询响应迅速,缓存的设计至关重要。引入缓存,打破标准流程,每个环节中请求可以从缓存中直接获取目标数据并返回,从而减少计算量,有效提升响应速度,让有限的资源服务更多的用户。报价搜索的缓存设计从三个部分来讲述:报价 RT 系统缓存的设计,为何要单独设计缓存管理系统CM,以及政策计算引擎中政策缓存使用。
1、RT 的缓存设计
对于 RT 系统的缓存,先从需要缓存的缓存 key,容量,过期时间,更新策略以及淘汰策略分别展开说明:
缓存 key:起飞-到达-日期;
容量:平均每个航线报价压缩后大概在 40k 左右,半个小时压缩的舱位报价需要 30G 的内存空间,显然直接存在本地缓存是不可能的。需要将缓存进行冷热处理,热数据放在本地缓存,冷数据存入 redis 中。同时将使用航线日期一致性 hash 拆分请求,这样的话能拆细热数据缓存,降低单机缓存压力;
过期时间:对于本地缓存热数据设置 5s,避免单机缓存数据量大,GC 时影响机器性能,redis 冷数据缓存存储 30min;
更新策略:报价发生更新时,触发计算引擎来计算报价,然后将最新的报价异步回调给 RT,更新缓存;
淘汰策略:当本地缓存过期或者容量超限制后,会使用 LRU 淘汰策略进行挤出策略,删除本地缓存,同时将热数据报价存入 redis 中。
RT 系统作为报价的核心系统,主要功能是舱位报价的缓存和实时包装处理。具体的报价的缓存调用如上图,两级缓存设计,报价搜索请求为一致性 hash 方式请求,首选查询本地缓存,存全部舱位报价数据,缓存时间 5s,目的是:
搜索请求一致性 hash key 和缓存 key 保持一致,可以提供热门航线的缓存数据,减少 IO 操作,提高搜索效率;
在内存中高效的更新缓存中代理商的报价数据。
若未命中本地缓存,则从二级缓存 redis 中加载舱位报价信息。当本地缓存过期时会将内存中的报价回写到 redis 中,redis 缓存有效期为 30min。这里需要注意,由于采用一致性 hash 策略,保证相同航线的请求会命中到同一台服务器上,提高本地缓存的命中率,同时也保证了本地缓存的一致性,因为所有的请求都是优先取本地缓存。
搜索查询到的报价信息结构如下图所示,同一次请求会拿到该航线日期下所有代理商的舱位报价信息,每个代理商舱位报价包含更新时间戳和过期时间戳。
缓存管理系统 CM,里面记录航线下每个代理商不同起飞时间段内的报价最近更新时间。每次 RT 搜索报价时只需要对比报价的更新时间戳和 CM 中记录的更新时间戳,将当前时间超过报价过期时间戳或者 CM 中记录更新时间晚于报价中的更新时间的代理商报价删除,针对这些代理商请求计算引擎进行舱位报价计算,计算引擎报价计算完毕之后,将报价按代理商维度通过 dubbo 异步回调给 RT 系统,RT 会轮训等待 300ms,等到报价回数达到阈值或者超时后,直接返回给主站当前航线的所有报价信息。
2、CM 存在的意义
报价 RT 为何需要使用缓存管理系统 CM 来管理报价的缓存呢,由于机票舱位报价计算依赖 AV,FD,和代理商的调价策略政策,每个因素变化都会影响报价的新鲜度,也就是影响着报价的更新策略。
如 AV 中航班的舱位库存售罄,那么该舱位报价就不能售卖
FD 中的某个航班的公布运价发生变化,这个舱位的报价价格就需要更新重算
代理商如果调整自己政策的策略,更新了某个航线的价格,同样这个航线代理商的报价也需要及时的更新。
所以对报价内部,需要一个系统告知 RT 系统代理商报价的更新时间点;对于外部系统,需要有一个友好的 API 接口来通知 RT 系统更新缓存报价。
基于此,CM 系统应运而生,它记录航线下不同代理商报价的时间节点信息,时间节点信息包括起飞日期范围对应更新时间戳;它还提供了高性能的 api 接口提供外部系统来更新航线报价的时间戳,让整个机票报价的形成完整闭环,目前机票整体的通过率保持在 90% 以上。
如图所示,CM 存储数据格式:航线-代理商-更新时间信息。每个航线包含多个代理商,每个代理商的报价包含不同起飞时间范围集合,集合中每段时间的更新时间戳不同。
对于 CM 系统来说,分为 client 端和 server 端,client 端提供给外部系统 API 更新代理商的报价,根据航线创建多个缓存队列,接收外部系统的调用,将请求进行缓冲和分类到各自的航线队列中,航线队列满或者队列的定任务会将队列中的的任务批量取出,通过航线一致性 hash 调用 server 端,然后将队列清空。
CM 的 server 建立了数据的航线缓存,此外由于配置了 dubbo 的一致性 hash 作为负载均衡,因此刷新和搜索操作都会在一台机器上操作,而每个机器上都有这些常驻航线的本地缓存,使得搜索和刷新操作都会在内存中操作。如果某一个航线的缓存失效,该航线的请求进入时会将数据从redis中加载到本地缓存。redis 中存储了所有 CM 的数据,当server 中完成一次 put 操作,更新本地缓存后,会将更新的航线代理商的时间戳数据加入到 redis 更新队列,异步将该航线代理商的 redis 数据进行更新。
put 操作:
参数:代理商+起飞+到达+起飞时间
触发时机:AV,FD,policy 更新,机票预定主流程变价或者失败触发 CM 刷新
search 操作:
参数:起飞+到达+起飞时间
触发时机:RT 系统查询舱位报价
3、计算引擎政策缓存
当机票基础数据变化,如代理商批量修改了调价政策,某地由于疫情或者航变导致 AV 航班取消和舱位关舱,亦或是航司促销打折导致 FD 运价改变等外部条件变化时,会导致刷新 CM 量倍增;机票预定主流程变价,或者报价过期同样也会触发 CM 刷新。这些种种原因会导致 CM 刷新 qps 高,那么调用实时报价搜索引擎的 qps 也响应增长,使得计算引擎压力变大。
对于过期的代理商报价,RT 系统是通过 dubbo 一致性 hash 调用政策计算引擎。在政策计算引擎内部,会并行调用政策,AV,FD信息,取到结果后加入到实时计算队列中,每计算一个代理商舱位报价通过异步 dubbo 回调给 RT 系统更新舱位报价缓存,回调的消息量超过 20wqps。如此大的计算量,政策缓存如何设计的呢。
AV,FD 在这里调用的是外部的 RPC 系统,政策缓存是政策计算引擎重中之重,采用 caffeine 本地缓存,其中 key 为航线 + 代理商,由于采用一致性 hash 搜索,提高命中率,即使穿透缓存,依然可以搜索库的航线代理商联合索引进行高效查询。为了保证政策本地缓存的闭环,dbsync 系统在同步搜索库的同时,会发消息给政策搜索引擎来更新本地缓存。在每次查询时政策缓存会 check 缓存 key 对应的时间戳是否早于数据库里面最近更新时间戳,来删除过期 key,最后本地缓存 loadAll 来获取代理商全量有效的政策。
政策缓存的设计思路如下:
缓存 Key:起飞-到达-代理商
容量:单程的政策量超过 5kw 条,直接使用 redis 会存在内存和网络开销问题影响性能,所以考虑一致性hash按航线拆分缓存,本地缓存 +mysql 存储;
过期时间:本地缓存有完备的主动刷新策略,过期时间设置较大 4h;
更新策略:同步系统消息实时清除过期缓存 + 定时 6min check;
淘汰策略:当容量超限制后,会使用 LRU 淘汰策略进行挤出策略,删除本地缓存;
缓存预热:在发布的时候回放最近线上流量来填充本地政策缓存,服务才上线,减少上线后由于没有缓存导致服务响应慢问题。
三、高并发数据库 CQRS
回到开始订票那个例子,成都到海口03月18号 EU2413 这个航班,平台有几百家代理商需要竞争低价展位。相同的机票服务,当然是谁的价格低就展示谁的报价(这里的展位可以理解成超市里的货架,相同的品牌商品有不同的供应商,谁的价格低就采购谁的),所以代理商不仅仅需要进行航线航班报价的政策覆盖,还需要实时调价到最低价,才能在平台上面展示售卖。
政策作为代理商调价的最小单元,策略复杂,支持多航线,代理通过调价策略,让自己的机票能在平台展示。由于政策的量级大,代理商导入和修改的频繁,政策的写入和存储在 qunar 平台维护和管理,政策按照代理商分库分表,使用 mysql 存储,使得代理导入政策互不干扰并且便于管理。
机票的预定搜索是需要按照航线快速索引的,显然代理商的政策库不能满足要求:搜索 QPS 高,按照航线来查询的话,会查询多张表代理商表,由于航线支持多机场,索引建立困难,如果查询也用政策库的话,代理商的批量导入和高 qps 的查询可能造成读写相互影响,造成系统性能急剧恶化。
因此我们采用 CQRS 的思想将数据库异构,将政策读写分离,异构出适用于航线搜索的搜索库。
1、CQRS 的简介
CQRS 为 Command Query Responsibility Segregation 的缩写,意思是命令查询职责分离。
使用场景:
读写业务模型差距大
读写性能要求不同
使用方式:
数据库读写分离
代码逻辑层分离
存储异构
与 event souring 模式结合
CQRS 使用 2 个服务分别提供读和写的功能。写服务接收 Command 请求,更新领域模型,将事件发送到一个消息队列上。读服务监听这个队列,获取事件,更新相应的读模型的数据。
所有的 Query 请求都由读服务处理并返回。可以看到,对于读服务,依赖读模型,根据查询而设计。通过这么一个简单的读写分离,能大大提高系统的性能。
2、政策异构设计
异构方式:以代理拆分政策库 mysql-> 以航线拆分搜索库 mysql
分表策略:热门航线 hash,拆分成 40 个库 1024 张表
同步方式:canal + 定时 diff
索引设计:多航线拆分,航线 + 代理商联合索引
基于前面 CQRS 思想,把代理商的政策库当做写库,那么计算引擎查询需要一个读服务来提供,考虑到政策量级的问题,使用 mysql 来存储读库也就是政策的搜索库,基于热门航线算法进行分表,使用航线 hash 策略,搜索库拆分成 40 个库 1024 张表。搜索库的更新使用canal实时同步和定时任务兜底全量同步,确保读写服务数据一致性。搜索库的索引为航线 + 代理商联合索引,需要将政策写库的多航线政策进行拆分。
3、政策同步流程
那么具体的政策读服务和写服务又是怎样运作的呢?
代理商通过 qunar 平台导入或者修改政策,会操作代理商政策库,即 CQRS 中的写库,政策库按代理商的分库,便于管理和相互隔离。
政策写入 mysql 数据库后,canal 伪装成从库监控 binlog 消息,将政策更新消息发送到 dbsync 系统,经过同步算法更新我们的读模型,即搜索库。
搜索库根据热门航线分库分表,总共 1024 张表,99 张备份,40 个库 6 个实例。
C 端用户搜索为读服务,通过航线一致性 hash 搜索查询,对于政策搜索引擎来说,实时舱位报价计算时会查询该航线代理商所有的有效政策,未命中缓存的同样会根据航线hash策略查询相应的搜索库。政策同步时会通过消息来更新政策本地缓存。对于新开的机场未及时维护航线路由表时,会 hash 到备份表中。
政策的同步高可靠,数据一致性,和延迟需要怎么保证呢。
高可靠:
canal 分别从 server 端和 client 保证数据的可靠性,均支持主从互备,不同 server 上的 instance 要求同一时间只能有一个处于 running,其他的处于 standby 状态,一旦 running 节点消息,standby 立马开始变为 running 节点。canal client 每次进行 connect 时,会首先向 zookeeper 询问当前是谁启动了 canal instance,然后和其建立链接,一旦链接不可用,会重新尝试 connect。
dbsync 定时任务,10min 全量 diff 政策库和搜索库中政策,兜底处理同步失败的政策。
数据一致性:为了保证有序性,一份 canal instance 同一时间只能由一个 canal client 进行get/ack/rollback 操作,否则客户端接收无法保证有序,并且每次 get 操作都会产生一个 mark,mark 标记会递增,保证运行过程中 mark 的唯一性和数据一致性。
延迟:政策相关表政策库拆分 19 个实例,政策同步服务 10 台。单机政策大量操作时,避免单个实例政策操作多,降低时延。目前的政策同步的时延在 1-2s 左右,也就是说,修改完政策后,报价能在2s 左右得到及时更新。
四、报价搜索优化:数据压缩
从前面可以看到,报价系统为了提升系统的容量,加快请求响应速度,使用了大量的缓存,尤其是本地缓存。此外,报价内部需要做大量的航司,航班,舱位,政策匹配等等,存在大量字符串操作 ,会引发一系列的内存瓶颈,计算瓶颈问题,最终导致系统 GC 问题,CPU 报警。我们统计了一天某个大航线下有 4w+ 报价量,单航线内存未压缩占用 40M+,政策的本地缓存占用 1.8G。根据报价系统的特性,提出了系统数据压缩,提升报价系统的计算性能和整体容量。
1、字符串压缩
一个空 new String()<JDK8开启指针压缩> 占用 40byte
reference/4 + object header/8 + char[]/16+int/4 +char reference/4 +padding/4 = 40 byte
那么将字符串压缩基本数据类型,如对于1位String压缩成 byte 存储,2 位 String 压缩成 short 存储,最多 4 位 String 压缩成 int,最多 8 位 String 压缩成 long。
举个例子:
对于航司二字码:MU 可以压缩成 short(19797),二进制表示如下图,M 放在高 8 位,U 舱位低 8 位:
可以看到压缩后,字符串存储内存大大降低,基本数据的匹配性能也更好。
2、集合的压缩
报价系统中除了字符串的大量使用,集合往往也是和字符串一起使用的最多的,对于 JDK 中的集合有个特性,key 为 int 的 Map 会自动成为包装类。可以看到前面讲字符串压缩成基本类型,如果使用 JDK 中的集合存储这些基本类型,那么会存在大量的拆装箱操作,为了进一步优化系统的压缩性能并且降低系统的自动拆装箱操作,我们将 JDK 中的集合进行改造替换,为了进一步提高压缩性能,我们使用 o.netty.util.collection 和 it.unimi.dsi.fastutil 高性能集合容器类型。
这两个集合工具类的特点:最小化内存占用,对于基本数据类型的 key,直接保存基本类型,匹配时无需拆装箱。
HashMap → IntObjectHashMap
HashSet→ IntSet
经测算:
存入 10000 个随机数据
IntObjectHashMap<V> 相对于 HashMap<Integer, V> 对于结构存储开销降低 40%-60%
IntSet 相对于 HashSet<Integer> 节省80%,不生成 Integer 对象,GC 性能更好
3、优化后的效果
报价的政策搜索引擎系统经过我们的压缩重构优化后,我们通过线上流量引流的方式进行压测,看到的效果,在仿真机器 1 倍流量,2 倍流量,2.5 倍流量的情况下,仿真的服务 CPU 使用率和 GC 频率指都优于线上的指标。
政策搜索引擎系统和 RT 系统经过压缩重构上线验证后,系统容量得到大大提升。
系统容量优化效果:
政策搜索引擎系统:服务器 444C -> 240C;承载 QPS:12 kqps 消息量
RT系统:服务器 3360C -> 1200C;承载 QPS:4000 qps 请求量
五、总结
到了这里,也接近本文的尾声了,前面洋洋洒洒地介绍了 qunar 机票报价高并发实施的关键路径,简述了机票报价的由来和机票报价的架构流程,引出高并发关键路径和实施方法,从业务的诉求和特性引出问题,并且给出了一些行业通用解决方法。
对于高并发来说,如何设计一套完备的缓存来提升系统的吞吐量,可以从业务缓存的容量,过期时间,更新策略以及淘汰策略来设计,对于是否使用多级缓存可以根据业务的性能和容量来看,如果业务闭环要求高,外部变化因素多,也可以参考本文单独设计一套缓存管理系统进行统筹管理。
上亿条数据的读写,使用数据库的 CQRS 读写分离大大提高系统的读写性能,对于读写业务模型差距大和读写性能要求不同的高并发系统是值得借鉴的。也可以考虑使用 redis 存储,对于大 key 来说,redis 有着明显内存和网络开销瓶颈需要考虑解决,一般来说可以拆成多级 key 处理。
对于高并发系统不可避免的会使用大量的缓存,一旦 qps 过高,容易达到服务器设置的内存瓶颈,FULL GC 导致系统性能急剧恶化,为了提升系统的 GC 性能,选择更高效的 GC 垃圾收集器如 G1,来减少停顿 (STW),往往效果有限;或者增加服务器来快速提供系统容量,是可以解决问题的,但是作为技术可以有更高追求。为了提高系统计算性能和容量,本文提到的对系统进行全局的数据压缩,能从根本上降低系统 JVM 内存使用,降低基本类型拆装箱操作,大大提升了系统性能,对于系统存在大量字符串匹配或者大量规律的字符串缓存系统尤其有效。相信这些关键路径,在其他高并发系统里都是可以借鉴和参考的。
END