作者介绍: 李佳奇 2014年加入去哪儿网机票事业部,担任Java工程师;曾负责旅行交通技术工作,完成了基于库存的搜索交易系统的全流程打通和改造;现从事机票主系统技术开发工作,致力于完善系统模型,改进开发流程,落地推广优秀技术等;

一、背景

1.1系统功能

Qunar 机票作为 Qunar 的核心业务之一,每天都有成千上万用户在 Qunar 的平台上完成搜索预定生单等操作, Qunar 也一如既往地为用户提供优质的出行体验和保证。就像每架飞机的座位数都是有固定数量的,在机票业务中,有一类特殊的产品是通过库存进行管理的,这里的一个库存就对应飞机上的一个座位,其业务模型大致如下:

上图中,左侧部分是后台供应商能在系统中做的操作:

  • 库存录入 通过系统提供的操作页面完成库存的初始化录入,包括库存的个数,所属的航班等等
  • 价格调整 通过系统页面,完成库存成本价的管理,每个库存从航司签约下来都有成本价
  • 增值服务 系统为供应商提供了多种增值服务,可以帮助代理商加快库存周转,提升利润等等

右侧部分是用户在 Qunar 购买机票时在库存系统上产生的操作:

  • 报价搜索 用户搜索的航线航班条件进入库存系统,库存系统匹配合适的库存
  • 库存占用 用户选择了库存产生的报价进行生单,为了保证用户的库存真实可用,需要对库存进行占用操作,来保证不会有其他人能继续使用这个库存,毕竟大家不会想飞机起飞时,自己和别人坐在同一个座位上
  • 库存释放 如果用户不想出行了,比如退票或者改签了订单,那么会在库存系统上产生一个库存释放操作

箭头的部分是库存搜索业务做的事情:

  • 库存匹配/库存组合 当用户的报价搜索请求到达时,库存会尽最大努力来满足用户的搜索条件,当原始的库存不满足时,系统会尝试进行库存组合来满足用户需求,比如用户搜索北京到上海,如果当前系统里没有北京到上海的库存,那么会尝试组合北京-郑州/郑州-上海这样的一对库存来做报价
  • 自动调价 库存搜索会根据当前市场上的价格,来计算给用户的报价,为用户提供有低价优势的报价
  • 价格过滤 有时用户的搜索会带一些特殊的要求,比如儿童,舱位等条件,那么库存搜索系统也会对符合用户行程条件的报价进行过滤,满足用户的个性化需求

一个典型的业务流程是下面的情况:

  • 代理商在系统中录入库存,进行价格调整,选择想要的增值服务
  • 用户在c端进行报价搜索
  • 库存系统通过库存匹配/库存组合来确定满足用户搜索条件的库存,并根据自动调价和价格过滤计算报价
  • 用户选择库存的报价之后,进入后续的交易流程,完成库存占用
  • 如果后续发生退款,在售后环节会进行库存释放

系统会关注这部分库存搜索和交易环节中的 两个核心指标

  • 库存周转效率
  • 库存准确性

对于库存周转效率,我们需要扩展更多的销售渠道,增加库存的曝光,同时降低库存的变价/售罄等因素对用户购买流程畅通性的影响。因此,我们需要对接目前市场上所有可以对接的渠道,这带来的副作用就是请求流量高,对库存报价的实时性要求高。

1.2搜索场景问题分分析

库存搜索服务为了保证库存的最大曝光,对接非常多的渠道,如下图:

这些渠道具有两个特点:

  • 数量多
  • 个性化

渠道数量多带来的问题是每个渠道的搜索流量都会集中到库存搜索入口。在渠道接入最多的时期,库存接口的搜索QPS达到了5K以上,对于库存系统有限的机器资源来讲,是不小的请求压力。同时对库存进行收益管理的需要有使得库存的价格调整是分钟级别的,因此库存的价格变化的QPS也是在K级别。因此我们需要设计一套缓存方案来解决渠道多带来的高流量,降低渠道接入给系统带来的成本。

渠道数量多的另一个问题是个性化。不同的渠道对于搜索的价格,服务标准,售卖要求都不一样,这就导致需要针对不同的渠道进行个性化处理,这对于设计缓存方案是不利的,意味着需要针对不同的渠道设置相互独立的缓存,带来额外的缓存构造和维护成本,同时也带来了缓存一致性问题。

由个性化问题引申出的,是我们可以在个性化的基础上提炼出共性计算场景,比如库存可售量,库存成本价等这些和渠道特性无关的特征,这些共性计算场景的发现为我们接下来设计缓存方案提供了重要的参考性指引。

二、搜索场景方案设计

