一、诞生
时光回到2016年,那时候阿里推荐系统的核心召回逻辑还称为"u2i",即离线计算好user到推荐item的关系,在线仅负责简单查询。在"u2i"模式中,受限于计算能力和产出时间的要求,无法将全网user与i2i全量表完整关联,通常都需要将i2i全量表前置过滤、截断、然后再与user行为表join产出u2i表,最后回流线上iGraph提供查询。这是一种重离线轻在线的架构,主要存在几大问题:一是浪费离线计算资源,每一个业务接入、每一次效果迭代,都需要针对所有user做一次全量计算;二是浪费iGraph在线存储资源,每一个业务的每一种策略,都会存在一张完整的u2i表,数据规模巨大;三是迭代周期慢,每一次截断和排序策略的调整,都要经过全量离线计算和数据回流、等待几个甚至十几个小时以上。2016双十一过后,推荐算法团队和搜索工程团队合作,启动了推荐offline2online项目,希望通过将笨重的u2i离线计算搬到在线,达到节约整体资源、加快迭代效率、提升召回计算能力的目的。经过三个月的协作努力,项目成功发布,推荐召回系统终于从老牛车改造成了小汽车,也是在这个项目中,借助DII的管控平台和服务框架,针对召回逻辑的抽象和封装,让我们产出了BasicEngine这个推荐在线召回引擎(后面简称BE)。简单来说,BE是一个典型的多列searcher+proxy的架构,各种i2i/s2i/b2i的召回(search)、合并(union)、关联(join)、过滤(filter)和排序(sorter)均在searcher本地完成,最后经过proxy的合并排序(merge sorter)返回出去。BE的整体架构图如下:
经过半年多的努力,手淘首焦、首图、卡片、清单、生活研究所、购后链路猜你喜欢、猫客猜你喜欢等大场景都成功接入了BE,既提升了迭代效率又拓展了优化空间,均取得了不错的业务效果提升。在过去的2017双十一中,BE也成功经受了大促需求频繁变更和峰值流量压力的双重考验,收获了第一个完美的双十一。下面的篇幅,就简单给大家汇报一下BE这半年多来的演进,同时这也是对DII/BE平台"从一到一百"能力的一次验证。
二、演进
1. 配置化
BE的searcher上的核心召回流程是多表的union和join操作,一旦union/join操作的树结构可以灵活配置,就可以适配绝大部分场景的业务需求,因此我们做的第一步改造就是让BE支持可配置的执行树结构。一起来看一段配置文件样例:
<query type="join" join_field="item_id">
<sorter name="aio_biz_top_sorter">
<scorer name="aio_rule">
<init_params>
</init_params>
</scorer>
</sorter>
<left_query>
<query type="union">
<merge_strategy name="type_based" uniq_field="item_id">
</merge_strategy>
<filter name="notin_filter">
</filter>
<children_query>
<query type="table" table_name="hpaio_i2i_be" table_type="kkv" query_field="item_list" degrade_seek_count="200" match_type="1">
</query>
<query type="table" table_name="hpaio_i2i_be" table_type="kkv" query_field="off_item_list" degrade_seek_count="800" match_type="2">
</query>
</children_query>
</query>
</left_query>
<right_query>
<query type="table" table_name="hpaio_1111_content_info" table_type="kkv">
<filter name="aio_content_filter">
</filter>
</query>
</right_query>
</query>
对应的执行树结构如下,与配置文件的映射关系应该是一目了然的了。
aio_biz_top_sorter
|
|---------- join -------- |
| |
not-in-filter |
| |
union |
| aio_content_filter
|-----|------| |
| | |
hpaio_i2i_be hpaio_i2i_be hpaio_1111_content_info
- 注:hpaio_i2i_be出现两次是通过不同的trigger来查询的,分别对应了实时trigger和全量trigger.
BE的proxy上除了对searcher返回的结果进行merge sort之外,另一个常见的操作就是访问iGraph上的曝光或者购买类目等user维度的表,然后将返回结果传递到searcher上做曝光过滤或购买类目过滤。不同的业务在访问的iGraph表名、过滤条件、返回字段上都有差异,因此我们也将iGraph的访问和结果解析做成了配置化的方式。还是来看一段示例感受一下:
<!-- igraph请求列表 -->
<igraph_query_list>
<igraph_query name="xxx_user_bought_leafcast_igraph">
<table_name>xxx_user_bought_leafcast_igraph</table_name>
<pkey>${user_id}</pkey>
<range>5000</range>
<return_fields>leafcat</return_fields>
</igraph_query>
<igraph_query name="TPP_user_exposure_log_for_all">
<table_name>TPP_user_exposure_log_for_all</table_name>
<pkey>${user_id}</pkey>
<filter><![CDATA[scene="3553"]]></filter>
<range>5000</range>
<return_fields>item_id</return_fields>
</igraph_query>
</igraph_query_list>
<!-- igraph返回结构解析列表 -->
<igraph_result_parser_list>
<igraph_result_parser type="send_searcher_request">
<query_name>xxx_user_bought_leafcast_igraph</query_name>
<need_hash>false</need_hash>
<id_seperator>|</id_seperator>
<result_field>leafcat</result_field>
<search_query_field>filter_leafcat_list</search_query_field>
</igraph_result_parser>
<igraph_result_parser type="send_searcher_request">
<query_name>TPP_user_exposure_log_for_all</query_name>
<need_hash>true</need_hash>
<id_seperator>,</id_seperator>
<result_field>item_id</result_field>
<search_query_field>exposed_list</search_query_field>
</igraph_result_parser>
</igraph_result_parser_list>
这段配置的含义是以request中的user_id参数值为key请求iGraph的xxx_user_bought_leafcast_igraph和TPP_user_exposure_log_for_all,分别返回若干leafcat和item_id,然后将这些leafcat和item_id按指定方式加入到传给searcher的request中去,便于searcher上获取参数值并使用。
BE的searcher/proxy上分别支持了这两种配置化方式之后,接入一个新场景的主要工作就聚焦在filter/sorter插件逻辑的编写上了,为了更大程度减少接入成本,我们的目光又投向了filter/sorter插件的通用化。
2. 通用插件
- 基于集合的过滤
BE内置支持in/not-in filter,无需编码,类似曝光或购买过滤这种需求直接配置使用即可。 - LR/GBDT算分
BE内置支持LR/GBDT模型算分功能,用户只需要根据场景导入模型并配置好fg文件即可使用。BE上主要进行的是召回之后的粗排序,一般到LR/GBDT这种复杂度已经足够用了,如果有更复杂的深度模型,可以在精排阶段的RTP上实现。 - trigger信息透传
在很多场景中,都希望将trigger的信息透传到粗排算分过程中,BE内置支持这个功能,只需要配置上透传字段的名称和类型,就可以获取到request中带过来的透传字段信息。假设request中的trigger格式是trigger_id:trigger_score:timestamp:count,则可以按如下格式配置透传信息:
<extend_key value="key"/>
<extend_fields>
<field name="trigger_score" type="float"/>
<field name="trigger_timestamp" type="int64"/>
<field name="trigger_count" type="int64"/>
</extend_fields>
- 打散排序
从目前接入的场景来看,大部分场景的粗排序都可以归纳为打散排序,我们也正在开发一个通用的打散排序插件,这样类似"每个类目下最多出3个"这种需求也无需额外写代码来实现了。
3. 实时更新
在离线u2i的方式下,实时更新几乎是不可能完成,但是在BE上进行实时更新却变得非常简单,依赖DII平台底层的搜索技术体系,无论是i2i关系对还是精品库/素材库的新增、删除、更新,都可以轻松做到秒级生效。双十一大促中,我们就有多个场景将这种能力封装暴露给运营同学,让他们可以实时干预精品库/素材库,轻松应对大促紧急需求。
4. 性能优化
作为底层的在线召回引擎,高性能毫无疑问是我们的基石。还记得BE刚完成在猜你喜欢场景上线的时候,按照当时的性能评估,如果要支撑首页、购后等场景的双十一流量,预估需要8w+ core的计算资源,但是经过我们持续不断的性能优化,双十一当天实际使用的计算资源是4w core。
4.1 存储引擎升级
在BE上线之初,i2i关系是以倒排表的方式存储的,虽然功能上是支持了根据triggr-id查询得到triggered item-id-list,但是性能很差。由于i2i关系只需要支持一种查询方式,用不到复杂的and-or组合查询,k-k-v才是更合适的存储格式。k-k-v存储上线之后,BE在i2i查询性能上提升了20%+,在某些右表(素材和宝贝之间也是一对多的关系)的查询上更是提升了100%以上。
4.2 通信协议升级
通信协议涉及两方面,一是BE proxy与searcher之间的协议,二是BE与调用方TPP场景之间的协议。
在第一版中,BE的proxy/searcher之间通信使用的是明文query的方式,简单来说就是proxy上把请求按照kvpairs的方式拼接成字符串发给searcher,searcher再去执行url decode和kvpairs split,典型场景的query长度通常都会达到几十k,这种方式显然是非常低效的。所以在后续的版本中,我们把searcher改成了arpc server,proxy/searcher之间的通信也改造成了基于protobuf的rpc协议,这样一来就把协议本身的overhead降低了很多,整体性能也有了很大提升。
BE的proxy与TPP场景之间默认的结果输出格式是json,json格式对调试非常方便,但是当数据量增大到一定规模之后,性能消耗也是非常明显的,因此我们又新增了一种基于flatbuffer协议的格式,flatbuffer是一种非常高效的序列化/反序列化协议,详情可以参考google flatbuffer,在实际场景测试中发现,当返回结果大小超过2000个以上的时候,flatbuffer相比json可以将BE的序列化性能提升50%,TPP场景的总体性能可以提升30%。
4.3 异步化改造
异步化改造主要是针对BE的proxy而言,在早期的版本中,proxy与searcher的交互模式是:一次性批量发送所有请求给searcher,然后再同步等待所有请求返回。这种同步等待的模式带来的后果是,需要配置非常多的工作线程才能提升proxy的单机qps,并且由于大量线程带来的切换代价,proxy的cpu也很难压上去。因此我们基于SAP的异步框架将proxy访问searcher改造成了异步模式:一次性批量发送所有请求给searcher,然后让出线程,等待所有searcher请求返回后再重新调度起来运行后续流程。异步化改造之后,proxy的线程数量大大降低,并且cpu可以压到90%+以上,服务rt也更加平滑。
4.4 执行流程细节的优化
半年来我们对BE的执行流程做了很多细粒度的优化,这里不做一一介绍,简单聊几点优化过程中的心得。1.高频路径上避免使用stl string
,stl string
是一个非常好用的数据结构,尤其是面向用户的接口上,百分百友好。但是在一些高频路径上,string的频繁构造和析构很伤性能,要尽量避免,多考虑使用(const char* + size_t)
或者int64_t
替换string
。举一个实际的例子,BE的逻辑中经常会涉及到将一个字符串const string
切割成多个子串,然后将子串解析成数值类型的逻辑,粗犷的做法都是先split得到vector<string>
,然后再挨个转换,其实这个vector<string>
的代价是非常高的,相反如果我们仅做一次拷贝得到string_copy
,然后在string_copy
上原地使用\0
切割并做类型转换,可以获得很大的性能收益。2.让CPU少干活,能用static_cast
就不用dynamic_cast
,能用float
就不用double
,能用int
就不用float
。3.点滴汇成海,越是细节的地方越不能忽视,写代码的时候要清楚知道每段逻辑的调用频率,合理在性能和简单之间做取舍。
系统实现层面的优化重要性不言而喻,但是业务方数据层面的优化也是不可或缺的,并且往往能取得立竿见影的效果,举个手淘首页卡片场景的例子,在指标无损的前提下,算法同学对数据的精简和优化一次就可以提升30%以上的性能。所以,任何一个系统的性能优化效果,都应该是系统和业务结合达成的。
5. 向量召回
在过去的半年中,BE在功能上的最大改进就是支持了向量索引的构建和召回。我们深度集成了开源的KNN库–FAISS,改造定制使其支持向量索引的分布式构建和查询,并融合到搜索的技术体系中。向量召回作为BE中一种新的召回策略,可以灵活地与其他召回策略组合使用。向量召回也在手淘猜你喜欢、猫客猜你喜欢等场景成功上线使用,支撑了算法同学在DeepMatch上的探索,并取得了不错的成绩。
6. 自动降级
自动降级是系统保命的绝招,尤其是应对推荐这种可能出现无法预测的流量凸起的场景,如果没有自动降级,系统可能会在瞬间崩溃掉。借鉴HA3引擎的成功经验,BE的proxy/searcher也都开发支持了自动降级功能。proxy的自动降级主要依赖于gig,一方面可以根据服务能力合理分发流量将多行searcher的cpu打平,另一个方面可以在searcher负载过高时选择随机丢列,减轻searcher整体负载。searcher的自动降级策略主要是在高负载的情况下减少召回的文档数,从而减少计算量、减轻cpu压力。BE上线了自动降级功能之后,可以轻松应对比正常峰值高出一倍的压力,这也让我们在searcher CPU跑到90%依然镇定自若。
三、2017双十一
2017双十一,BE支撑了手淘推荐首焦、首图、卡片、生活研究所、猜你喜欢以及Matrix内容/珠峰调控等场景的召回业务,入口叠加峰值接近30w、峰值最大延迟低于35ms、峰值cpu整体利用率达到55%,单场景最大i2i表100亿+、召回宝贝数3w+,单场景最大返回结果数4000+。整个过程如丝般顺滑,不但大促当天的素材和业务预案调整轻松面对,还通过各个场景的扩缩容始终保持着系统整体水位的合理水平。
四、未来
过去的半年,是BE支撑业务架构升级、快速粗放发展的半年,接下来我们会做深做细,紧密结合业务需求,从提升体验、增强功能、优化性能三方面入手继续前进。1.提升体验,功能组件化、配置web化,都可以极大降低用户的接入、开发、迭代成本,是我们需要花大力气去完善的基础设施。2.增强功能,从最开始的i2i召回,到逐渐铺开的向量召回,推荐的召回逻辑也在演进中,BE会随着业务需求提前做好技术储备和系统支撑,帮助业务探索和尝试。3.优化性能,追求极致性能永无止境。