今日分享 – 干货 | 数据库压力降低90%,携程机票订单缓存系统实践

作者简介

Chaplin,携程资深PMO,平时喜欢解决系统相关的问题,包括但不限于分布式/大数据量/性能/体验等,不畏复杂但更喜欢简单。

本文旨在分享携程机票后服务订单处理团队,在构建机票订单缓存系统过程中的一些思考总结,希望能给大家一些启发或帮助。通篇分为以下七大部分:背景,瓶颈,选型,架构,方案,优化,总结,文章概要如下图:

一、背景

近些年随着携程机票业务的不断发展,用户量和订单量也稳定地增长,再加上用户访问入口的多样性、机票的有效期特别长等特征,导致查询流量不断增长。这些,给基于强依赖订单数据库的订单查询系统带来了不小的压力。

不仅业务上带来的流量压力,技术改造也带来了更多的流量,如微服务化的推进、机票前后台订单业务解耦,不可避免地使得订单查询系统被依赖程度进一步提高。而且我们没有使用类似GraphQL 的技术,之前的前后台服务直连数据库,现在使用查询API,没有高度定制化产生了数据冗余,带来额外的查询压力。

作为典型的用户订单详情查询服务,承受着越来越重的压力,峰值时期订单详情查询QPS数倍于同期订单TPS。

为了保障用户的使用体验,支持机票业务的持续发展,保证机票订单查询系统的稳定高效,构建和不断升级机票订单查询系统,自然成为重要且紧急的事情。

二、瓶颈

大部分应用系统的瓶颈都会出在比较慢的地方,如外部资源及磁盘IO或数据库,订单系统的瓶颈显而易见,重复高频的订单数据访问,最终带来的是订单数据库访问的压力。

尽管之前通过数据库主从复制、读写分离的手段,一定程度上缓解数据库的压力。但是从节点的增加无疑会带来一些不得不考虑的问题,包括数据同步时的AG延迟问题,数据库服务器的运维成本等。

不仅局部优化有瓶颈,而且我们一直认为局部的优化,不如总线级别的优化来的更有效率,数据库总线跟内存总线对于订单系统数据存取速度的提升并不是一个级别。

基于以上种种,机票订单后处理团队,决定构建一套相对完整的基于内存数据缓存体系,不仅用于缓解机票订单数据库的访问压力,也用来提高订单查询服务的稳定性和容灾能力。

三、选型

目前各种类型的缓存都活跃在成千上万的应用服务中,还没有一种缓存方案可以解决一切的业务场景或数据类型,我们需要根据自身的特殊场景和背景,选择最适合的缓存方案。

目前业内用来做缓存的,通常主要有以下几种:Memcache、Redis,MongoDB。关于几者的优劣对比网络上也有不少的相关文章,这里不再详细列举。

选型并非是一个固定不变的标准,虽然系统设计没有银弹,但是路就在脚下,我们必须要结合自己的业务场景及运维能力做出综合判断(取舍),这里说明一下我们关注的点。

(1)故障快速恢复,机票订单的查询服务面临的QPS较高,又是许多上层应用服务的依赖项,由于业务比较核心,所以数据安全问题必须考虑。因此第一要求故障快速恢复,携程的Redis部署是多组多实例多机柜,完全满足需求。

(2)性能要好,机票订单数据的查询,超过92%的场景都是根据订单号的查询,场景比较单一,很少有使用索引条件的缓存查询需求。单一机票订单的数据量并不多,所以要求的缓存性能越高,用于查询集群机器线程池效率越高,整个查询集群机器越少,硬件成本及维护成本才会变低。

(3)输出稳定,缓存性能稳定,查询集群的线程池运行越稳定,尖刺就会少,整个系统稳定性会提高。

我们综合机票订单业务场景,采用了Redis作为机票订单缓存的存储介质。

因为Redis的部署特性,要求我们的单个实例不能太大,为了避免实例太大,我们前期做了很多工作,比如容量评估及预测,通过拆分+压缩来规避容量问题。

四、架构

在携程机票的微服务化架构体系之下,按照业务层级的不同,后台服务呈现层次化调用的树状结构。

比如订单详情服务输出机票订单的状态、价格、航班、乘客等各类基本信息,同时依赖于机票的改签业务、退票业务、航变业务,支付业务等提供的查询服务,而这些独立的子业务与订单详情服务本身一样需要订单基本信息数据作为业务支撑。为了避免循环依赖,需要把订单基本数据查询业务剥离出来,单独提供底层数据访问服务,同时支撑不同业务模块。所以机票订单后服务团队依据服务的层次设计了两级缓存的体系,如下图所示。

