介绍
美丽联合的搜索体系, 经历过很长的体系演进和改造. 一方面, 业务上变化促使我们不断对搜索体系进行深入. 在创业公司, 相对比相对业务成熟的公司而言, 最大的特点,就是每一次体系的迭代,都会以业务或者某些业务需要的特性作为目标和结果. 而这个系列讲述的, 是这一路上(至少是目前为止), 蘑菇街搜索平台体系的衍生. 搜索平台和主搜索体系的区别或者联系, 也会体现在文章中. 首先要指出的是, 搜索平台并非现在蘑菇街的主搜索体系, 但是搜索平台本身确实是从主搜索技术体系中剥离出来以应对和主搜索场景不同的业务需求(一直到现在,搜索平台和主搜索体系都在搜索部门内部的两个小组). 这篇文章会以笔者在蘑菇街搜索体系经历过的搜索演进的时间轴来讲述搜索平台每一个阶段, 解决的一些重要问题以及一些积累的看法. 感谢这一路来为搜索平台做贡献的小伙伴. 这个系列分为三个主题, 大致的大纲如下:
1 第一篇: 蘑菇街早期搜索体系以及部分Solr/Lucene 的分享
2 第二篇: 蘑菇街搜索平台实时引擎的单机实现以及分布式,Dump体系改造
3 第三篇: 蘑菇街搜索平台的虚拟化落地以及ES 集群的加入
第一篇: 蘑菇街早期搜索平台体系以及部分Solr/Lucene 的分享
我们先从2013年9月份开始吧,先介绍下当时的业务背景:
当时, 正好遇上DDay(转型电商平台)的重要节点. 当然,在这个阶段,没有主搜索和搜索平台这个说法, 因为我们的搜索就一个技术栈——基于Solr 改造过一部分的搜索引擎. 在介绍结构或者工程之前, 先说明下这个阶段的业务背景:
1.DDay 开始意味着我们有了自己的商品, 并且当时除了女性类目, 我们还有家具, 美妆等类目,业务上, 首页(List页)和搜索结果页(搜索框)后端存储都是搜索引擎.
2.这个阶段业务变化太大, 业务大量接入,引擎并且是在前端承受大部分流量, 这个阶段前端基本没有缓存, 每天都有差不多上千qps 裸打引擎(没有缓存), 而写入需求, 没有那么强的一致性要求, 说白了,这个阶段需要我们做的, 更多的是稳住线上qps.
3.算法在这个阶段开始使用一些离线算好的比较简单的加权线性模型, 这个阶段算法没有专门的ab 系统, 分流更多是靠前端PHP 的一些uuid 来完成, 也就是业务分流了. 算法离线生成好的分数,如果通过一条一条写入solr, 效果的验证太慢了, 要解决这个问题, 需要我们自己来完成排序的一些插件.
这些背景, 简单的形成了如下的搜索体系(图1):
在这个结构下, 几个明显的特点写出如下(图很简单,但是大家看看下面的描述比较详细):
1. 引擎端读写分离
使用我们自己在源码添加了排序插件的solr 实现. 在写入端, 增量脚本和全量都是php 脚本 scan DB 的方式实现, 增量和全量脚本的互斥由比较粗糙的时间间隔控制. 增量脚本回朔功能在增量脚本中通过持久化扫表的offset实现. 引擎端增量或者全量写入的Doc首先写入master. master 和 slave 之间使用定时几分钟同步索引的方式(增量同步索引段或者全量同步索引段). 在查询端, 查询过程中(准确的说, 在排序流程中), 针对某些排序字段(比如最新,最热,流行)等字段会按需走到排序插件.
2 .排序插件介绍:
2.2.1 离线算法分数的同步: solr slave 会定时轮训算法离线数据的一个服务, 如果有最新版本的关于某一个字段的分数, 举个例子,比如首页中的”最新”这个排序字段如果有最新的离线训练好的字段, 那么, 就会以一个压缩map<K:bussinessPrimaryKey, V:values> 的压缩结构通过网络传过来. 当时的商品规模基本在百万级别, 直接传输会引发两个问题,一个是网络, 另一个是GC, 所以, 在传输发的细节上, 我们会首先将需要传输的这个分数map压缩为 hash_map_header 和 List<hash_map_segment>, 分批传回, 注意, 传输过程中, 为了查询段数据一致性, 我们是使用双缓存方式,也就是说, 一个buffer 在线上使用, 另一个buffer 加载新的分数,只有新的分数同步完成,检验完成之后, 进行两个原子引用的swap.
2.2.2 排序插件查询端实现, 是重写了QueryParser, 并继承了SolrField 字段在 QueryComponent源码执行过程中, 根据排序字段配置, 选择我们重写好的SortField, 在SolrField中, 当时版本还没有DocValue这种列式索引, 我们的方式, 是在倒排链收集阶段, 根据Lucene 内部 DocId, 根据当时版本的”FiledCache” 正排字段获取业务主键ID, 最后根据ID 查询2.2.1 中对应业务主键id的关联score_key 的那个分数.
关于排序插件的流程, 稍微解释,如下:
上图描述的是一个Query 在检索过程之后, 后面有一个检索出来的倒排ID集合(这个Id注意不是业务主键Id,而是Lucene 内部的Lucene Doc Id). Lucene 内部的排序逻辑简单来描述, 会把这些倒排Id放入Heap 中进行排序. 这个图比较简单, 想说明的点是我们的排序插件的核心,就是对红色部分,Comparator 的自定义实现. 最终在comparator 的比较逻辑中, 我们加入了自己的逻辑(先根据Lucene Doc Id 通过FieldCache 内存正排结构, 找到业务主键Id, 然后和上述算法同步过来的集合做查找).
3 .查询流程缓存
solr 结合Lucene 引擎内部, 有几个缓存, QueryCache, 缓存query级别, 其中有些细节需要注意, 首先, key 为query 级别, 并且排序要一致. FilterCache, 缓存filter 级别, 由于filter 不参与排序阶段, key 为filter本身查询. DocumentCache, 这个缓存, 主要是缓存”正排”级别, 我们叫的正排,主要是查询结果出来之后, 根据用户需要返回哪些字段, 需要去根据倒排id(再一次,这里的倒排id并非是主键id)获取返回字段(这些返回字段,有些同学叫”摘要”也有些同学叫”正排”). FieldCache, 这个部分, 是Lucene 自己的FieldCache, key为某一个正排字段建立的一维或者多维数组, 早期,一般使用的时候主要早加载到内存(Lucene 5 之后, 被docvalue 取代), 数组的下表为倒排id, 数组的值,为正排的值, 一个这样的数组结构对应一个正排字段. 之所以要讲缓存, 是因为这个阶段我们DocumentCache使用了堆外的内存, 这个思路对有想法的同学可以借鉴下的, 至少我们在某一个规模下线上还是比较稳定的使用这个方式. 堆外缓存直接使用了当时的免费版本的bigMemory(EHCache).
4 同步的策略和过程
这个是要提到的一个点, 因为在solr 的原生同步过程可能和没有接触过solr的同学理解有所不一样. 首先, 这个版本的同步不是根据WAL日志(solr 中可以理解为Tlog) 同步. 是索引二进制文件的同步. 同步的流程(注意这里说的是正常情况), 如下: slave 首先和master 询问自己的索引段和master的索引段是否不同, 根据version. master 接到询问之后, 根据replication 文件获取关联的version 的索引是否完整存在于master端 (之索引需要检查,是因为master在中间某个时刻可能触发了merge优化索引操作, 这个过程会删除部分无用索引段,这种情况就需要一次完成的索引同步), 如果存在, 那么选择增量同步, 并返回给slave 关于这个版本的索引文件名,slave 获取索引文件名字, diff 自己的索引文件名, 获取缺少的那部分”增量索引”. 整个增量过程,可能影响master 的merge过程.
这里有几个关于Lucene merge的流程或者理解没有和大家介绍, 我这里给一个简单的图来说明merge 这个功能以及过程,如下:
如上, 当我们使用api向Lucene(或者Solr)中写入数据,在存储引擎层面, 每一个写入的线程其实是写入一个段中, 首先分词, 构建索引写入内存 ram buffer 中, 当ram buffer 中的数据是不可见的(后面会有专门的介绍可见性这方面). 当rambuffer 达到某一个阈值的时候, 会对这个段索引进行Flush 持久化操作(Flush 越多,生成了越多的”小段索引”), 为了提高查询性能以及对n多个小段的管理, Lucene 在每次Flush的时候, 都会判断是否需要进行小段索引合并为一个大的索引, 这个过程,我们就称为”索引merge过程”. 上图为了简单给大家介绍这个功能,只是画了合并为一个”大段”. 而真实的merge过程, 要分为两步走:
第一步判断哪些段需要进行合并, 比如说超过一定大小的段,合并的代价太大, 就不进行合并了.
第二步, 合并的算法是什么? 在内部, 一直到现在, Tire 的合并算法是默认使用的, 并且合并到多少个段结束,其实也是计算出来的. 这里不展开了, 主要想强调的是, 这个merge 过程其实还是异步执行, 但是不能理解为没有代价, 因为对磁盘的利用率以及IOWait 可能就在这个时候彪起来, 还有一点需要注意, 一旦合并结束, 其实理论上来讲, Lucene 会删除合并之前的那些小段(因为没用了), 正因为这个点, 所以刚才上述所讲的同步过程增量同步索引段有的时候在master 端是无法做到的,于是就有了不得不进行全索引同步.(PS:其实现在在ES 的scroll 功能中, 由于使用了游标保持”扫描镜像”,所以merge 过程会受一定”阻碍”).
现在的版本, 其实对merge过程有添加了”限流”的功能(限制写入磁盘的速度,使得merge过程比较平滑),大家可以尝试.
5.不得不提到的实时性
由于使用了master/slave 的结构,并且同步的方式也和大家大概说了.这个阶段, 我们的(业务字段实时性)就完全取决于slave 向master 同步的时间戳, 我们当时是5到10分钟不等. 但是这个时间是要打折扣的, 因为即便同步好了索引, 要可见, Lucene 内部需要 open 一个新的索引reader, 并且, 缓存是需要预热的, 预热是异步进行, 好了才能完全可见. 而算法字段, 则是取决于对上面结构图中对算法中心的拉取时间周期.[实时性的详细内容会在平台的后期阶段相对详细介绍]
这个过程我也简单给出一个流程图, 如下:
首先slave 同步增量索引(或者全部索引, 然后出发SolrCore的openNewSearcher 这个方法, 这个方法简单理解为打开一个新的searcher, 这个时候线上查询还是使用老的searcher, 对应老的slave 的索引. 打开新的searcher 之后, 需要并行异步的预热, 预热好了, 新的searcher 引用就会替换老的searcher引用. 这个过程中, 有些比较重要的请求, 比如说RealTimeGet, 或者叫ids请求使用的是newSearcher).
6.业务隔离
其实这个点有点虚了, 我们的结构为一个垂直业务(同一类,比如主页List商品页, 搜索商品页, 化妆品业务, 家具业务)这些不同维度,我们就会独立搭建上图的结构. 比较自然的,不同业务维度(可以理解为主键维度不一样, 我们就有一套单独的增量/全量脚本以及引擎, 当然,算法离线的分数同步进程可以公用,也可以分开来,业务上取决于效率, 技术上取决于网络IO).
此阶段一些关于使用Solr或者Lucene体系东西可以给大家分享下:
1.1 缓存中, querycache 变化其实还是比较大的, 粒度比较粗, 而filter cache 是比较固定的, 走filter cache要注意分离条件, 固定的filter条件注意单独提出, 不要和容易变化的条件公用一个filter. 最后, 大家不要忽略documentCache, 为啥? query时间=倒排(QTime) + 正排时间. 大家不要以为原生solr 查询日志中的QTime 很低就认为query 正常, 正排的时间不在QTime的统计范围内. 在使用缓存的时候, 如果允许, 记得到源码上review一遍查询进入缓存的”门槛”. 有的时候, 你虽然开了缓存, 并且看上去缓存也没满, 但是query就是走不到缓存. 举个例子, 在Solr中使用QueryResultCache 的时候, 一定要看query 的start+rows 的值是否超过了queryResultMaxCached这个参数.
1.2 不得提到的实时性: 上文也说了, 在这个要强调的, 是缓存对实时性的影响,以及一些避开策略. 首先, 缓存预热是可配置的(并且是异步进行的), 但是配置的时候对性能的折中是需要考虑的(实时性和性能), 大促期间, 其实我们同步并非正常间隔, 我们会在某个高峰时间段关闭主从同步,一方面避免数据出错,另一小方面也是处于缓存的稳定. 其次, 如果是ids查询(id=1 or id=2) 这种业务主键查询, 大家可以走ids查询. 因为ids 首先会走Tlog(Tlog 为solr 写入到索引之前, 为了保证索引的可靠性,多加了WAL 日志, 日志的粒度为一条完整的写入数据, 并且它拥有一个二级哈希索引, key 为业务主键Id, value 为Tlog 的Offset). 如果Tlog查找不到(tlog不可能一直增长, 当Lucen索引段commit或者flush持久化到磁盘并且开了新的可见性reader之后, 这部分无用的tlog就会被删除), 再使用Lucene 底层的search api 查找, 查找也是优化过的,因为既然是业务主键Id, 只需要找到倒排链中的第一个数据就行, 直接查找的损耗在词典而非倒排.
上面讲到了Tlog的一些东西, 这个地方还是也简单给大家一个流程图介绍这个Tlog到底是啥. 在介绍之前, 我先说下背景, Tlog(或者ES 中的TransLog)都是Lucene 上层的日志, 这个日志和一般WAL 日志类似功能. 一方面是由于Lucene 的索引会县写入内存,经过Flush 之后才进行写入文件, 这个过程中如果存在Down机,那么重启恢复的时候数据就丢失了. Solr 和 ES 考虑到这个方面, 在Lucene 上层, 使用了Tlog记录下写入的数据,并在意外down机的情况下进行Tlog数据重播恢复.另一方面, 在分布式的情况下, 同一个分片的节点”闪断”等意外出现之后, 如何与这个分片的其他节点保持数据一致? 那就是首先会从这个分片Leader 节点拉取”一个窗口周期”内的Tlog数据, 如果实在找不到, 则进行全索引同步(比如挂的时间太长). Tlog 和写流程的结合, 如下图:
如上图所示, 上面图关于Tlog 想说明的点有:
Tlog 是Lucene 上层的模块, 给Solr 写入数据, 先写入Tlog 文件,再将写入文件的Offset 结合这条记录的业务主键放入Tlog的内存耳机索引(一个Map). 基于这个结构, 所以可以执行ids(根据一个或者多个主键的KV查询) 查询.
Tlog 并不是一直都保留所有写入的数据, 上图所示, 当进行commit 或者写操作进行Flush 等内存索引落盘持久化之后, 这部分相关的Tlog 其实是处于可回收状态(tlog文件可删除状态). 所以, 上面提到的IDS 查询可能有部分ID 在Tlog 中不存在,需要到索引中根据主键ID进行查找.
Tlog 是有状态的, 上图中没有体现, 但是也想提一下, Tlog 在本机重启恢复, 分布式恢复等操作时候, 都有关联的状态,并且和使用SolrCloud 中每个节点的状态对应.
以上内容, 和使用Solr的分布式结构有关, 如果你也和我们使用的比较早期的master/slave 结构, 那么从本身的选型(读写分离, 周期同步master索引)对这部分Tlog 的流程是有影响的.
1.3 对慢Query的优化和排查部分
介绍下我们的思路和一些这个阶段的收获:
在介绍这部分之前, 我想还是先画一个Solr 的查询处理以及对应的引擎检索流程,方便大家理解下面比较”繁杂”的优化细节...
如上图所示, 左边黄色的是一条Solr Query(更多是展示用, 所以fq 等一些语法不严格给出了). 通过Lucene 引擎的语法树重写之后, 会变为一棵语法树结构, 叶子节点(在上图中叶子节点都有颜色标识的倒排链关联)为最小粒度的倒排索引查询执行语句. 而图中的Boolean Query 表示”复合”查询.
比如说上图中的四条子倒排链最终会通过”cost 扫描代价”最小的倒排链作为外层循环, 然后根据他们之间是”AND(在上图中每个子query都有”+”这个属性表明是必须包含的意思)” 还是 “OR” 进行倒排链合并等操作. 还有一个想再介绍下, 就是上图中大家可以看到,红色叶子query的倒排链和绿色倒排链我故意用不同的颜色区分开来, 一个是说明他们查询类型不一样, 另一个,也想和大家介绍他们走的索引也不一样,执行倒排链的加载过程也不一样. 比如上面的TermQuery, 会根据”红色连衣裙”分词之后的词词单元, 先走倒排词典(FST 状态机词典结构定位到这个词的倒排链文件指针). 而上面绿色的rangeQuery在当时我们的版本其实使用的, 是一个Trie 的方式把数字转化为有序字符串之后, 然后将这些范围涉及到的倒排链全部都先加载到内存一个DocIdSet的byte[]中(Lucene 6 为Point类型的KD树,对于一维数字类型, 可以简单看为”二叉树Block结构”).
大家可以先忽略我说的这些索引结构, 我想强调的是:
1.不同Query 走不同索引结构
2.Query除了倒排消耗, 词典的查找,一样消耗时间
3 不同Query 在执行倒排链交集算法之前, 对倒排链的加载方式不一样, 比如上面提到的TermQuery, 只是获取了倒排链的文件指针, 而range Query 会被”优化” 为提前将这个叶子节点的倒排链全都放到内存中(“早加载”倒排链).
有了上面的一些介绍, 下面给大家分享下我们当时排查的一些思路, 有些错误的地方大家请一笑而过:
1.3.1 首先确认慢在哪里
当然先排除full GC 以及系统指标之后的慢query, 查询日志中Qtime 很高, 那么说明这条索引至少在检索和排序阶段就开始慢了. 这里有两种情况, 词典慢, 之所以词典会慢, 是因为在一个索引字段分词太细或者某一个时间戳字段粒度为毫秒(比秒的粒度要细很多). 另一种情况, 为倒排链慢, 那么从日志中看看hits的值. 在QTime 高的情况下, 重新把慢查询逐一分解看哪一个条件比较慢(过程重复采样, 不要一次就下结论). 找到条件后, 针对QTime 高的case, 其实可以直接源码debug
一般来说, 对倒排链的排查和优化, 原则就是短链在前的方式. 这是因为在做倒排链交集的查询优化中, Lucene 已经使用了skiplist 结构, 但是有两种情况有些区别,一种,对于termquery 在是获取一个倒排链的文件指针,另一种,则是使用DocIdSet的ConstantQuery, 这种query是在交集算法之前,就会把这个条件下的所以倒排链放到内存. 在倒排链hits命中数比较高的情况下, 早加载所有倒排链不见得是优化(比如说范围查询在最新版本也是这么做, 即便使用了BK树的索引). 另外想提到的, 是排序阶段, 排序字段优先去走FieldCache(或者现在的DocValues索引), 逻辑尽可能少, 如果你使用了动态脚本, 记得不要每次编译
还有一个点, start 值太高的query(深度翻页). 对于Qtime不高, 但是客户端或者proxy 查询高的情况, 那么上文提及到的获取正排的时间就要留意了, 这种情况出现在一次查询rows上百或者上千, 这种情况检验的方式就是精简字段之后再查对比时间,优化的思路为documentCache的缓存大小.
1.3.2 针对上述原因的一些经验分享:
①.看QTime , start 是否太高, rows 是否太高. query 固定的话, 比如说List页的请求query 是类目词, 那么可以考虑独立集群或者尽可能提高querycache 命中率. start 值比较高,那么限制深度翻页, 比如说一些扫索引操作, 迁移到DB去做. 其次考虑使用search after的策略(这个优化需要业务上的翻页配合,一垂直搜索很难利用这个特性,因为排序的原因和间隔跳页的问题), 一般是类似扫描操作使用, 比如ES 中的scoll功能. 如果filter 比较高, 那么对应调整filter 缓存.
②.正排RT太高(这个原生是没有时间打出的,可以粗略使用客户端时间-QTime 时间估算), 首先review rows 是否太大,其次看是否需要调整document cache, 调整这个cache的时候, 注意看GC情况.
③. 写对读的影响: 注意, master/slave 的结构可能不存在这个问题, 但是如果你的结构是没有读写分离的,不区分状态的, 那么索引flush 会引发缓存预热以及索引段对磁盘的写入量, 对应系统指标为磁盘利用率和iowait.
④. 排序逻辑尽可能的简单. 排序插件对字段的访问,如果内存没有压力, 就直接预先加载到内存形成正排索引之后再进行, 如果自己写这个结构, 要注意源码流程中Flush 阶段对缓存失效的判断时机和重新加载这部分索引到内存的平滑性.新版本使用Docvalues 字段存储.
⑤. 排查上述问题之前, 对系统的GC, 和常见的load, IO,网络因素先看, 因为这些指标再有PE运维支持的情况下是很容易找到问题的,先看这些指标,可以节省很多时间. 查看load 比较高的方式基本使用Topn H + Jstack 确认热点线程逻辑, 并观察多次Jstack 中比较集中的执行线程代码逻辑是否符合当前的场景. 举个case, 我们之前发现ES 写多读少的case中, jstack 居然大部分线程在查找version这个乐观锁版本的代码,并且查找是走索引的(ES version字段存在索引中), 后面定位到了ES 在写入中如果用户指定了业务主键ID, 为了避免写冲突, ES 需要查找Version,优化方式是自动生成id.我想说明的, 是有些情况下, 需要看仔细些,可能你以为写瓶颈的时候, 其实内部流程是写触发了读瓶颈.
⑥.高峰期同步索引merge 的影响要注意, 如果可以, 索引的优化和同步间隔时间可以拉长(实时性允许的话). merge 在新版本中提供了写磁盘限流的功能, 包括merge 策略的调整(比如说达到多少大小的大段不进行合并), 如果存在这方面瓶颈,可以现场时调优这个部分. 如果控制了索引合并策略,记得看查询RT(比如说大段不合并,那么段多的情况下, Lucene 查询可能受到影响).
⑦. 范围查询的优化, 放在Filter 中, 并且使用SolrConstantScoreQuery的优化流程(也就是走到了Solr 的PostFilter 优化流程), 至少在4.10.x版本之后, 这个优化不会走默认的倒排索引, 而是轮训每个段索引,并且使用Docvalues 存储结构, 这样的底层查询方式, 避免走词典,倒排链这样对范围查询不太适应的检索流程(Lucene 6 之后的版本, 范围查询底层其实使用了Point KB树索引, 所以这个优化要需要根据场景测试).
好吧, 至此, 好像讲了很多东西也还在第一个阶段(主要也给大家分享下我们使用Lucene 或者Solr 的部分涉及到点).
下一期,我们将介绍蘑菇街搜索平台的背景以及其中一个实时引擎的单机实现以及分布式改造.
更多流量、广告、搜索、算法相关内容, 敬请关注“美丽联合数据技术”公众号