作者简介
熊聘,携程国际事业部公共研发团队Leader,目前主要负责国际化相关的基础组件和市场相关项目的研发。开源社区爱好者,喜欢阅读优秀的开源项目源码,对新技术有着深厚的兴趣。
最近一段时间一直在对原有的SEO项目进行重构,目前已经进入重构的后期阶段,想和大家一起分享一下整个重构的细节,希望对大家有所帮助。
一、什么是SEO项目
SEO(Search EngineOptimization),搜索引擎优化,利用搜索引擎(目前主要指Google)的规则提高网站在搜索引擎内的自然排名和网站的品牌影响力。
用户在搜索引擎上搜索相应的关键字,点击搜索结果直接跳转到SEO的着陆页(Landing Pages),然后通过Landing Pages将流量引到需要推广的网站,从而将这些流量转化成订单 。
SEO项目主要是根据不同的推广维度设计相应的Landing Pages,并为这些Landing Pages提供相应的数据,目前该项目主要涵盖携程酒店和机票两大产线,后续可能会接入更多的产线。
二、为什么要重构
原SEO项目主要存在以下几个方面的问题:
代码耦合:前端代码和服务端代码全部耦合在同个项目里面,在开发过程中相互依赖,页面信息模块化,可配置化,支持AB测试等一系列的功能都很难实现。
数据存储:SEO项目的数据和之前的其它系统存储在同一个DB中,并且部分数据表是共用的,必然导致某些表中的字段从SEO项目的角度来看是无用的但又不能去掉。
数据更新:数据全部更新完一次约2-3天,整个过程需要人工干预,如果更新过程中出现了任何问题需要重新进行全量更新,并且还存在脏数据 ,主要分为两类:一类是数据表中某个字段的值部分是正确的,部分是不正确的;另一类是数据不完整,比如:如果某个城市没有任何酒店或者机场,则这条城市数据是没有意义的,因为在做城市维度推广的时候,这个城市下面是没有任何酒店或者机场的数据的。
需求无法满足:在SEO页面的底部需要根据一定的规则计算相关的链接信息,计算某一个站点的某一个产线在某一种特定语种下需要的时间约为4小时,现有16个站点,每个站点有15种语种和有3个产线,计算出所有站点下所有语种和产线的链接信息需要的时间为16*15*3*4=2880小时(120天),显然目前的实现方案是无法满足业务需求的。
为什么会存在以上这些问题?主要原因如下:
需求迭代太快:IBU一直处于高速发展的状态,很多需求都是需要在很短的时间,快速的完成,并且对未来需求的变化很难把握,在做需求的过程中难免会选择一些短期的,尝试性的,快速见效的方案。
开发人员少:在重构之前整个SEO项目仅1-2个人来完成所有的开发工作,当需求源源不断涌现时,开发人员难免会措手不及,应接不暇。
数据复杂:目前SEO几乎需要与机票和酒店相关的所有数据,而这些数据的收集过程又是极其分散、复杂和繁锁的,收集某条数据时可能需要采集多个数据源的数据才能将这条数据中的所有字段补全,并且数据量大导致更新时间长,数据之间的关联性高从而导致数据更新过程中加大了保证数据完整性的难度。
三、技术选型
项目的技术选型对整个项目来说至关重要,这里主要表述的是服务端的技术选型。
开发语言:去掉了之前的部分代码采用Java实现,部分代码采用PHP实现的方案,将开发语言统一定为Java。主要原因是公司主推Java语言,团队中所有成员最熟练掌握的编程语言都是Java。
数据存储:在数据存储方面主要采用MySQL数据库,去掉了之前一部分数据采用ES存储,另一部分数据采用MySQL存储的方案。SEO项目中酒店的数据量最大,也仅千万级,对于MySQL来说是完全没有问题的。
RPC框架:公司提供了两种对外暴露服务的方式,一种是通过Baiji契约实现的,另一种是CDubbo。未选择后者的主要原因是当时刚推出来,在稳定性上可能会略差于前者,同时整个团队对前者的理解更深入,使用的也多一些,降低学习成本。
四、设计方案
SEO项目的整体架构如下图所示:
1、Vampire
主要是用来采集数据并转换成格式化的数据。采集数据的方式主要有增量和全量两种,数据来源可以是MQ、DB和API(后面还会接入更多的数据源)。其核心思想是通过并发的方式拉取来自不同数据源的数据并将这些数据进行转换成格式化的数据,然后调用Faba的Write接口将数据写入DB中。
由于全量数据的数据量较大,所以在整个过程中拉取全量数据最为复杂。
从目前来看更新全量数据绝大多数情况是采用调用API的方式,需要考虑被调用API的QPS、响应时间、更新一次的时间间隔、API的返回报文大小(有些情况需要考虑分页)、API的超时时间、Gateway超时时间、网络带宽、数据之间的依赖关系等,从而确定Vampire在调用API时的线程数 、调用频次、调用周期、调用时间(一般在非高峰期调用)、部署时的机器数量、虚拟机的CPU核数和内存大小等,针对调用不同的API需要对不同参数进行优化。
增量更新相对而言简单一些,主要采用MQ的对接方式,需要考虑先发送的消息后到达,后发送的消息先到达的情况、重复消息、消息丢失、MQ中队列的大小等。在整个拉取数据的过程中还需要考虑数据提供方可能出现脏数据或者无法支撑Vampire带来的流量,因此还需要支持暂停、恢复、强制更新等功能。
无论是增量还是全量的方式拉取数据,最后都需要转换成格式化的数据并写入DB,这个转换过程的处理速度至关重要,因为Vampire从整体上来看其实是一个生产者和消费者模型,生产者是接入的各种不同数据源,而消费者则是将拉取的数据进行转化然后调用Faba提供的写接口,快速完成数据的转换工作。
理想情况下应该是生产者的生产速度等于消费者的消费速度,当生产速度大于消费速度时,生产出来未被来得及消费的数据就会囤积在内存中,容易造成OOM,所以在实际使用的时候一般是消费速度大于生产速度。
而对于Vampire而言,生产者的速度是接入各数据源的流量之和,随着数据源的增加而增加,但是消费者的消费能力是固定的,所以要想提高整个数据采集和转化的吞吐量,本质上是要提高消费者的速度,也就是提高Faba的Write接口的速度(后面会详细讲解Faba处理数据的机制)。
目前生产环境部署了4台8核8G的虚拟机,Vampire的处理能力可以达到每秒10K ,处理1000W条数据耗时约30min。
2、Faba
该子项目主要是为整个SEO项目提供数据Read和Write操作。其中Write接口主要由Vampire调用,用来补充数据,Vampire将采集到并转换好的数据通过调用Faba的Write接口将数据写入DB,Read接口主要是对外提供访问数据的方式,由Service来调用。
Write接口主要是采用异步的方式实现的,Vampire在调用时会将数据先暂存到一个消息队列中,然后再来消费这些数据,这样处理的好处在于:首先提高了Write接口的QPS和响应时间,其次可以将一些相同的操作进行合并成批量的操作,从而尽量减少DB连接数的消耗,最后可以在写入时尽可能的对一个批次的写入的数据进行去重,减少不必要的写入操作。
Write接口的设计需要考虑三个方面的因素:
第一、支持幂等。因为写入的数据来源于消息队列,消息队列会有重试的机制,所以在写入的时候需要支持幂等。
其实消息队列也不能保证数据是有序到达的,数据是否有序到达仅对增量拉取数据有影响,对于全量拉取数据没有影响,因为在全量拉取数据时,每条数据当且仅当只会被拉取一次,所以对每条数据的更新操作是相互独立的无需考虑先后顺序。
对于增量拉取数据而言,假设一条城市数据在同一时刻先后将城市名称从A修改到B,再从B修改到C,这两条更新的操作会被有序的推送到Vampire,然后再由Vampire转换成格式化数据后调用Faba的Write接口,从消息队列中消费这两条数据时可能会先收到城市名称从B修改到C的数据,后收到从A修改到B的数据,这时会以两条数据发生修改的时间做为时间戳,在DB中更新数据时只更新当前时间戳大于这条数据在DB中的更新时间,其余的全部过滤掉,也就是城市名称从B修改到C的数据会被更新到DB,从A修改到B的数据会被过滤掉。
第二、消费速率。很容易看出在整个写入的过程中的瓶颈是DB的写操作,公司DB的连接池大小是100,也就是说通过多线程来消费消息队列中的数据,线程池的大小不要超过100,确定了消费者的消费能力,生产者的生产能力只需要通过简单的计算就可以确定了,理论上只需要将生产者单位时间内生产数据的总量等于消费者线程数100*每个批次内数据平均条数,这只是一个理想的情况。
实际情况可能还需要考虑三个因素:
1)消息队列的大小,也就是囤积数据的能力,这个与机器内存有关;
2)可接受的数据延时时间,也就是一条数据从进入消息队列到写入DB的时间;
3)IO的处理能力,往DB中写数据会产生大量的IO操作,特别是在进行批量写入操作时,之前由于这个因素没有考虑到,导致和SEO的DB在同一台物理机器上的其它DB的以前正常的读写操作出现大量的超时告警。
第三、数据优先级,Vampire会从不同的数据源来拉取数据,不同的数据源会提供某一条数据中的若干个字段,不同的数据源的数据质量也会有所不一样,也就是不同数据源对同一条数据中的若干个字段有不同的优先级,优先级高的数据质量高,这个优先级是在接入数据源时定义的,所以在更新数据时还需要根据数据的优先级来判断数据是否更新,目前同一条数据的同一个字段的数据源只有一个,所以可以先不考虑这方面的问题。
Write接口的性能
Read接口目前主要是从DB中读取数据,其性能主要取决于以下两个方面的因素:
第一、数据库表结构的设计,在设计时尽量减少数据的冗余,将原来的每张数据表垂直拆分成多张数据表,根据业务需求建立好索引,让每一条查询的SQL语句都走索引,对于复杂的SQL查询,拆分成多条简单的SQL,然后让每条简单的SQL都命中索引,并且将这些简单的SQL尽可能的复用,如果某一条SQL查询出来的结果会比较大需要分页,这时会通过对SQL的执行进行解析,确定出合理的页大小,对于复杂查询和分页查询多数据情况下都是通过执行多条简单的SQL将返回的结果通过程序组装的方式完成的。
第二、接口的设计,对外的Read接口在设计时也是尽量的简单,这里的简单包括入参简单和返回值简单。
入参简单指的是调用接口传入的参数尽可能的少且传入的每个参数都是必要的,例如:某一个接口有A、B和C三个参数,假设通过A和C这两个参数可以间接推导出B这个参数,这时B这个参数就是没有必要的,应该去掉;
返回值简单指的是返回的报文不要太多,在设计时一般小于4KB,同时返回的报文中的数据字段都是有用的。
在整套接口拆分的过程中还需要考虑两个重要的因素:
1)所有接口通过若干次的组合调用是否可以获取DB中的所有有用数据;
2)完成一个特定的功能需要调用多个简单接口的次数尽可能的少,尽量多调用响应快的接口,少调用响应略慢的接口 。
在单机4核4G,Tomcat连接数200,DB连接数100的环境下,数据量为1KW 时,Read接口直接访问数据库,不走缓存,对于简单的查询QPS最高可以达到1400 ,对于复杂的多条件分页查询QPS最低可达到400
Faba中的缓存分为本地缓存和分布式缓存两种。
对于本地缓存主要存储一些数据体量小,访问频次高,数据不一致性要求低的数据;分布式缓存主要是通过Redis作为载体来实现的,存储一些数据体量相对较大,value小,访问频次高的数据。
同时在缓存数据时对数据量小的数据尽量做到全量缓存,定期更新,对于数据量大的数据采用LRU淘汰策略来更新缓存,在缓存空间固定的情况下,提高缓存命中率。由于根据目前的需求来看仅通过直连DB的方式达到的QPS已经可以满足了,所以开发缓存的优先级较低,目前还在开发过程,接口性能方面的数据暂时还不能给出。
3、Service
根据对业务需求的分析发现,每个产线的SEO页面都是由若干套页面组成的,每套页面都是从不同的角度来推广,每个页面由若干个Module组成,一个Module对应一个接口。
以机票为例:机票的SEO页面会包含出发地和机场这两套页面,出发地这套页面由A、B和C这三个Module组成,机场这套页面由B、C和D这三个Module组成,这时仅需要开发4个接口分别实现A、B、C和D这4个Module对应的功能即可,可以很好的提高接口的复用性。同时,也可以通过配置让一个页面中的某个Module在不同的语种、币种、城市等维度中展示不同的数据。
4、Page
该项目主要由前端团队负责,这里不做详细描述。
5、Portal
主要由4个模块组成,其中Config模块可以根据不同的语种、币种等条件进行配置来控制Service中的各接口在不同参数情况下的返回结果、排序方式等;Log模块主要用来记录Vampire数据更新的进度、更新时长和日志等;
AB Test模块主要是配合Config模块实现不同配置之间的对比,从而帮助业务人员更好的做出抉择;Statistic模块主要用来统计Faba中缓存的命中率等性能方面的数据。
四、总结
SEO项目的核心在于数据,如何采集数据,更新数据,将质量较好的数据在每次的更新中逐渐沉淀下来是整个项目的关键;接口、数据表设计的尽量简单是提高整个项目性能的根本。
本文只是大致描述了一下SEO项目重构的整体方案,对于设计方案中的具体实现细节并未做过多的描述,同时有些非核心功能还在开发中,对此感兴趣的同学可以留言,也欢迎大家拍砖。