(1)一级缓存

一级缓存是订单查询服务响应结果级别的缓存。订单查询服务作为一系列核心服务,支撑了不同前端渠道的订单查询请求,包括APP,Online、H5等,同时也支持后台订单处理的各个环节业务对订单数据的查询需求。

订单查询服务的请求QPS很高,对同一个订单的请求在1秒之内有时会达到十几次。对于许多业务场景来说,1秒的时间内订单的数据基本不会发生变化,因此完全可以将该订单的整个订单查询服务响应结果缓存起来,设置秒级别的过期时间。这样做一方面能够提高服务的响应速度,另一方面能够减少对底层数据库服务的访问,降低数据库的压力,预防意外的或者恶意的流量冲击。

(2)二级缓存

二级缓存是针对订单数据库热点数据表的数据的底层缓存。根据对机票订单业务的梳理和对数据库数据表的访问频率的统计,我们筛选出了80+的热点机票订单数据表,对每个表的数据建立缓存,并提供数据查询服务接口。

这样一来,将不同业务方与订单数据库解耦,统一的数据访问服务层更有利于统一建立和管理数据缓存系统,提高缓存数据的使用效率。同时对底层数据库的垂直拆分和水平拆分,做到数据使用的业务方无感,提高了系统的扩展性。

五、方案

缓存数据一致性的实现方案,一般可以分为两大类:主动式和被动式缓存。

主动式缓存就是提前将可能访问到的数据加载到缓存中,然后设置过期时间,周期性刷新。等到需要查询数据的时候,可以优先命中缓存。这种方式比较适合于数据量不大,变化不频繁的场景。

被动式缓存是指每次查询时先从缓存中查询数据,没有则查询底层数据库,然后把数据库的查询结果加载到缓存,并设置一定的过期时间。数据过期后,当再次遇到查询请求时重复前面所说的过程。被动式缓存具有延迟加载的特性,也就是只有真正被访问过的数据才会被加载到缓存中,能够更有效的利用缓存数据。

机票订单的数据热度具有阶段性,用户订单成交的前后阶段订单查询频率较高,用户的历史订单查询频率就很低了。因此我们采用被动式加载的方案,随着时间的推移,老的历史订单数据会自动逐渐过期,新的订单数据被逐渐加载。缓存数据的总量保持平稳,同时避免了老旧数据的清理。

分布式缓存架构的数据一致性是个难点,这也是订单处理团队一直在努力思考并解决的重中之重。缓存数据意味着数据的变更在过期时间之内是被忽略的,可能与实际的数据库数据有一定差异。一般情况下,一种方案是尽量设置较短的过期时间,以使缓存数据尽快与数据源同步。但是较短的过期时间带来的是缓存命中率的大大降低,削弱了缓存数据的效果。

为了解决数据一致性问题,我们设计了两套方案来更新缓存数据,保证缓存数据的实时性。两套方案相互补充,避免其一短暂失效而产生一致性问题。

方案一的思路是扫描数据库的数据变化记录,比如数据表里面的数据更新时间戳,或者订阅数据库的binlog记录。当扫描到数据变化时,删除对应的缓存数据。

方案二的思路是借助于携程的消息通知平台服务。在订单处理流转的各个重要环节,会有消息事件产生。通过订阅这些消息,能够感知到订单数据的变化,并且通过对不同消息的影响数据范围可以精准地配置缓存数据更新。

两套方案的并行实施,保证了缓存数据的延迟控制在了100毫秒以内。

缓存的构建过程中,我们也遇到几个必须慎重处理的关键点:缓存穿透、缓存击穿、缓存雪崩。

(1)缓存穿透的解决方案(空标记)

缓存穿透是指,在数据存储系统中不存在的记录,不会被存储到缓存中。这种记录每次的查询流量都会穿透到数据存储层。在高流量的场景下,不断查询空结果会大量消耗数据查询服务的资源,甚至在恶意流量攻击下可能拖垮数据库系统。

以机票订单为例,有些订单购买了保险,也有的订单没有购买保险的记录。没有保险产品的订单每次查询时都会从数据库中查到空的结果,命中不了缓存,消耗了大量的Batch Request。

针对这种情况,我们采取了设置空标记的措施,针对查询正常返回但是记录为空的数据,将特定的空标记写入缓存,避免了这部分流量穿透到数据库。

