作者简介
陈少伟,携程度假研发部资深开发工程师,主要负责度假起价引擎的研发工作,喜欢钻研技术,对新技术有浓厚的兴趣。
背景介绍
携程度假为客户提供了非常丰富的旅游线路,每个旅游线路涉及到不同的出发地,不同的出发地下有不同可出发班期,每个班期都有对应的这一天的价格。旅游产品的价格由多个资源组成的,任何一个资源价格发生变化,都会影响到产品的价格。
为尽快捕捉到价格变化,我们不断优化调整架构,使得价格越来越准确,计算越来越快,同时也对被调服务及硬件产生了极大的压力,也带来了新的瓶颈。为了解决瓶颈再进行新的架构调整,如此周而复始,架构不断演变,每个版本的优化都带来准确率、计算速度的跃迁。
本文将按照我们主要架构调整的版本(引擎1.0、引擎2.0、引擎3.0)来介绍度假起价引擎的架构演变过程,以及过程中踩过的坑及优化思路,希望通过该分享,可以给公司同类项目以及行业同类需求提供一些启发和借鉴。
一、业务范围及名词解释
业务范围:如下图,引擎计算的价格包含“产品起价“和“班期起价”,这一部分都是离线计算的。
图1 度假起价引擎业务范围
任务单元:分以为下两种,
(i) 资源任务单元(以下简称为资源任务),指定产品、出发地、日期下的某一类资源价格的计算过程,存在于引擎2.0、引擎3.0中,一个班期任务单元可分为多个资源任务单元;
(ii) 班期任务单元(以下简称为班期任务):指定产品ID,出发地,出发日期下多个资源任务单元的组合,存在于引擎1.0、引擎2.0、引擎3.0中;
班期价格:指的是一个产品、出发地、出发日期对应的单人推荐价格,一个单人价格的组成则是该产品、出发地、出发日期下不同资源单人价格的加和。涉及的资源可能有:机票(大系统机票、度假采购机票)、酒店(系统酒店、手工酒店)、保险、邮轮、玩乐、门票、优惠等。
任务量:产品、出发地、出发日期3个维度相乘即为总任务量,引擎2.0、引擎3.0又把一个班期任务单元拆分为多个资源,平均一个班期任务单元可拆分为2到3个资源任务单元。引擎1.0、引擎2.0、引擎3.0的任务量大概如下:
(i) 引擎1.0:班期任务数接近3000W左右。
(ii) 引擎2.0:班期任务数在6000W左右。
(iii)引擎3.0:班期任务数在54000W左右;
限流:为保持系统的稳定运行而采取的自我限制手段,按限流方式分为两种:
(i) 外部限流(集群限流):无限制调用可能会导致内部或者外部接口崩溃,接口的访问量由外部接口统计并提供,超过一定额度即不再调用,当前线程休眠,以分钟为单位;
(ii) 内部限流(单机限流):经过引擎2.0,3.0的优化后,多个任务可合并为一个接口调用,当合并的量级越大,接口压力越小,DB(sqlserver,mysql,hbase等)的更新压力越大,在某些情况下会对DB产生很大冲击,因此而做的内部自我限制,使得对DB的调用更为平稳,以秒为单位;
准确率:如果引擎计算出来的价格和用户实际访问的价格差异在一个限定区间内则认为价格是计算准确的,否则是不准确的;
引擎模块:
图2 引擎模块
二、系统的核心和难点
核心:引擎的主要工作就是计算产品班期的价格,旅游产品的价格由多个资源组成的,任何一个资源价格发生变化,都会影响到产品的价格。尽快捕捉到资源价格变化,并准确体现到产品价格上是引擎的核心指标。
难点:随着业务发展,任务量不断上涨,在接口调用量被限的情况下(主要是外部门接口因业务原因需要限流),更准确及时的更新产品价格。
三、引擎1.0
总任务量3000W左右,只有班期任务,单个班期任务内各个资源串行计算,汇总为一个班期价格并更新到MYSQL数据库,同时把MYSQL价格相关数据通过JOB的方式同步到SQL SERVER数据库,生产的数据源为SQL SERVER。任务队列的载体为redis list,sortedset(.net封装);
主要存在问题:
(i) 任务队列:使用redissortedset(.net封装)做为优先队列来使用,当数据量比较大时,对sortedset的调用量达到1.2W/min时,会偶发出现网络异常,随着业务增长,数据量、调用频率也会同步上升,问题可能会不断放大。
(ii) 任务生成:任务生成部分涉及到了分组聚合排序,单机方式生成任务信息后发送到相关的队列中。在3000W任务的情况下暂无压力,随着产品数量、班期任务的增加,单机分组聚合排序是有一定风险的:
一、随着任务增长,会有内存瓶颈;
二、单机执行任务生成速度慢;
三、存在单点故障。
鉴于以上的问题,我们着手进行引擎2.0的改造。
图3 引擎1.0
四、引擎2.0
为解决引擎1.0中存在的问题,我们进行了以下的优化。
4.1 使用Hermes替换Redis
消息队列的选型,公司内部有使用的消息队列类型有Rabblitmq、CMessaging(内部框架)、Hermes(内部框架)等,结合业务场景、使用量以及运维成熟度等多方面考量,选择了Hermes。
4.2 任务生成优化
班期量从3000W增加到6000W,单机生成任务瓶颈明显,改进方案:
(i) 单机生成改成集群生成
(ii) 使用spark集群进行分组、排序、聚合并发送消息
图4 生成优化
4.3 任务计算优化
随着业务的发展,任务量从3000W至13000W,资源价格汇总存在明显瓶颈,通过按资源计算的方式加以优化,如下图:
图5 任务计算优化过程
由上图我们可以看到,原先所有资源计算完之后扔到同一个队列进行处理,任务量小的时候问题还不大,任务量大的时候就成了瓶颈。去掉资源汇总队列由各个队列直接处理,使得瓶颈不再存在,并提高了计算的效率。同时对不同资源进行解耦,各资源的计算频率可灵活控制。
同时,各个资源解耦的情况下方便对各自资源特点做定向优化。如下:
(i) 机票计算优化:机票由于外部接口业务原因进行限流,一轮基本需要1个星期才可以算完,价格严重不准,原因在于任务量大,而机票限流,所以我们并不能无限制的调用机票接口获取价格。
后来我们仔细的分析了国际机票的请求,发现机票请求的主要因素为出发日期、出发地、目的地,不同的产品对应的机票的规则虽然可能都不一样,但是对于同样出发日期、出发地、目的地来说对机票的请求却是一样的。
国际机票总任务1800W,但经过这样一处理,不重复的任务数却只有350W,这样相当于任务数压缩成不到原任务的1/5,而这个思路同样可以复用到国内机票(1/10)。
所以我们针对航线数据做了一个反向索引,以航线为key,不同的产品做为value,这样不同的产品但是同个出发到达机场及时间可以命中同一条航线的索引,通过索引可以减少大量重复的机票请求。
既节省了对机票接口的调用量,又使机票资源的计算速度大大提升,原先需要3天计算的任务,现在1天内可以完成,并且航线(出发日期、出发地、目的地)的组合是相对固定的,也就是说随着任务数的增加,航线的增加却相对要比任务数增加要缓慢得多,这样实际请求数是比较可控的。
图6 航线聚合
(ii) 酒店计算优化:通过了解发现,酒店资源的价格只跟目的地有关系,跟出发地是没有关系的,而原来引擎计算酒店的方式是按一个产品、出发地、出发日期来一个一个计算的,极大浪费了酒店资源的流量,平均一个产品有10个出发地,不同的产品也可能为同一个目的地,所以原来的计算方式可以根据目的地来进行聚合,聚合后对接口的调用量减少到了不足原来的十分之一,计算时间也由原来的2天到现在的8.5小时。
图7 目的地聚合
(iii)VBK计算优化:不同出发地配的资源基本都是一样的,但是按现有的引擎计算流程,是按单出发地进行计算的,那么有多少出发地,相同的资源价格就会被计算多少次,其实是一种重复计算。
把VBK产品拎出来单独处理,引擎的其他流程计算排除VBK产品,VBK产品的引擎计算以班期为单位,计算一个班期的价格推广到N个出发地,减少重复请求,并且VBK产品的库存、价格变动通过消息通过引擎进行价格更新。
如果按以前的大流程计算,VBK计算到一轮可能差不多得3天时间,而现在VBK产品计算可以计算4次,VBK产品现有班期总数大概有3000W多,占总引擎班期数近一半,该类型产品的速度优化对引擎的价格新鲜度有重要意义。
图8 出发地聚合
4.4 总结
引擎2.0优化后的效果:
(i) 任务生成速度:5小时至1.25小时;
(ii) 任务计算周期:2周至1.5天
五、引擎3.0
随着任务量增加,班期数从6000W增加到54000W,系统面临如下新的瓶颈:
(i)MYSQL数据库存在IO瓶颈
(ii) 任务生成后消息分发不及时
针对以上两个问题,我们启动3.0改造计划。
5.1 使用HBase替换mysql
随着计算量的增加,MYSQL数据写入存在IO瓶颈,更换SSD、分库分表后依然未彻底解决问题,考虑到引擎使用DB如下场景:写入多、查询少,查询条件简单,再加上我们是离线计算方式计算价格,比较适合NOSQL数据库的适用场景,在综合评估HBase的各项指标、开发成本、运维成熟度之后,基本符合我们的预期。
价格是引擎的核心指标,如果某次发布导致价格计算错误将产生严重的后果,为了防止最严重的后果出现,我们利用HBASE的多版本机制,当某次发布导致计算价格不对,可指定版本立即回滚回正确的上一版数据。
图9 价格快速恢复
5.2 任务生成优化
之前任务信息是以HDFS文件的方式存储,通过JOB方式读取并发送消息,存在问题:
一、单机消息发送慢,需要4小时发送完;
二、任务发送过程无法发布。
解决方案:通过spark在生成完任务就通过各个节点把消息发到各个资源队列,速度快且不影响发布。
图10 引擎3.0
5.3 热门任务策略优化
热门任务的策略原先是按过去三天访问过班期次数>3,按产品、出发地聚合任务并提升计算频率,按1天计算4次,总任务量大概是2000W左右。但按这个策略实际效果也并没有达到预期,因为过去3天访问量高的班期并不意味着这些班期的价格是不准确的,由此我们做如下优化:
(i) 策略调整:把过去三天访问过的价格差异超过过大的班期按产品、出发地聚合任务提升频率计算,1天计算3次,总任务量大概是500W左右,完成优化后,计算的准确率下限提升了2%左右;
(ii) 分批加载优化:通过数据采集,并对价格差异过大的产品做分析发现,机票、酒店所占价格差异超过10%的比例是最高的前2位,其中又以机票占比最高,进一步对机票的分析发现,机票很多的价格差异是由于分批加载导致的,因此如果可以减少分批加载造成的价格差异的话,那么必然可以提升起价的准确率。
(iii)即将要做的:按(i)优化的方式对用户的访问覆盖面并不高,实际上确有一定的局限性,假如用户的目的地是厦门,那么按(i)的优化方式只会去跑用户访问过的产品,但不代表用户不会去访问其他到厦门的产品,根据我们对数据的分析,如果按出发地、班期、目的地为维度进行聚合的话,计算对用户访问的覆盖面还将增大一倍;
图11 热门任务策略优化
六、优化结果
图12 引擎各版本准确率
图13 计算时间对比
七、总结
综上,以上的很多优化并不是孤立的,有很多优化都是并行着进行的。
一个优化做完之后,计算效率、准确率提升的同时,也带来了计算速度的增加,计算速度的增加同样会增加被调服务的调用次数,不管是对自身系统还是对被调服务各方面压力都是增加。
因此整个的架构确实是演化出来的,而并不是事先设计好的。架构是为业务服务的,业务上的需求决定了架构演化的方向。