介绍
美丽联合的搜索体系, 经历过很长的体系演进和改造. 一方面, 业务上变化促使我们不断对搜索体系进行深入. 在创业公司, 相对比相对业务成熟的公司而言, 最大的特点,就是每一次体系的迭代,都会以业务或者某些业务需要的特性作为目标和结果. 而这个系列讲述的, 是这一路上(至少是目前为止), 蘑菇街搜索平台体系的衍生. 搜索平台和主搜索体系的区别或者联系, 也会体现在文章中. 首先要指出的是, 搜索平台并非现在蘑菇街的主搜索体系, 但是搜索平台本身确实是从主搜索技术体系中剥离出来以应对和主搜索场景不同的业务需求(一直到现在,搜索平台和主搜索体系都在搜索部门内部的两个小组). 这篇文章会以笔者在蘑菇街搜索体系经历过的搜索演进的时间轴来讲述搜索平台每一个阶段, 解决的一些重要问题以及一些积累的看法. 感谢这一路来为搜索平台做贡献的小伙伴. 这个系列分为三个主题, 大致的大纲如下:
1 第一篇: 蘑菇街早期搜索体系以及部分Solr/Lucene 的分享
2 第二篇: 蘑菇街搜索平台实时引擎的单机实现以及分布式,Dump体系改造
3 第三篇: 蘑菇街搜索平台的虚拟化落地以及ES 集群的加入
第二篇: 蘑菇街搜索平台实时引擎的单机实现以及分布式改造
上一期, 我们介绍了13年Q4到14年部分Q1 在Solr搜索引擎上使用到的方式以及一些Solr的部分经验,接下来这一期,我们介绍下平台初步形成以及平台的发展:
14年Q4以及15年Q1,Q2平台的初步形成
到了14年Q4以及15年初, 蘑菇街的搜索体系有了一些新特性的变化, 实时性的要求, 但是这个要求其实并非主搜索场景, 最新提出这个点的, 是交易以及商品的一些搜索集群.(注意, 那个时候我们并没有专门的主搜团队或者这个垂直的小组, 所有搜索相关的事情都放在我们这边, 包括那部分觉得DB运维代价很高,希望用搜索解决问题的需求). 于是,这个时间段也慢慢的衍生出了”搜索平台” 这么一个产品或者说需求吧, 之所以这么说, 是这个阶段很多业务都接入了搜索体系, 哪怕是只是用于一些运营操作的需求, 以及一些根本没有体现”文本搜索”这样的api 后端调用(比如说一些api的提供需要join很多DB table, 或者需要调用很多中心化的服务,为了减少这部分消耗, 技术上考虑在数据底层采用一个存储将底层的数据通过dump聚合, 上层再查询这个存储. 幸运的是, 我们当时一心只想支持业务, 当时只有一个搜索部门,也不存在”竞争”的说法, 那个时间段接了几十个业务吧. 不幸的是, 我们接得越多, 运维就越累, 需要想办法解决这方面的问题). 于是, 这个时间段,主搜索和搜索平台的方向, 开始慢慢划开. 主搜索更加偏向于垂搜场景, 对RT以及算法模型,业务的深度结合支持力度要足够. 而搜索平台这个阶段的难点, 在于实时性的实现, 自动化运维的打通,用户平台的后续支持等类似一站式平台一样的产品给到用户, 对于用户而言, 减小接入成本, 尽可能透明搜索功能. 接下来, 我就主要开始根据时间维度来介绍搜索平台的事情了, 我相信大家持续订阅这个微信号, 一定会看到主搜索方面的分享的.
搜索平台14年Q4—-15年Q1实时性的第一个版本迭代
这个阶段, 对于实时性的技术要求是最多的, 下面着重介绍下这部分的实现思路.第一版本的实时引擎, 思路其实更多的是借鉴了几个产品, 其中包括开源的Zoie,淘宝TSearch,淘宝 VSearch.
首先, 介绍下Lucene 原生的实时性: 经历过Lucene源码的同学可能知道Lucene 索引过程就是一个”不实时”的过程. 原因在于写线程写入到内存buffer 后, 在buffer 的信息并不可见. 举个不恰当的例子, 打开一个浏览器页面, 过程中不刷新的话,你会错过在这个不刷新周期内的新上线内容. Lucene 也是一样, 在Lucene 中, 新增内容可见需要两个底层步骤, 一个是先把内存的信息flush(可以不完全生成一个_segmentsX总体段文件,但是关于这个段文件的信息一定要在writer 对象中存在,类似于发布内容到浏览器). 可参考下图:
另外,就是针对这个新增的信息进行打开一个新的reader 替换老的reader(类似于刷新浏览器的动作). 这个过程可对比上述图有如下变化:
也就是说, 如图中红色框所示, 对openreader 打开之后, 对新增到线程的rambuffer 中的数据可见. 上述的过程中, 如果Lucene 只有一个索引, 那么会对这个索引进行频繁的Flush 操作达到可见性, 但是这个Flush 本身涉及到fsync 甚至Merge 索引合并的策略, 频繁在磁盘索引操作代价是比较高的, 但是在一定数据量的内存索引去操作, 是可行的. 坦白说, 这个点, 其实当时更多是参考了上文中列出的已经在线上使用的产品. 所以, 要解决实时性, 我们思路上确定了”历史索引+实时索引”的方式.
这里, 稍微用图再说明下设计到实时上的索引之间的关系:
如上图所示, 索引其实有三个, 两个内存索引,一个磁盘索引(历史索引), T1时刻, 写入写到内存索引1, 内存索引2 standby. T2 时刻, 内存索引1 写满, 写入内存索引2, 并出发内存索引1和历史磁盘索引的合并. 而不管T1还是T2, 查询都需要从这三个索引中查找. 大家可能第一感觉会觉得T2内存索引1和磁盘索引合并过程中就应该有重复数据啊, 其实这个是和实时性有关的, 合并过程中, 历史索引的searcher 并不会触发”刷新”, 也就是说这个过程中, 有重复, 但是重复的在磁盘索引中不可见.
涉及到实时性的技术点其实很多, 包括中间的删除问题, 包括分布式api的兼容,包括是在索引级别还是在Reader 级别实现实时性. 这些点都不是一下子能说清楚的, 大家如果感兴趣可以一起交流. 我只是想说实时性改造这个点上, 我们基本上使用两个Q的时间从完成到二期优化. 很重要, 过程也很辛苦. 而15年Q1 我们主要是在索引级别实现了实时性,并残暴的兼容了solrcloud 在分布式上的某些不兼容api. 这个过程, 其实有些原生的功能我们是不能直接使用的, 这是由于兼容api的方式所直接导致.
15年Q1结束的搜索平台体系, 读写查询流程如下:
上图中分布式的方面, 其实我们用的是改写过的的SolrCloud, 原因是上面我们介绍过了的实时性实现方式使我们在基于Solr 源代码改过的方式, 而改过了某些重要对象之后, 我们为了快速验证自己的这部分的功能, 当时做了分布上上某些api的兼容, 然后我们上线了. 需要说明的是, 这个版本完成了实时性, 但是某些功能是舍弃掉的(原因是因为这部分api原生兼容的问题). 而这个阶段上线后, 也暴露出了问题, 其中最主要的为:
基于索引级别的实时性(或者说基于SolrCore 级别的实时性), 在兼容部分特别粗糙, 要改很多原生的api.
基于上述的点, 我们进行了内部的重构, 把基于SolrCore 的实时性更改为基于Lucene Reader 级别的实时性, 逻辑上内存索引和磁盘索引关系是不变的. 这里可能是需要大家对Lucene Reader 的概念熟一些可能体会会比较好. 我简单画个图来说明Lucene Reader 和索引段的关系, 其实, 上文在介绍实时性的时候已经有列出一些, 如下:
从上往下看, 是索引写入的过程, 每个线程在某一个过程时刻对应一个索引段的概念. 这个段包括了这个线程这个时间段内(上一次flush到下一次flush)的所有数据. 线程的分配对写操作来说是随机获取空闲线程的,并没有业务上的一个”路由”方式. 从下往上看是查询过程, 查询需求先到一个叫IndexSearcher 的对象, 这个对象持有一个CompositeReader, 这个Reader 可以是一个树状的Reader, 只是在上图中为了简单, 只是画出一层结构. 对于CompositeReader 对象的叶子节点, 就是SegmentReader, 每一个SegmentReader 对应一个索引段. 查询的时候回遍历所有叶子节点针对每一个段进行查询. 然后再合并.
我们在15年Q2前中段时间, 其实做的就是把实时性的技术实现下沉到了Reader 级别. 这样的话, 主要解决了上层原生SolrCloud 的原生兼容性问题. 可惜, 好景不长, 原生的SolrCloud(当时应该是4.x版本)给我们带来了一系列的线上麻烦, 主要体现为如下:
1. 对于拥有Leader 状态以及Replica 状态的分布式系统并且使用了http 阻塞请求作为转发传输层协议的结构, 在qps比较高,或者某一个接点如果出现一些问题, 很可能就会由于默认的重试逻辑比较容易造成雪崩, 我想还是再画个图说明下问题:
上图中的1,2,3 分别是有序的请求, 这个系统当前有有多个并发请求, 这些请求可以随机落到某一个shard的某一个leader或者replica进行查询(solrcloud中每个节点对查询而言,都具备转发以及分布式排序,合并等功能). 红色的两个node分别表示两个http 线程池或者请求队列目前正在处于”紧张”阶段. 这个时候, 查询B(随机打到shard2_node1)需要等待处于shard1_node1 的这个请求, 而查询A(随机打到shard1_node1)需要等待处于shard2_node1的请求. 于是, 就悲剧了. 这种方式,尤其对于http 老的同步短链请求方式而言明显. 针对这个问题, 我们也对Query 进行了比如说截断(不幸的是, 4.x版本支持的是倒排链截断, 但是不包括词典截断. 词典的截断,我们是后面才考虑加进去的), 超时控制等控制(失败时候返回正常的部分数据,也就是particialreult=true 的特性), 但是有状态的集群, 在我们当时的场景中, 确实有蛮多问题.
2. 其次, 对于有状态的集群而言, 离线全量的难度会很大. 由于这个阶段我们尽力解决了引擎内核的问题, 接下来, 平台的搜索体系将会扩大到Dump, 订单这些业务而言, 数据量比较大, 在线索引一方面对线上的影响很大, 另一方面, 时间长, 丢数据的风险就多, 离线索引其实还有一个额外的好处, 就是对现有索引进行”优化”. 而在原生的solrCloud中, 即便我有离线索引, 这份索引怎么作用到线上会让我们很头疼. swap 的方式在有Leader 和 Replica 这种状态下显得”没什么用”.
针对上面的问题, 我们开始了15年Q3 的平台体系引进, 这次演进分为两个子项目,
1. 引擎端去掉SolrCloud 的分布式支持, 状态转移到Merge 层.
2. Dump 以交易数据为出发点, 开始进行离线Dump 的开发, 并对接引擎.
于是, 我们2015年Q3 结束后, 系统变为这个样子:
上述图中, 可以看到我们为了完成引擎端”去SolrCloud” 以及 Dump 端体系化和”平台化” 的概念, 系统模块多出几个部分:
左边草蓝色部分为Dump 端, Dump 端主要包括离线Hive 数据结合MapReduce 根据分布式的分片规则(规则存放在zk中). 离线生成好Lucene 索引(这个小模块是剥离了SolrCore 来实现, 为了tps 尽可能的高,我们在离线生成索引部分剥离的SolrCore 部分不写Tlog,并且写入过程中不进行commit操作), 并把索引放到HDFS 指定位置, 引擎端读写分离, 写操作主要由右图中下方金黄色部分的Updater 管理, 包括通知引擎需要全量, 引擎下线(下线状态同步到zookeeper中)之后使用HDFS Shell 脚本去拉取于自己这个分片相关的接点. 下线过程由Updater 管控, 下线至少会留有一个节点在线上. 在Dump 中也包含了增量, 增量主要是接入DB binlog(我们接入的是中间件系统的pigeon, 它主要基于阿里巴巴的cannal以及公司内部的分布式消息队列Corgi), 然后增量框架会负责调用子系统(拼装整条完成Doc 的子系统,这部分由业务方继承我们的某一个类, 完成对binlog变更ID涉及到到的其他需要完整存储的索引字段的拼装工作)进行拼装之后最终放入pg 一个表中, 之所以使用PG表,是因为在某一个shard的某一台接点下线做全量索引的替换(这个功能的实现, 就是基于solr 的swap index功能)完毕之后, 需要回朔增量数据(这里面有一个小的逻辑要说明, 回朔到什么时间点? 其实需要调度来解决), 回朔到和一个当前时间点基本接近的时候,我们会让这个节点上线,也就是通过zookeeper暴露给merge. 但是实现上细想, 我们使用的消息队列是基于分区并且是拉的模式,某一个Topic可能有16个物理分区分别在N个不同的消息broker 上, 我们判断时间这个操作需要针对每一个分片完成. 而当时使用的消息中间件还没有开放出这个接口, 我们就变向的使用了单表的pg来进行会说时间戳判断.这个点使用单分片的队列的话也是可以的,只是在功能上, 我们经常会判断某一条消息是否增量有进来过这样的查询可能就有点麻烦了.
右边系统为引擎部分, 引擎部分Merge 剥离了SolrCore来实现分布式排序和部分聚合功能. 内核是我们在一期实现的基于Reader 级别的lucene 实时引擎. Merge 支持分组隔离,并且根据zookeeper的分片规则对查询进行路由. 右图中间绿色部分为集群端, 集群端分shard, 而shard 中的每一个接点没有状态,彼此不知道, 彼此之间的状态是同步到zk,暴露给merge 或者updater.
上图最右边的空白底色模块, 其实在这个阶段是也相对重要的, 而在这个阶段, 这个模块更多的是对集群的一些管理,比如说集群自动化运维.
针对这个阶段(2015年Q3阶段), 集群引擎层面和Dump层面已经初见系统功能, 而随着这个阶段对于集群来说, 业务越来越多, 很多业务上有的qps和tps都不同, 而集群的结构本身是业务隔离的. 我们没有使用solr的muti index的特性来做为多租户的实现. 提一点,这个阶段其实我们的主搜索场景也开始进行了引擎C++自研的方式, 所以, 这个阶段来说, 技术栈上,搜索平台和主搜索体系引擎也就此分开.
回到搜索平台上, 这个阶段接入的业务越来越多, 差异性很大, 我们的集群是一个业务一个集群的, 在运维上, 我们希望自动化部署的功能足够的简单, 就希望有一个模板”文件”, 只要我有机器资源,我就能够直接把模板根据这个业务的tps和qps 进行初步的容量预估与部署.
首先, 在资源上考虑, 我们希望尽可能的有一些方式能够在资源利用率上, 有些改变.
其次, 我们希望管理平台在用户端能够完善功能, 把用户申请集群, 变更数据源以及变更schema 和查询,分词等功能以及监控等统一给用户提供门户. 当然, 在Dump 上, 有些业务方不一定能够有hive脚本的接入, 我们也提供也另外一个补足, 其实这个阶段, Dump 在平台端的快速迭代是要比主搜场景要多的. 归纳一下, 针对目前系统的结果,我们开始了搜索平台三期的迭代方向和节奏: 基于虚拟化Docker容器和Pass 平台的实时引擎.
下一期,我们也将介绍这部分的一些思路和细节.
更多流量、广告、搜索、算法相关内容, 敬请关注“美丽联合数据技术”公众号