基于上文的搜索场景问题分析,我们针对性地从以下几个方面来进行方案设计:

  • 减少无效请求
  • 提升缓存命中率
  • 提升缓存更新效率

2.1 减少无效请求

减少无效请求的合理性在于,并不是所有的上游请求都是有效的,比如对于不存在航线的库存,上游的请求对于库存系统来讲是没有意义的,因为一定会返回无报价,再例如,在非常接近的时间内,相同条件的库存请求也是可以进行合并或者剪枝的,特别是在两次请求的间隔时间内如果库存报价信息没有发生变化的场景下。

因此,在证明了无效请求真实存在并分析无效请求产生的场景之后,我们运用了下面两个手段来解决无效请求的问题:

  • 产品目录化
  • 渠道缓存利用

2.1.1 产品目录化

产品目录化具体是指我们定义了一个我们当前售卖库存对应的航线航班列表,并通过接口把这套列表提供给我们的接入渠道,这样我们的接入渠道可以根据当前收到的上游请求参数来决定是否请求我们的库存搜索。即,把常规搜索场景下用户要什么的问题转换为库存系统有什么的问题,把原来的的过滤计算消耗转移到渠道层,减轻库存系统的计算压力。

具体来讲,在产品目录化之前,渠道会把用户的搜索请求转发给库存系统,比如北京-上海,2021-08-20起飞,库存系统收到请求之后搜索自己的库存报价有没有满足该条件的,如果有返回给渠道调用。这样形成的结果是,每个用户的请求渠道都要来查询库存系统有没有对应的报价,而库存系统并不能覆盖所有的用户请求,所以对于库存系统当前并不能覆盖的航线,这样的请求是无用的,可以避免的。产品目录化就是库存系统主动告知渠道当前库存系统拥有的航线和航班数据,比如库存系统提供航线航班目录给渠道查询,返回类似的结果北京-上海/CA1234/2021-08-20/2021-08/22,以这样的格式告知渠道库存的产品目录,这样渠道在接收到用户请求之后,可以根据这份产品目录来进行请求过滤,如果目录并不满足本次用户请求,则无需再请求库存系统,这样就达到了减少无效请求的目的。

产品目录化带来的收益是很明显的,对于个别航线覆盖度低的渠道,请求 QPS 从 K 级别降至了个位数级别。但即使这样,整体的请求量仍然很大,因为并不是所有渠道都支持产品目录化以及渠道自身过滤的这种模式。探索还需要继续。

2.1.2 渠道缓存利用

渠道缓存利用是指在我们对接的渠道中,有些渠道是有自身缓存的,为了降低这些渠道的不必要请求,我们从对方的缓存策略入手,针对有闭环能力的渠道缓存,我们接入对方的缓存更新接口在我们自身报价发生变化时主动通知渠道,这样能最大程度地提高渠道缓存的命中率,降低渠道对库存搜索接口的实时请求量。

另一方面,对于接入渠道中没有缓存能力的部分,我们在设计 Open API 时专门定义了航线航班报价变动的通知 SPI ,这样对于有缓存管理能力的接入方,可以完善自己的缓存闭环,减少对库存搜索接口的实时请求。

** **

2.2 提升缓存命中率

缓存命中率是在使用基于缓存的搜索解决方案时非常重要的指标。缓存会带来性能的提升,降低计算资源和 DB 的压力,但如果缓存命中率提不上去仍然有大量请求需要实时搜索,那么上述的优势会打折扣,因此在提升缓存命中率方面,库存搜索也做了方案的探索和落地。

2.2.1 请求重放

请求重放是提升缓存命中率的其中一个手段。库存对应的航线具有如下特征:

  • 航段覆盖度不高
  • 航段分散度大
  • 单一航段搜索请求分布稀疏

这些特征决定了,对于具体的一条库存航线,其搜索请求的间隔会比较长,这要求缓存结果常驻内存,但如果缓存时间过长,缓存的实效性就成为了问题。其实如果我们可以做到库存报价的完全闭环,缓存实效性也可以有保障。但是现实的问题在于,库存报价除了依赖成本价格之外,还会受到市场价格,销售进度等外部因素等影响,而这些因素的变动入口都不在库存搜索系统中,因此做不到完全的闭环。但我们也不是完全放弃了努力,后面的缓存隔离就是我们在无法完全闭环的场景下,做的最大程度闭环的尝试。

同时,在库存搜索场景中,库存价格计算依赖的外部因素变化是不可感知的,常规的缓存闭环方案在此场景下是不可行的。

因此,库存搜索设计了请求重放机制来解决这个问题。