(2)缓存击穿的解决方案(分布式锁)

缓存击穿是指,对于一个热点KEY,在其失效的瞬时,如果有大量的对这个KEY的请求,都会到达数据库。

我们通过分布式锁的方式,在缓存数据失效的时刻,仅允许单一请求读取数据库数据并加载到缓存。

(3)缓存雪崩的解决方案(随机时间差)

缓存雪崩是指,在同一时间有大量缓存一起失效。

我们使用了一个简单有效的方法,对不同的缓存数据设置过期时间时,采用不同的时间,比如增加一个小的随机值。

此外,由于不同订单处理过程中的消息发生时间不同,而订单处理过程一般会有等待队列,处理时间会有差异,也降低了缓存同时失效的风险。

六、优化

做完以上这些,应该算个及格的缓存系统,但是我们相信更优秀的系统是通过一步步的迭代及演进出来的。机票订单缓存系统的优化,必然要与机票订单的自身特征深度吻合,高度定制化,才能发挥更加有效的作用。

我们通过对携程机票订单以及机票用户行为的大量数据分析,制定了一些针对性的优化措施。

(1)全天候分段缓存过期策略(按时间)

根据对机票订单系统每日实时订单量数据的分析,实时订单量呈现双驼峰的形态,上午9点到11点以及下午1点到4点之间这两个时间段内,订单量处于峰值状态,中午处于相对的低谷。而夜间订单量为逐渐下降的趋势,直至凌晨1点到凌晨3点达到最低点。这与人们的出行作息时间是非常吻合的。

在订单量的峰值时段,数据库访问压力比较大,反之低谷时段则压力较小。对此我们为订单查询服务的一级缓存设置了分时段的过期时间策略,业务高峰时间段缓存时间相对调整变长,低峰时段调整变短。每个查询服务可以单独配置这套规则,随时动态调整。这样对于数据存储端压力来说也就是起到了削峰填谷的作用。

(2)订单状态分类策略(按业务)

机票订单根据业务定义有各种不同的状态,表示着订单处理流转的各个阶段。基于对订单状态及其后续变更行为数据的分析,对于已退票或取消的终态订单,其订单数据基本不会再发生变化。这类订单的缓存过期时间可以设置的相对较长;对于已过起飞时间的订单,用户再查看访问订单数据的概率也大大降低,其订单数据缓存时间也可以相对长一些;而订单处理中未出票等过程态订单,短时间状态变更的可能性很高,因此不应该放入缓存之中。

(3)用户行为的订单访问预测(预加载)

根据统计,用户乘机前的几个小时内,出于关注出发时间、机场航站楼、航班变动,办理登机手续等需要,会频繁查看订单数据。数据访问的压力集中在了白天的峰值时间段内。

基于这种对用户行为的预测,我们考虑了在前一天的凌晨业务低峰阶段,将第二天业务高峰时间段内起飞的订单数据,提前加载到缓存中预热,将高峰时间段的一部分缓存构建压力,转移到了低峰时间段,既提高了低峰时间段的系统资源利用,也缓解了高峰时间段数据库的压力。不仅如此,更大的好处是,第二天订单数据库即使有短暂的故障,用户查询当天出行订单也不会受到影响,因为数据都已经提前构建在缓存里了。

七、总结

经过查询系统的不断迭代,埋点数据的统计显示,订单缓存数据的命中率达到了90%以上,也就是说对数据库的访问压力降低到了原来的十分之一的水平。

从数据库层面的性能统计监控来看,数据库服务器的cpu利用率峰值由60%降低到了15%,均值由20%以上降低到了10%以下。对数据系统的可用性来说改善效果非常显著。

在服务响应性能方面,缓存系统的建立也带来了性能的提升。以订单详情服务为例,服务响应时间(PT99)均值由150ms降低到了120ms以下。

以下列举一些保证性能及可靠性的具体技术点:

1)采用了Redis Pipeline技术,节省了建立链接的时间。

2)采用了GZIP压缩技术,节省4-5倍的Redis存储空间。

3)采用了NIO技术,让我们的缓存服务支撑更多的链接。

4)采用了Hystrix熔断技术,保证了单个Redis实例异常时候,可以放弃该实例,保证其他正常读取,保证高可用。

整个缓存系统的构建,没有侵入到复杂的机票业务中,更像一个“外挂”系统,工作量及业务风险可控。除缓存系统外,未来的订单查询业务核心挑战依然是稳定性,包括故障的快速定位,调用链的梳理及简化。

正文完