携程机票查询系统,日均20亿流量,且要求高吞吐,低延迟的架构设计。于是,提升缓存的效率以及实时计算模块长尾延迟,成为制约机票查询系统性能的关键。本文是携程集团机票业务技术总监宋涛在「云加社区沙龙online」的分享整理,着重介绍了携程机票查询系统在缓存和实时计算两个领域的架构提升。
携程是OTA行业内首屈一指的互联网企业,而在携程内部,机票搜索又是使用最频繁的服务(没有之一)。所以,我们所面对的一些技术问题,可能会对很多中型企业有一些借鉴作用,希望今天的分享能给大家带来一些收获。
一、 机票搜索服务概述
1. 携程机票搜索的业务特点
首先简单介绍一下机票的搜索业务:大家可能都用过携程,当你去输入目的地,然后点击搜索的时候,我们的后台就开始了紧张的工作。我们基本上会在一两秒的时间,将最优的结果反馈给用户。这个业务存在以下业务特点。
(1)高流量、低延时、高成功率
首先,我们不得不面对非常高的流量,同时,我们对搜索结果要求也很高——成功率要高,不能说查询失败,或者强说成功,我们希望能够反馈给用户最优质最新鲜的数据。
(2)多引擎聚合,SLA不一
携程机票搜索的数据来源于哪儿呢?有很大一部分结果来源于我们自己的机票运价引擎。除此之外,为了补充产品丰富性,我们还引入了国际上的一些GDS、SLA,比如我们说的联航。我们将外部的引擎,和我们自己的引擎结果聚合之后发送给大家。
(3)计算密集&IO密集
大家可能会意识到,我说到我们自己的引擎就是基于一些运价的数据、仓位的数据,还有其他一些航班的信息,我们会计算、比对、聚合,这是一个非常技术密计算密集型的这么一个服务。同时呢,外部的GDS提供的查询接口或者查询引擎,对我们来说又是一个IO密集型的子系统。我们的搜索服务要将这两种不同的引擎结果很好地聚合起来。
(4)不同业务场景的搜索结果不同要求
携程作为一个非常大的OTA,还会支持不同的应用场景。例如,同样是北京飞往上海,由于设定的搜索条件或搜索渠道不一样,返回的结果会有一些不同。比如,有的客户是学生,可能就搜到学生的特价票,而其他的用户则看不到这个信息。
总体来说,每天的查询量是20亿次,这是一个平均的查询量。其中经过鉴定,9%的查询量来自于爬虫,这其中有一些恶意爬虫,也有一些是出于获取信息目的的爬虫,可能来自于其他的互联网厂商。对于不同的爬虫,我们会有不同的应对策略。
在有效查询当中,大概有28%是来自国际客户,然后有63%属于中文客户。携程的国际客户,特别是机票业务,所占有的比重越来越多。
2. 携程基础设施(Infrastructure)建设情况
好,接下来就简单介绍一下,就是为了应对这样的业务特点,我们有哪些武器呢?
(1)三个独立的数据中心
携程目前有三个独立的数据中心。他们是可以互相做灾备的,就在两天前,我们有一个比较盛大的庆祝会,经过差不多一年的系统提升和演练。我们实现了其中一个数据中心完全宕机的情况下,携程的业务不会受到影响。这个我们给他起了一个很fashion的名字叫流浪地球。我也代表我们机票业务领到了一个团体贡献奖。
(2)技术栈
讲一下我们的DataCenter大概的技术栈。可能跟很多的互联网厂商一样,我们是用了SpringCloud+K8s+云服务(海外),这里感谢Netflix无私的开源项目,其实支撑了很多互联网的基础设施部门,不然,大家可能还要摸索很长时间才能达到同样的效果。
(3)基于开源的DevOps
我们基于开源做了整套的DevOps工具和框架。
(4)多种存储方案
在携程内部有比较完善可用度比较高的存储方案,包括MySQL,Redis,MangoDB……
(5)网络可靠性
携程非常注重网络的可靠性,做了很多DR的开发,做了很多SRE实践,广泛推动了熔断,限流等等,以保证我们的用户得到最高质量的服务。
3. 携程搜索服务的架构
这里我简单画了一下携程机票搜索服务的架构,如下图所示。
我们的数据中心有三个,中间部分可以看到,我们首先引入了GateWay分流前端的服务,前端的服务通过服务治理,可以和后端聚合服务进行交互。聚合服务再调用很多的引擎服务,在这儿大家看出可以看到非常熟悉的Redis的图标,这就是我们广泛使用的分布式缓存。
缓存的具体细节下文会讲到。我们聚合服务的结果,可以通过Kafka推送到我们的AI数据平台,会做一些大数据的分析、流量回放,还有其他的一些数据相关的操作。在图中trip.com框的右边,我们还专门在云上部署了数据的过滤服务,使得传回的数据减少了90%,这是我们的data Center的介绍。
二、缓存架构的演进
1. 缓存的挑战和策略
(1)为什么大量使用缓存应对流量高峰?
在流量高下为什么要使用缓存?其实有过实战经验的同学都知道,缓存是提高效率、提升速度,首先需要考虑的一种技术手段。
对于我们来说,为什么要大量使用缓存?
首先,我们虽然使用了很多比较流行的开源技术,但是我们还是有一些瓶颈的。比如,虽然我们的数据库是分片的、高可用的的MySQL,但是它跟一些比较流行的云存储、云数据库相比,它的带宽、存储量、可用性还是有一定差距,所以我们通常情况下需要使用缓存来保护我们的数据库,不然频繁的读取会使得数据库很快超载。
另外,我们有比较多的外部依赖,它们提供给我们的带宽,QPS也是很有限的。携程的整整业务量是快速增长的,而外部的业务伙伴给我们的带宽,要么已经达到了他们的技术瓶颈,要么开始收非常高的费用。在这种情况下,使用缓存就可以保护外部的一些合作伙伴,不至于把他们系统给击穿,另外也可以帮我们节省一些费用。
(2)本地缓存 VS 分布式缓存
在整个携程架构的演进的过程当中,一开始本地缓存比较多的,后来部分用到分布式缓存,然后占比越来越高。
本地缓存主要有两个问题:一个启动的时候,它会有一个冷启动的过程,这对快速部署是非常不利的。另外一个问题是,与分布式缓存相比,本地缓存的命中率实在是太低了。对于我们海量的数据而言,单机所能提供的命中率非常低,低到5%甚至更低。
在这种情况下,我们现在已经几乎全面切向了分布式缓存。现在我们的分布式缓存解决方案是Redis分布式缓存,总体而言,现在携程可用性和容错性都是比较高的。
我们在设计当中,本着对战failure的这么一个理念,我们也不得不考虑失败的场景。万一集群挂掉了,或者它的一部分分片挂掉了,这时候需要通过限流客户端、熔断等方式,防止它的雪崩效应,这是在我们设计当中需要注意到的。
(3)TTL设置
还有一点需要强调的,TTL生命周期设置的时候需要花一点心思,这也是跟业务密切相关的。
买机票经常有这种场景:刚刚看到一个低价机票,点进去就没有了。这种情况出现的原因可能是什么呢?大家知道,航空公司的低价舱位票,一次可能就只放出来几张,如果是热门航线,可能同时有几百人在查询。所以,几百人都可能会看到这几张票,它就会出现在缓存里边。如果已经有10个人去订了票,其他人看到缓存再点进去,运价就已经失效了。
这种情况就要求有一个权衡,不能片面追求高命中率,还要兼顾数据新鲜度。所以,为了保证新鲜度、数据准确性,我们还会有大量的定时工作去做更新和清理。
2. 缓存架构演进
接下来讲一下缓存架构的演进。
(1)多级缓存
这里我举了三处缓存:
- 子引擎级别的缓存。
- L1分布式聚合缓存,L1聚合缓存基本上就是我们用户看到的最终查询结果。
- L2二级缓存,二级缓存是分布式的子引擎的结果。
如果聚合服务需要多个返回结果的话,那么很大程度上都是先读一级缓存,一级缓存没有命中的话,再从二级缓存里面去读中间结果,这样可以快速聚合出一个大家所需要的结果返回。
(2)引擎缓存
我们使用了一个多级的缓存模式。如下右图所示,最顶部的是我们指引前的结果缓存,储存在Redis中,在引擎内部,往往根据产品、供应商,会有多个渠道的中间结果,所以对我们的子引擎来说会有一个中间缓存。这些中间结果的计算,需要数据,这个数据就来自上文提到的最基础的一级缓存。
(3)基于Redis的一级缓存
一级缓存使用了Redis,主要考虑到它读写性能好,快速,水平扩展性能,能够提高存储量以及带宽。
在当前设计当中有两个局限性:首先为了简单起见,使用了固定的TTL,这是为了保证返回结果的相对新鲜。第二,为了命中率和新鲜度,我们还在不断地去提高。总之,目前的解决方案还不能完美地解决这两方面的问题。
返回结果我们分析了一下,在一级缓存当中,它的命中率是小于20%的,在某些场景下甚至比20%还要低,就是为了保证更高的准确度和新鲜度。高优先度,一级缓存的TTL肯定是低于5分钟的,有一些场景下可能只有几十秒;然后我们还支持动态的刷新机制,整体的延迟是小于3毫秒的。在整个的运行过程中,可用性一直比较好。
(4)二级缓存
二级缓存一开始是采用了MongoDB,主要考虑到几个因素:首先,它的读写性能比较好,另外有一个比较重要因素是,它支持二级缓存。大家知道Redis其实就是一个KV这样的一个存储了。而在设计二级缓存的时候,为了支持多一点的功能,比如说为了能够很方便地做数据清理,就需要用到二级索引的功能。我们会计算出来一个相对较优的TTL,保证特定的数据有的可以缓存时间长一点,有的可以快速更新迭代。
二级缓存基于MongoDB,也有一些局限性。首先,架构是越简单越好,多引入一种存储方式会增加维护的代价。其次,由于MongoDB整个的license的模式,会使得费用非常高的。
但是,二级缓存使得查询的整体吞吐量提高了三倍,通过机器学习设定的TTL,使得命中率提升了27%,各个引擎的平均延时降低了20%——这些对我们来说,是非常可喜的变化。在一个比较成熟的流量非常大的系统中,能有一个10%以上的提升,就是一个比较显著的技术特点。
针对MongoDB,我们也做了一个提升,最后把它切成Redis,通过我们的设计方案,虽然增加了一部分复杂性,但是替代了二级索引,这样一个改进的结果,成本降低了90%,读写性能提升了30%。
三、负载均衡的演进
系统的首要目标是要满足高可用,其次是高流量支撑。我们可以通过多层的均衡路由实现把这些流量均匀分配到多个数据中心的多个集群中。
1. 目标
第三,需要降低事故影响范围,即使是在稳定的系统里也不能避免事故,但是当事故发生时,我们要使事故的影响范围尽可能的小。
第四,我们需要提升硬件资源的利用率,另外还会有一些长尾问题,比如个别查询的时间会特别的长,需要我们找到调度算法上的问题,然后一步步解决。
2. 负载均衡架构
上图所示的是携程路由和负载均衡的架构,非常典型,有GateWay、load、balance、IP直连,在IP的基础上,我们实现了一项新的Pooling技术。
我们也实现了Set化,在同一个数据中心里,所有的服务都只和该数据中心的节点打交道,尽量减少跨地区的网络互动。
3. Pooling
为什么要做 Pooling 呢?因为前文提到了我们有一些计算非常密集的引擎,存在一些耗时长,耗费CPU资源比较多的子任务,同时这些子任务中可能夹杂着一些实时请求,所以这些任务可能会留在线程里边,阻塞整个流程。
Pooling 要做的事情就是:我们把这些子任务放在queue里边,将节点作为worker,总是动态的去取,每次只取一个,计算完了要么把结果返回,要么把中间结果再放回queue。这样的话如果有任何实时的外部调用,我们就可以把它分成多次,放进queue进行task的整个提交执行和应用结果的返回。
4. 过载保护
在 Pooling 的设计当中,我们需要设计一个过载保护,当流量实在太高的情况下,可以采用一个简单的过载保护,把等待时间超过某一个阈值的请求全都扔掉。当然这个阈值肯定是小于谈话时间的,这样就能保证我们整个的 Pooling 服务是高可用的。
虽然我们可能会过滤掉一些请求,但是大家可以想象一下,如果没有过载保护,很容易就会发生滚雪球效应,queue里面的任务越来越多,当系统取到一个任务的时候,实际上它的原请求可能早就已经timeout了。
下图所示的是压测结果,大家可以看到,在达到我们系统的极限值之前,有Pooling 和没Pooling两种情形下的负载均衡差异。比如在80%负载下,不采用Pooling的排队时间会比有Pooling的情况下高出10倍。
所以对于一些面临相同流量问题的互联网厂家,可以考虑把 Pooling 作为自己的一个动态调度,或者作为一个control plan的改进措施。
如下图所示,我们实现了 Pooling 之后平均响应时间基本没有大的变化,还是单层查询计算普遍需要六七十毫秒。但是实现了 Pooling 之后,有一个显著的变化是键值变少了,键值的范围也都明显控制在平均时间的两倍以内。这对于我们这样大体量的服务来说,比较平顺曲线正是我们所需要的。
四、AI的应用
这里我列出了使用效果比较好的几个AI应用场景。
1. 应用场景
(1)反爬
在前端,我们设定了智能反爬,能帮助屏蔽掉9%的流量。
(2)查询筛选
在聚合服务中,我们并会把所有请求都压到子系统,而是会进行一定的模式运营,找出价值最高实际用户,然后把他们的请求发到引擎当中。对于一些实际价值没有那么高的,更多的是用缓存,或者屏蔽掉一些比较昂贵的引擎。
(3)TTL智能设定
之前已经提到,整个TTL的设定,使用了机器学习技术。以上就是我们AI应用在搜索场景中效果最好的三个。
2. ML技术栈和流程
ML的整个技术栈,还有我们的模型训练流程,如下图所示。
3. 具体场景
这里讲一下AI的一个具体场景,就是过滤请求。
我们有一个非常开销非常大的子引擎,叫多票。它会把多个不同航空公司的出票拼接起来,返回给最终用户。但是,它的拼接计算非常昂贵,所以只对一部分产品开放。我们通过机器学习找到了哪些查询可以通过多票引擎得到最好的结果,然后只对这一部分查询用户开放,结果显示非常好。
大家可以看PPT右上角的图片,我们整个引擎能够过滤掉超过80%的请求,在流量高峰的时候,它能把曲线变得平滑起来,非常显著。整个对于查询结果、订单数,都没有太大的影响,而且节省了80%的产品资源。同时,可以看到这种线上模型,它的运算时间也是非常短的,普遍低于1毫秒。
五、总结
最后对这次的分享做总结,希望能够给大家带来一点点启发。我们使用了多层灵活的缓存,从而能很好的应对高流量的冲击,提高反应速度。
另外我们使用了比较可靠的调度和负载均衡,这样就使我们的服务保持高可用状态,并且解决了长尾的查询延迟问题。
最后我们在携程内部尝试了很多技术革新,将适度的AI技术推向生产,从目前来看,机器学习发挥了很好的效果。带来了ROI的提升,节省了效率,另外在流量高峰中,它能够起到很好的削峰作用。
以上就是我们为应对高流量洪峰所采取了一系列有针对性的架构改善。
六、Q&A
Q:在哪些场景下使用缓存?
A:所有的场景都要考虑使用缓存。在高流量的情况下,每一级缓存都能带来很好的保护系统,提高性能的效果,但是一定要考虑到缓存失效时的应对措施。
Q:缓存的迭代过程是怎样的?
A:如前文所述,我们先有L1,然后又加了L2,主要是因为我们的流量越来越大,引擎的外部依赖逐渐撑不住了,所以我们不得不把中间结果也高效的缓存起来,这就是我们L1到L2的演进过程。在二级缓存我们用Redis替代了MongoDB,是出于高可用性的考虑,当然费用的节省也是当时考虑的一个因素,但是更主要的我们发现了自运维的MongoDB比Redis,它的整体可用性要差很多,所以最后决定做了切换。
Q:分布式缓存的设计方式?
A:分布式缓存的关键在于它的键值怎么设定?必须要根据特定的业务场景,比如说我们有的键值里加入了IP地址,也就是我们的Pooling,基于Redis建立了它的队列,所以我们queue当中是把这种请求方的IP作为建设的一部分放了进去,这样就能保证子任务能够知道到哪儿去查询它相应的返回结果。
Q:为什么redis的读写延迟能做到3ms以内呢?
A:我首先要解释一下,所谓读写延时低,其实主要指的是读延时,读延时可以做到三毫秒以内,应该是没有什么问题的。
Q:这个队列是内存队列?还是MQ?
A:互联队列我们是用的Redis实现的,而不是用的消息队列或者内存队列,主要是为了保证它的高可用性。
Q:缓存失效怎么刷新的,这里涉及分布式锁吧?
A:前文提到的缓存失效,并不是指它里边存的数据失效,我主要指的是整个缓存机制失效了。我们不需要分布式锁,因为我们都是单独的key-value存储。
Q:缓存数据一致性怎么保证的?
A:这个是非常难保证的,我们常用的技巧是,首先缓存超过我们预设的阈值的话,我们会强行清除。然后如果有更精确的内容进来,我们是要动态刷新的。比如本来可以存5分钟,但是在第2分钟有一位用户查询并且下单,这时候肯定是要做一次实时查询,顺便把还没有过期的内容也刷新一遍。
Q:热key,大key怎么监控的?
A:其实对我们来说,热区没有那么明显,因为一般来说我们的一个key基本上对应一个点,一个出发地和一个目的地,中间再加上各种渠道引擎的限制。而不像分片那样,你分成16片或者32片,有可能某一分片逻辑设计不合理,导致那一片过热,然后相应的硬件直接到了瓶颈。
Q:老师能详细讲一下Pooling吗?
A:我先讲一下原理:子任务它们所用的时间长短是不一样的,如果完全基于我们的SOA进行动态随机分的话,肯定有的计算节点分到的子任务比较重,有的节点分到的比较轻,加入Pooling,就好像加入了一个排队策略,特别是对于中间还会实时调用离开几秒钟的情况,那么排队策略能够极大的节省我们的计算资源。
Q:监控是怎么做的?
A:监控我们现在是基于原来用了时序数据库,如ClickHouse,和Grafana,然后现在兼容了Promeneus的数据收集和API。
Q:二级缓存采用Redis的哪种数据类型?
A:二级缓存存的是我们的中间结果,应该是分类型的数据类型。
Q;TTL计算应该考虑哪些问题?
A:我们首先最害怕数据出现问题,比如系统总是返回用户一个已经过期的低票价,用户体验肯定会很差,所以这方面我们会牺牲命中率,然后会缩短TTL,只不过TTL控制在5分钟之内,有时候还需要微调一下,所以用了机器学习模型。
Q:IP直连和Pooling没太听明白,是说AGG中,涉及到的计算进行拆分,将中间结果进行存储,其他请求里如果也需要这个中间计算,可以直接获取?
A:IP直连和PoolingIP直连,其实把负载均匀的分到各个节点Pooling,只不过你要计算的子任务加入队列,然后每一个运算节点每次取一个,计算完了再放回去。这样计算的效率更高。中间结果没有共享,中间结果存回去是因为有的子任务需要中间离开,再去查其他的实时系统,所以就相当于把它分成了两个运算子任务,中间的任务要重新放回队列。
Q:下单类似秒杀了吧,发现一瞬间票光了,相应的缓存是怎么更新的?
A:如果我们有第1个用户选择了一个运价,没有通过,我们是要把缓存数据都给杀掉的,然后尽量防止第2个用户还会陷入同样的问题。
Q:多级缓存数据怎么保证一致?
A:因为我们一级缓存存的是最终的结果,二级缓存是中间结果,所以不需要保持一致。
Q:请教一下,一级、二级、三级缓存,请求过来了,怎么提高吞吐量,按理说,每个查询过程需要消耗时间的,吞吐量应该会下降?
A:是这样的,如果你没有这些缓存的话,那几乎所有都要走一遍。实时计算这种情况下,时间长,而且我们部署的集群能够响应的数很有限,然后一、二、三级每一级都能够屏蔽掉很多的请求。我们一级大概能够屏蔽20%,二级可以屏蔽掉差不多40%~50%。然后在这种情况下,同样的集群吞吐量显然是有明显增加。
Q:如何防止缓存过期时刻产生的击穿问题,目前携程是定时任务主动缓存,还是根据用户请求进行被动的缓存?
A:对于缓存清除,我们既有定时任务,也有被动的更新。比如说用户又取了一次或者购票失败这些情况,我们都是会刷新或者清除缓存的。
Q:搜索结果会根据用户特征重新计算运价和票种么?
A:我要做一个小的澄清,用户有时候会奇怪:为什么我的运价跟别人的运价不一致,是不是有杀熟或者其他的情况?我可以负责任的告诉大家,我们不利用大数据杀熟!那为什么同样的查询返回的结果不一样?有一定的比例是因为我们缓存数据出现了问题,比如前面缓存的到后面票卖光了,然后又推给了不幸的用户。另外一点是,我们有很多的引擎,比如说国外的供应商,特别是联航,他们的系统带宽不够宽,可用性没有那么高,延时也高,所以有一部分这种低价的票不能够及时返回到我们最终结果里边,就会出现这种情况,这不是我们算法特意去做的,只不过是我们系统的局限性。
Q:Pooling 为什么使用 Redis?
A:Redis是为了追求更高的读写速度,其他中间件,比如说内存队列是很难用在我们这种分布式的调度当中。我们如果用message queue的话,由于它存在很明显的顺序性,不能够基于键值去读到你所写的,比如你发送了一个子任务,这时候你要定时去拿这个结果,但是你基于其他的消息队列或者内存队列是没法拿到的,这也是我们的一个限制。
Q:多级缓存预热如何保证MySQL不崩?
A:冷启动的问题更多是作用在是本地缓存,因为本地缓存发布有其他的情况,需要有一个预热的环节,在这之间是不能接受生产流量的。对于多级缓存、分布式缓存、预热问题很小,因为本来它就是一个分布式的,可能有一小部分节点,比如要下线或者什么,但是对整个缓存机制来说影响很小,然后这一部分请求又分散到我们的多个服务器上,几乎不会产生太大的抖动的。但是还要重复一点,如果整个缓存机制失效,比如缓存集群由于某种情况完全下掉了,这个时候还是要通过熔断或者限流来对我们的实时系统作过载保护。
Q:Redis对集合类的QPS并不高,这个怎么解决。
A:其实你Redis多加一些节点,然后减少它的存储使用率,把整体的throughput提上去就可以了。如果你对云业务有了解的话,就会知道每一个节点,它都是有throughput的限制的。如果就单节点的throughput成为瓶颈的话,就降低节点它的使用率。
讲师简介
宋涛,携程集团机票业务技术总监。北京大学计算机系毕业,加州大学计算机博士。先后从事微软公司Windows团队,智能搜索团队的架构和技术管理工作,Amazon公司云服务部门云存储管理服务技术主管。拥有多年大型项目架构经验,是携程技术领军人物。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。