当前位置: 首页 > 科技观察

异构数据半小时实现搜索功能,一个系统搞定

时间:2023-03-15 21:49:21 科技观察

背景对于闲鱼这样处于高速增长期的部门,业务场景正在快速扩展,越来越多的业务数据需要搜索能力。如果按照常规方式为每个业务构建独立的搜索引擎服务,开发和维护的时间成本将是巨大的。是否可以只用一个搜索引擎系统来支撑不同业务场景产生的数据?不同场景下的异构数据如何在同一个引擎中兼容?闲鱼根据实际业务需求构建了通用搜索系统解决了这个问题。搜索原理简述闲鱼使用的搜索引擎是阿里巴巴的HA3引擎,配合其上层管控系统Tisplus2使用。可以拆分为以下几个子系统:1.dump:访问搜索系统首先要做的就是将DB数据通过一些业务逻辑进行转换(后面会详细介绍merge和join的过程),根据将引擎BuildService能够识别的文件格式写入文件系统或消息队列,供BS建立索引。这个过程分为两种类型:完全和增量。2.BuildService(简称BS):将dump输出的数据构建成索引文件。检索机只有加载BS产生的索引文件后,才能提供倒排、正向、汇总查询服务。3、搜索服务网关:服务层封装了统一的服务接口,对服务访问方屏蔽了搜索系统的底层细节。4.SearchPlanner(简称SP):结合搜索中心的多种能力,调用算法服务对网关传入的查询字符串进行改写、分类、打分,实现多渠道召回、分层查询、分页转向去重等功能,包装并返回QRS返回的结果。5.引擎在线服务:分为两个角色:QRS和Searcher。SP的查询请求发送给QRS,QRS将请求转发给多个搜索器机器,然后收集搜索器返回的结果进行合并、打分、排序、返回。整个搜索系统的简化结构图如下:针对每个业务场景从头构建一个搜索系统,复杂且耗时。我们希望提供一个通用的搜索系统。当新的业务数据连接到搜索功能时,业务开发学生没有必要精通搜索系统的原理。只要将需要检索的数据注册到我们的系统中,就可以完成检索能力的自主化。Access几乎不需要开发,真正实现十分钟快速访问搜索。多个业务的数据共存于一套搜索引擎服务中,各个业务的数据相互隔离,互不影响。这里涉及到两个问题:①如何将不同业务db的异构数据写入同一个引擎建立索引,并且写入过程完全自动化透明,不需要业务接入方参与开发;②不同业务场景如何实现开发同学在使用查重的过程中,感知不到其他业务数据的存在,就像使用一套为自己的业务单独搭建的引擎服务一样方便。我们的解决方案针对的是上面遇到的两个问题。我们的方案是提前搭建一个通用的搜索系统,提前实现dump、bs、search、SearchPlanner、gateway层的基础能力。业务调用该服务时,通过设置可选的入参来选择你需要的能力(如关键词改写、类别预测、pvlog打印、分层召回等)。通过中间层自动化转储过程,对转储和搜索过程进行字段翻译和结果打包。基本搜索引擎服务的构建过程与常规方法没有太大区别,这里不再详细描述。下面详细介绍本方案实现过程中拆分出来的四个技术点:①通用搜索预约表;②元数据登记中心;③双层垃圾场;④在线查询服务。1、通用搜索保留表一般情况下,我们会将dump产生的又大又宽的表字段命名为itemId、title、price、userId等语义清晰的字段名。但是如果你想为多个场景共享一套引擎,就不能这样做了,因为并不是所有的场景数据都有itemId、title、price字段,在某些场景下可能需要访问color字段,但是我们的引擎没有定义这个字段,导致无法支持这个业务场景。既然问题的关键在于字段定义是有语义的,那么我们解决这个问题的思路就是让引擎中的所有字段完全没有意义,只有类型信息。我们预定义了如下图的2个维度,每个维度有2张Mysql表和2张ODPS表(这个定义已经可以覆盖大部分场景),称为通用搜索保留表。为每个预留表预留各种类型的字段,根据维度、表在维度中的位置、字段类型来命名字段:1)将第一个维度中第一个预留表的字段命名为dima_pk,dima_a_int_r1、dima_a_text_multilevel_r1、dima_dimb_joinkey等;2)将第一维的第二个保留表命名为dima_b_inner_mergekey、dima_b_int_r1、dima_b_int_r2、dima_b_long_r1等;3)命名第一个保留表在第二个维度中的字段命名为dimb_pk、dimb_a_int_r1等;4)将第二个维度的第二个保留表命名为dimb_b_inner_mergekey、dimb_b_int_r1、dimb_b_long_r1等;然后按照上图的结构使用保留表,和引擎原来的转储系统对接,配置索引构建信息。当这套引擎服务搭建完成后,如果直接访问通用搜索预订表中的几条数据,就已经可以从引擎的在线查询接口中查询到数据了。但是这个搜索系统对于业务开发的同学是没有的,因为业务源表的结果和我们预留的表结构完全不同,业务同学很难把源表的所有数据迁移到普通表上根据我们预定义的格式。商科学生在查询时搜索保留表,使用“dima_a_text_multilevel_r1='iPhone6S'”这种非语义的方式也是不能接受的。接下来我们将解决这些问题。2.元数据注册中心我们设计了一个元数据注册中心。当新业务需要接入搜索能力时,只需在注册中心填写业务相关的注册信息(包括业务场景标签、需要接入搜索能力的数据库等)。表名、字段等基本信息),系统会分配一个唯一的业务标识码,作为在dump、bs、query过程中实现多业务隔离的最重要标识。元数据注册表结构:元数据注册表提供以WEB界面形式注册新服务的能力。当用户填写业务的数据库名时,会自动通过中间件拉取所有包含的表名。用户选择自己需要访问的表名,界面会列出该表下的所有字段,以及系统预置的所有预留的通用搜索表字段。用户用鼠标在源表字段和保留表字段之间建立连接,完成后点击提交。系统会检查用户建立的映射关系的有效性。检查通过后,按照上面的元数据注册结构进行写入。进入数据库。这个注册数据以后会在dump、query等多个环节用到。3、二层转储中各个业务源表的语义一般比较简单,多张表的组合可以形成一个业务场景的全貌。例如:1)商品基本信息表会存储商品id、title、description、图片、sellerid等信息;2)商品扩展信息表会以商品id为主键存储商品扩展信息,如sku信息、扩展标签信息等;3)卖家基本信息表会以用户id为主键,存储用户的昵称、头像等基本信息;4)卖家信用信息表会以用户id为主键,存储用户的芝麻信用等级。在典型的搜索请求场景中,用户搜索“iPhone6S”。在搜索结果中,用户不仅希望看到商品的标题、描述、图片等基本信息,还希望看到扩展表中存储的sku、扩展名等。标签等扩展信息,以及卖家昵称、头像、信用等级等用户维度信息。如何在一次召回过程中将存储在多个表中的同一产品的所有相关信息全部返回?这就需要在转储过程中将多表数据按照一定的方式组织起来,组装成需要的宽表格式,然后写入引擎建立索引的持久化存储。在转储过程中,我们根据主键合并并连接与该业务场景相关的多个表。将同一维度的多张表按照主键组合成一张大宽表的过程就变成了合并。例如1)和2)之间根据商品id进行合并,结果记为M1;3)和4)之间是根据用户id做合并,记录结果为M2。这样一来,M1中有一列数据是卖家的用户id,M2的主键是用户id。根据用户id连接M1和M2,得到最终的大宽表。宽表中的任何数据都包含1)2)3)4)完整的场景信息。在构建通用搜索保留表的过程中,我们按照dima_pk+ima_b_inner_mergekey和dimb_pk+dimb_b_inner_mergekey方法做了内合并,按照dima_pk+dimb_pk方法进行了维间join,完成了保留表之间的连接和建造者服务。业务同学只要正确地将源表数据迁移到保留表中,就可以实现上述复杂的dump过程。数据迁移不仅要保证迁移源表中的所有数据,还要保证迁移在线实时增量数据,迁移过程需要根据元数据注册表中的字段映射信息进行转换。这个过程还是比较复杂的。如何自动实现这部分工作呢?我们的实现方式是基于阿里巴巴内部的中间件平台“经纬”进行二次开发,编写独立的消费tar包上传到经纬平台运行,根据各个业务的注册信息完成申请。各业务的迁移任务,这部分工作是我们在开发通用搜索系统时完成的,对接入各业务的同学是完全透明的。经纬平台支持全量迁移任务和增量迁移任务。全量迁移任务简单理解就是在源表上循环执行“select*fromtable_xxxwhereid>mandid”,比如业务开发同学需要访问社区POI数据的搜索能力,他注册该业务在注册中心,在mapping_info中声明需要将源表的poi_id映射到dima_pk,将源表的poi_name映射到dima_a_text_r1,环境为预发布环境,配置完成后,系统会自动分配一个biz_code比如1001经纬任务启动时,我们上传到经纬的自消费代码会将从源表中获取的poi_id为123123123的数据转换成主键为“1001_0_123123123”的数据,并写入进入通用搜索预约表,其中1001代表业务唯一标识码,0代表预发布环境,123123123代表原始业务主键。这样,用户只需填写一次数据,自动完成数据转储工作。4、在线查询服务由于dump输出数据的字段是没有语义的,那么对应BuildService构建端的索引数据的字段也是无语义的。貌似非语义定义方式支持将多场景异构数据写入同一个引擎服务,但是对于业务开发的同学来说太不友好了。他们在业务开发中调用搜索服务时,期望的方式是自然的业务语义调用,比如下面的代码片段:param.setTitle("iPhone6S");param.setSellerId(1234567L);result=searchService.doSearch(param);但是现在字段没有了语义,开发的复杂度大大增加,时间长了甚至会陷入难以维护的境地,因为没有人会记住“param.setDimaALongR1(1234567L))"的意思是,这个查询是基于用户ID还是基于产品ID?虽然在底层我们把多个业务的数据放在了一个引擎服务中,但是我们希望提供给业务开发同学(也就是我们系统的用户)的在线查询服务和搭建一套的体验是一样的发动机独立。因此,这里需要有一个翻译层。一般搜索系统收到的查询请求是“title=iPhone6S”,我们需要根据元数据注册中心的映射关系自动翻译成“dima_a_text_multilevel_r1=iPhone6S”,然后向引擎发起搜索请求,翻译将引擎返回的数据DO中的非语义字段转化为源表的语义字段。可以看到,通过我们提供的搜索网关的二方封装,业务同学可以通过语义化的方式设置查询条件“param.setTitle("iPhone6S")",同时自动封装非-引擎返回的语义字段转化为语义字段。商务同学完全不知道这中间的转换过程,对他来说就像是在使用专为他打造的搜索引擎服务。每个业务接入方的源表字段定义不一样,只写一套搜索网关代码是肯定无法实现以上能力的。我们的解决方案是当用户在元数据注册中心访问新业务时,后台自动生成为该业务定制的二方包代码,包括查询入参、返回DO、查询服务接口。仍然以poi数据接入为例,poi业务领域开发者在元数据注册中心说明需要根据poi_name做文本模糊匹配,需要根据poi_code做包含排除精确查询。我们根据这个注册信息,自动为用户生成poi业务场景专用的查询服务入参。每个输入参数都是按照一定的规则拼接的。网关在线服务获取到该参数后,可以根据命名规则字符串翻译成具体查询。参数命名规则如下图:输入demo代码如下:publicclassUnisearchBiz1001SearchParamextendsIdleUnisearchBaseSearchParams{privateSetunisearch_includeCollection_prefix_poiCode;privateSetunisearch_excludeCollection_prefix_poiCode;privateStringsearchUnisearch_keywords_poiName;}当给用户BaseUnisearch查询条件传入这个查询条件时通过在线查询服务,根据命名规则,通过反射机制判断unisearch_includeCollection_prefix_poiCode需要对业务源表的poiCode字段进行包含(include)查询,然后检索对应的保留表字段poiCode来自元数据注册表中的映射关系数据。名称为dima_a_long_r1,构造SearchPlanner查询字符串,执行后续查询动作。当引擎返回查询结果时,网关查询服务根据元数据注册信息再次使用反射对引擎结果的DO进行翻译转换,打包成如下所示的POI业务专用DO,返回给业务发展的学生。publicclassUnisearchBiz1001SearchResultDoextendsIdleUnisearchBaseSearchResultDo{privateLongpoiId;privateLongpoiCode;privateStringpoiName;}一共有8个通用搜索保留表,所有字段的总和相当大。如果把所有的字段都召回,大部分字段其实都是没有被业务注册过的空字段,返回的数据会比实际需要的数据量大几十倍,网络传输开销大,反序列化开销大空字段数、DO字段转换的开销会导致在线查询服务的RT很高。解决这个问题比较简单。我们将整个在线召回过程定义为两个阶段。第一阶段只根据用户的查询条件,在引擎中调出符合要求的数据主键rawpk;第二阶段根据rawpk列表获取对应数据时的摘要(即所有字段的信息),使用引擎支持的dl语法,要求在中只返回用户注册的保留字段第二阶段。当然,这些工作也已经被我们提前在通用搜索系统的网关代码中实现了,对访问各个业务的同学来说是透明的。到目前为止,增量问题的特殊解决方案看起来很完美。看来我们已经用这套系统完美解决了数据导入、转换、bs、查询等一系列任务的自动打包。商科学生只需在我们的商科注册中心界面进行注册即可。但实际上,表面之下还隐藏着一个更严重的问题,那就是增量大的问题。由于与BuildeService直连的是通用搜索预留表,结构固定,也就是说原来的dump层数据源结构是不能改的,唯一能改的就是把通用数据从业务源表中写入通过经纬系统。搜索保留表的数据。当上游有新业务进来时,如果其源表数据量达到亿级别,按照目前经纬可以达到的迁移速度,意味着通用搜索预约表的更新TPS可以达到5万级别,而每秒更新5万条数据的压力将直接冲击实时BS系统,即引擎需要每秒更新5万条doc数据,以保证搜索结果和源表数据的一致性。然而,搜索引擎的实时BS能力取决于实时内存的容量。如此大的增量TPS会在短时间内填满实时内存,导致源表后续更新数据无法被BS实时索引,搜索系统无法搜索到新的业务数据(包括新的、更新的和删除的数据)称为增量延迟问题。多个业务共享这套引擎服务,已经在线提供搜索服务的业务无法接受增量延迟;而对于这个新接入的业务,在第一次接入的时候,数据就被转移到了通用保留表中,在同步的这段时间里,查不到数据是完全可以接受的。因此,我们想出了一个实现方式,即上线存量业务的增量数据正常情况下是实时馈入引擎的,而新业务全量迁移数据造成的增量数据是不实时馈入引擎的。即时的。具体实现分为以下几个步骤:1.通用搜索保留表会按照db表的创建规范有一个gmt_modofied字段,类型为datetime。当保留表中的数据发生任何变化(增删改查)时,gmt_modofified字段会更新为此次操作的时间戳。这个逻辑是在经纬迁移任务的DAO层实现的。2.为通用搜索保留表的每个表增加一个日期时间类型的字段,命名为gmt_drop_inc_tag。经纬全量任务导入新业务数据时,我们在经纬任务启动参数中加入“drop_inc_tag=true”标志。相应的,我们在经纬独立消费代码中识别到这个flag后,我们会完成数据转换在生成的DAO层的入参DO中,将gmt_drop_inc_tag字段赋值给gmt_modofied,然后写入DB。如果其他业务的全量和增量经纬任务的启动参数中没有“drop_inc_tag=true”标志,则其他业务的增量经纬任务写入DB的记录中只会更新gmt_modofied,不会更新gmt_drop_inc_tag字段企业。3、在引擎原生的dump层,我们在每个generalsearchreservedtable和后面的mergenode之间增加了一层udtf逻辑代码。这里的udtf代码是对dump层的一个开放,可以让引擎接入方在dump过程中对数据做一些特殊的处理。上游的每条数据都会经过udtf逻辑处理后输出到下游进行merge、join、输出到BS系统。我们这里实现的逻辑是,如果识别出当前进程是fulldump,则将当前流入数据的gmt_drop_inc_tag设置为空,然后输出到下游;gmt_modofied字段是否与gmt_drop_inc_tag字段相同,如果两个字段相同,则对该数据执行drop逻辑,如果两个字段不同,则将当前传入数据的gmt_drop_inc_tag设置为空,并向下游输出。所有执行过drop逻辑的数据都会被dump系统丢弃,不会输出到最终的输出数据文件中。这样,老业务的增量数据在正常dump过程后,仍然通过BS(BuildServcie)系统实时体现在搜索在线服务中。而本次新接入业务的全量迁移数据只是从服务源表迁移到搜索保留表,BS系统完全不知道这批数据的存在。新业务的所有数据从源表迁移到保留表后,我们触发引擎服务的全量流程,即先对普查中的所有数据重新运行dump逻辑reservedtable以fulldump的形式,得到完整的HDFS数据,然后离线BS系统对这些HDFS数据批量建立索引,然后加载到searcher机器上提供在线服务。然后,启动新业务的经纬增量迁移任务,保证业务源表的变化实时反映到引擎中。结果闲鱼通用搜索系统已为3家商家提供服务,每家新商家在10-30分钟内即可被访问到。在这个系统出现之前,如果业务方想要接入搜索能力,需要向团队中精通搜索底层原理的搜索业务负责人提出开发需求,等待一周左右的开发进度,等待搜索拥有者完成一套引擎服务,商科学生只能进入商业发展阶段。我们用这个系统来解决寻找失主的单点阻塞问题,用自动化技术实现生产力的解放。展望未来,闲鱼将继续在自动化和效率提升方面进行更多探索,将开发者从繁重重复的工作中解放出来,将时间投入到更具创造性的工作中。今年闲鱼还有更深入、更具挑战性的项目在进行中。期待您的加入,与我们一起创造奇迹。