如上图所示,对于库存搜索的实时请求,库存搜索系统会将其加入到请求队列中进行管理,通过请求重放能力来按照一定的频率重放这些请求,保持对应的缓存维持一定的热度,这样在分散的真实请求过来时,缓存的实效性就有了保证。同时,对于不可控的外部因素变化,通过调节重放频率可以降低外部变化对库存报价产生影响的延迟。

但是有一点要注意,不是所有的缓存场景都适合用请求重放来打热缓存,比如对于有查定比要求的场景,重放的请求实际上会虚耗查询资源。

2.2.2 缓存隔离

缓存隔离是库存系统在提升命中率上的另一个手段。上文提到,多渠道的库存报价有共性计算的部分,这部分具体是指库存成本价,库存可售数这些和渠道特性无关的基础属性。通过将这部分基础属性隔离到单独的缓存,并完善闭环,可以保证涉及到库存基础属性的查询100%命中缓存,间接提升渠道搜索过程中的性能,减少实时查询。同时,将渠道特性相关的其它类型缓存比如市场价格按照缓存更新因素的种类进行缓存隔离,每部分缓存根据自身的闭环特性单独进行闭环管理,将每部分缓存的命中率都提升到最高。

2.3 提升缓存更新效率

在完成上文提到的请求重放的方案之后,摆在库存搜索面前的问题是如何提升重放驱动的缓存,同时降低重放队列的存储资源占用,因为库存搜索对接的渠道非常多,因此请求重放队列几乎覆盖了 C 端全部的用户请求。

库存搜索从以下三个方面来提升更新效率:

  • 冷热隔离
  • 统一缓存管理
  • 完善闭环

2.3.1 冷热隔离

冷热隔离如上图所示,将重放队列中的请求按照当前库存有无报价进行拆分,有报价队列和无报价队列以不同的频率进行重放,同时在资源调度方案,优先处理有报价队列中的重放请求。这样可以实现以较小的资源驱动有效报价缓存保持一定的新鲜度,同时又兼顾无报价缓存不会被穿透。

2.3.2 统一缓存管理

统一缓存管理是指库存搜索系统在原有的搜索系统中,单独拆分出 cache manager (图中绿色部分)系统进行库存/渠道的统一缓存管理,抽象提炼统一的缓存模型。最大程度降低渠道缓存接入的开发成本,同时由于缓存模型一致,也有效降低了维护成本和学习成本。

2.3.3 完善闭环

完善闭环是在缓存隔离完成后,针对变化因素由库存系统完全可控的那部分缓存,定义统一的闭环事件可靠通知来完善缓存闭环。在上图中,闭环事件具体包括:新增库存,修改库存价格,上下架库存,库存售卖等事件。

2.4 交换空间

对于请求重放队列的存储空间占用,库存系统参考 JVM 的 copy 垃圾回收算法的设计,对于每个渠道的请求队列都设计了两个 work shard 作为队列交换空间。

在任意时刻,会有一个 work shard 处于工作状态,保存搜索请求。系统定义了几个检查点:

  • 定时检查
  • 空间上限触发
  • 队列清空触发

在检查点执行的时间内,work shard 停止工作,进行有效请求迁移,切换到新的 work shard 上继续工作,同时原 work shard 进行过期请求清理等工作,等待下次切换。

这样设计的好处在于:

  • 保证消费实时性
  • 控制存储上限

2.5 全流程异步

为了提高搜索系统整体的吞吐能力,搜索系统采用了全流程异步的模型设计,利用 springmvc 支持servlet异步的特性 + mq 回调的方式,实现了下面的特性:

  • 实时搜索异步回调
  • 缓存搜索直接响应

实现方案上我们采用的是 Spring Web MVC On Servlet 技术栈,运行原理简单描述如下:

通过 async context 进行跨线程的上下文传递,同时分离 tomcat 线程池和工作线程池,tomcat 线程完成接受 request/ 提交 task 的职责后就结束本次请求的处理,可以继续处理新建来的请求。而真正的任务执行交由工作线程池进行。这个特性依赖 Servlet3.0 的异步特性为基础,如果要使用的话需要注意。

上面代码片段是具体的编码实现:

  1. 定义controller返回类型DefferedResult (Spring mvc还兼容了CompletableFuture )
  2. 定义超时时间,防止线程池不被拖死必不可少
  3. 将异步获得的结果提交到 DefferedResult

具体的使用方式可以参考官方文档: https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-async

同时要注意下异步模式下过滤器,拦截器等配置方式和同步模式略有不同。

三、总结

本文重点描述了在库存的搜索场景下,库存搜索系统应对高并发请求进行的各方面的探索和方案设计,对于业务场景类似的系统有一定的参考借鉴价值。同时,在高并发场景下的系统设计中,也有经典问题和通用方案,如下图:

![](https://img.6aiq.com/e/9d8a5346b9034f2cabea320a9