赞
踩
目录
搜索平台的公共集群,由于业务众多,对业务的es查询语法缺少约束,导致问题频发。业务可能写了一个巨大的查询直接把集群打挂掉,但是我们平台人力投入有限,也不可能一条条去审核业务的es查询语法,只能通过后置的手段去保证整个集群的稳定性,通过slowlog分析等,下图中cpu已经100%了。
昨天刚好手头有一点点时间,就想着能不能针对这些情况,把影响最坏的业务抓出来,进行一些改善,于是昨天花了2小时分析了一下,找到了一些共性的问题,可以通过平台来很好的改善这些情况。
首先通过slowlog抓到一些耗时比较长的查询,例如下面这个索引的查询耗时基本都在300ms以上:
- {
- "from": 0,
- "size": 200,
- "timeout": "60s",
- "query": {
- "bool": {
- "must": \[
- {
- "match": {
- "source": {
- "query": "5",
- "operator": "OR",
- "prefix\_length": 0,
- "fuzzy\_transpositions": true,
- "lenient": false,
- "zero\_terms\_query": "NONE",
- "auto\_generate\_synonyms\_phrase\_query": "false",
- "boost": 1
- }
- }
- },
- {
- "terms": {
- "type": \[
- "21"
- \],
- "boost": 1
- }
- },
- {
- "match": {
- "creator": {
- "query": "0d754a8af3104e978c95eb955f6331be",
- "operator": "OR",
- "prefix\_length": 0,
- "fuzzy\_transpositions": "true",
- "lenient": false,
- "zero\_terms\_query": "NONE",
- "auto\_generate\_synonyms\_phrase\_query": "false",
- "boost": 1
- }
- }
- },
- {
- "terms": {
- "status": \[
- "0",
- "3"
- \],
- "boost": 1
- }
- },
- {
- "match": {
- "isDeleted": {
- "query": "0",
- "operator": "OR",
- "prefix\_length": 0,
- "fuzzy\_transpositions": "true",
- "lenient": false,
- "zero\_terms\_query": "NONE",
- "auto\_generate\_synonyms\_phrase\_query": "false",
- "boost": 1
- }
- }
- }
- \],
- "adjust\_pure\_negative": true,
- "boost": 1
- }
- },
- "\_source": {
- "includes": \[
- \],
- "excludes": \[\]
- }
- }
-
- 这个查询比较简单,翻译一下就是:
-
- ```sql
- SELECT guid FROM xxx WHERE source=5 AND type=21 AND creator='0d754a8af3104e978c95eb955f6331be' AND status in (0,3) AND isDeleted=0;
这个查询问题还挺多的,不过不是今天的重点。比如这里面不好的一点是还用了模糊查询fuzzy_transpositions,也就是查询ab的时候,ba也会被命中,其中的语法不是今天的重点,可以自行查询,我估计这个是业务用了SDK自动生成的,里面很多都是默认值。
第一反应是当然是用filter来代替match查询,一来filter可以缓存,另外避免这种无意义的模糊匹配查询,但是这个优化是有限的,并不是今天讲解的关键点,先忽略。
我们通过kibana的profile来进行分析,耗时到底在什么地方?es有一点就是开源社区很活跃,文档齐全,配套的工具也非常的方便和齐全。
可以看到大部分的时间都花在了PointRangQuery里面去了,这个是什么查询呢?为什么这么耗时呢?这里就涉及到一个es的知识点,那就是对于integer这种数字类型的处理。在es2.x的时代,所有的数字都是按keyword处理的,每个数字都会建一个倒排索引,这样查询虽然快了,但是一旦做范围查询的时候。比如 type>1 and type<5
就需要转成 type in (1,2,3,4,5)来进行,大大的增加了范围查询的难度和耗时。
之后es做了一个优化,在integer的时候设计了一种类似于b-tree的数据结构,加速范围的查询,详细可以参考(https://elasticsearch.cn/article/446)
所以在这之后,所有的integer查询都会被转成范围查询,这就导致了上面看到的isDeleted的查询的解释。那么为什么范围查询在我们这个场景下,就这么慢呢?能不能优化。
明明我们这个场景是不需要走范围查询的,因为如果走倒排索引查询就是O(1)的时间复杂度,将大大提升查询效率。由于业务在创建索引的时候,isDeleted这种字段建成了Integer类型,导致最后走了范围查询,那么只需要我们将isDeleted类型改成keyword走term查询,就能用上倒排索引了。
实际上这里还涉及到了es的一个查询优化。类似于isDeleted这种字段,毫无区分度的倒排索引的时候,在查询的时候,es是怎么优化的呢?
实际上,如果有多个term查询并列的时候,他的执行顺序,既不是你查询的时候,写进去的顺序。
例如上面这个查询,他既不是先执行source=5再执行type=21按照你代码的顺序执行过滤,也不是同时并发执行所有的过滤条件,然后再取交集。es很聪明,他会评估每个filter的条件的区分度,把高区分度的filter先执行,以此可以加速后面的filter循环速度。比如creator=0d754a8af3104e978c95eb955f6331be查出来之后10条记录,他就会优先执行这一条。
怎么做到的呢?其实也很简单,term建的时候,每一个term在写入的时候都会记录一个词频,也就是这个term在全部文档里出现的次数,这样我们就能判断当前的这个term他的区分度高低了。
上面提到了这种查询的数据结构类似于b-tree,他在做范围查询的时候,非常有优势,Lucene将这颗B-tree的非叶子结点部分放在内存里,而叶子结点紧紧相邻存放在磁盘上。当作range查询的时候,内存里的B-tree可以帮助快速定位到满足查询条件的叶子结点块在磁盘上的位置,之后对叶子结点块的读取几乎都是顺序的。
总结就是这种结构适合范围查询,且磁盘的读取是顺序读取的。但是在我们这种场景之下,term查询可就麻烦了,数值型字段的TermQuery被转换为了PointRangeQuery。这个Query利用Block k-d tree进行范围查找速度非常快,但是满足查询条件的docid集合在磁盘上并非向Postlings list那样按照docid顺序存放,也就无法实现postings list上借助跳表做蛙跳的操作。
要实现对docid集合的快速advance操作,只能将docid集合拿出来,做一些再处理。这个处理过程在org.apache.lucene.search.PointRangeQuery#createWeight这个方法里可以读取到。这里就不贴冗长的代码了,主要逻辑就是在创建scorer对象的时候,顺带先将满足查询条件的docid都选出来,然后构造成一个代表docid集合的bitset,这个过程和构造Query cache的过程非常类似。之后advance操作,就是在这个bitset上完成的。所有的耗时都在构建bitset上,因此可以看到耗时主要在build_scorer上了。
找到原因之后,就可以开始验证了。将原来的integer类型全部改成keyword类型,如果业务真的有用到范围查询,应该会报错。通过搜索平台的平台直接修改配置,修改完成之后,重建索引就生效了。
索引切换之后的效果也非常的明显,通过kibana的profile分析可以看到,之前需要接近100ms的PointRangQuery现在走倒排索引,只需要0.5ms的时间。
之前这个索引的平均latency在100ms+,这个是es分片处理的耗时,从搜索行为开始,到搜索行为结束的打点,不包含网络传输时间和连接建立时间,单纯的分片内的函数的处理时间的平均值,正常情况在10ms左右。
经过调整之后的耗时降到了10ms内。
通过监控查看慢查询的数量,立即减少到了0。
后续将通过搜索平台侧的能力来保证业务的查询,所有的integer我们会默认你记录的是状态值,不需要进行范围查询,默认将会修改为keyword类型,如果业务确实需要范围查询,则可以通过后台再修改回integer类型,这样可以保证在业务不了解es机制的情况下,也能拥有较好的性能,节省机器计算资源。
目前还遇到了很多问题需要优化。例如重建索引的时候,机器负载太高。公共集群的机器负载分布不均衡的问题,业务的查询和流量不可控等各种各样的问题,要节省机器资源就一定会面对这种各种各样的问题,除非土豪式做法,每个业务都拥有自己的机器资源,这里面有很多很多颇具技术挑战的事情。
实际上,在这一块还是非常利于积累经验,对于es的了解和成长也非常快,在查问题的过程中,对于搜索引擎的使用和了解会成长的非常快。不仅如此,很多时候,我们用心的看到生产的问题,持续的跟踪,一定会有所收获。大家遇到生产问题的时候,务必不要放过任何细节,这个就是你收获的时候,比你写100行的CRUD更有好处。
先来看看 ES 在腾讯的主要应用场景。ES 是一个实时的分布式搜索分析引擎,目前很多用户对 ES 的印象还是准实时,实际上在 6.8 版本之后官方文档已经将 near real-time 改为了 real-time: "Elasticsearch provides real-time search and analytics for all types of data." ES 在写入完毕刷新之前,是可以通过 getById
的方式实时获取文档的,只是在刷新之前 FST 还没有构建,还不能提供搜索的能力。 目前 ES 在腾讯主要应用在三个方面:
当然除了上面的场景之外,ES 本身在站内搜索、安全、APM 等领域也有广泛的应用。
目前 ES 在腾讯公有云、专有云以及内部云上面均有提供服务,可以广泛的满足公司内外客户的业务需求。公有云上的使用场景非常丰富,专有云主要实现标准化交付和自动化运维,腾讯内部云上的 ES 都是 PB 级的超大规模集群。
在这些丰富的应用场景,以及海量的规模背景下,我们也遇到了很多的痛点与挑战。主要覆盖在可用性、性能、成本以及扩展性方面。
ES 使用姿势、参数调优等在社区有很多的案例和经验可以借鉴,但很多的痛点和挑战是无法通过简单的调优来解决的,这个时候就需要从内核层面做深度的优化,来不断完善这个优秀的开源产品。接下来就是本次分享的核心部分,我们来看看腾讯是如何在内核层面对 ES 做优化的。
首先介绍可用性优化部分。总体来说,原生版本在可用性层面有三个层面的问题:
系统健壮性不足: 高压力下集群雪崩,主要原因是内存资源不足。负载不均会导致部分节点压力过载,节点 OOM。我们在这个层面的方案主要是优化服务限流和节点均衡策略。
容灾方案欠缺: ES 本身提供副本机制提升数据安全性,对于多可用区容灾还是需要云平台额外实现。即使有副本机制,甚至有跨集群复制(CCR),但还是不能阻挡用户误操作导致的数据删除,所以还需要额外提供低成本的备份回挡能力。
内核 Bug: 我们修复了 Master 任务堵塞、分布式死锁、滚动重启速度慢等一系列内核可用性相关的问题,并及时提供新版本给用户升级。
接下来针对用户在可用性层面常遇到的两类问题展开分析。一类是高并发请求压垮集群,另一类是单个大查询打挂节点。
先来看第一类场景,高并发请求压垮集群。例如早期我们内部一个日志集群,写入量一天突增 5 倍,集群多个节点 Old GC 卡住脱离集群,集群 RED,写入停止,这个痛点确实有点痛。我们对挂掉的节点做了内存分析,发现大部分内存是被反序列化前后的写入请求占用。我们来看看这些写入请求是堆积在什么位置。
ES high level 的写入流程,用户的写入请求先到达其中一个数据节点,我们称之为数据节点。然后由该协调节点将请求转发给主分片所在节点进行写入,主分片写入完毕再由主分片转发给从分片写入,最后返回给客户端写入结果。右边是更细节的写入流程,而我们从堆栈中看到的写入请求堆积的位置就是在红色框中的接入层,节点挂掉的根因是协调节点的接入层内存被打爆。
找到了问题的原因,接下来介绍我们的优化方案。
针对这种高并发场景,我们的优化方案是服务限流。除了要能控制并发请求数量,还要能精准的控制内存资源,因为内存资源不足是主要的矛盾。另外通用性要强,能作用于各个层级实现全链限流。
限流方案,很多数据库使用场景会采用从业务端或者独立的 proxy 层配置相关的业务规则,做资源预估等方式进行限流。这种方式适应能力弱,运维成本高,而且业务端很难准确的预估资源消耗。
原生版本本身有限流策略,是基于请求数的漏桶策略,通过队列加线程池的方式实现。线程池大小决定的了处理并发度,处理不完放到队列,队列放不下则拒绝请求。但是单纯的基于请求数的限流不能控制资源使用量,而且只作用于分片级子请求的传输层,对于我们前面分析的接入层无法起到有效的保护作用。原生版本也有内存熔断策略,但是在协调节点接入层并没有做限制。
我们的优化方案是基于内存资源的漏桶策略。我们将节点 JVM 内存作为漏桶的资源,当内存资源足够的时候,请求可以正常处理,当内存使用量到达一定阈值的时候分区间阶梯式平滑限流。例如图中浅黄色的区间限制写入,深黄色的区间限制查询,底部红色部分作为预留 buffer,预留给处理中的请求、merge 等操作,以保证节点内存的安全性。
限流方案里面有一个挑战是,我们如何才能实现平滑限流?因为采用单一的阈值限流很容易出现请求抖动,例如请求一上来把内存打上去马上触发限流,而放开一点点请求又会涌进来把内存打上去。我们的方案是设置了高低限流阈值区间,在这个区间中,基于余弦变换实现请求数和内存资源之间的平滑限流。当内存资源足够的时候,请求通过率 100%,当内存到达限流区间逐步上升的时候,请求通过率随之逐步下降。而当内存使用量下降的时候,请求通过率也会逐步上升,不会一把放开。通过实际测试,平滑的区间限流能在高压力下保持稳定的写入性能。
我们基于内存资源的区间平滑限流策略是对原生版本基于请求数漏桶策略的有效补充,并且作用范围更广,覆盖协调节点、数据节点的接入层和传输层,并不会替代原生的限流方案。
接下来介绍单个大查询打挂节点的场景。例如我们在分析场景,做多层嵌套聚合,有时候请求返回的结果集比较大,那么这个时候极有可能这一个请求就会将节点打挂。我们对聚合查询流程进行分析,请求到达协调节点之后,会拆分为分片级子查询请求给目标分片所在数据节点进行子聚合,最后协调节点收集到完整的分片结果后进行归并、聚合、排序等操作。这里的主要问题点是,协调节点大量汇聚结果反序列化后内存膨胀,以及二次聚合产生新的结果集打爆内存。
针对上面单个大查询的问题,下面介绍我们的优化方案。优化方案的要点是内存膨胀预估加流式检查。 我们先来看下原生方案,原生版本是直接限制最大返回结果桶数,默认一万,超过则请求返回异常。这种方式面临的挑战是,在分析场景结果数十万、百万是常态,默认一万往往不够,调整不灵活,调大了内存可能还是会崩掉,小了又不能满足业务需求。
我们的优化方案主要分为两个阶段:
这样用户不再需要关心最大桶数,只要内存足够就能最大化地满足业务需求。不足之处是大请求还是被拒掉了,牺牲了用户的查询体验,但是我们可以通过官方已有的 batch reduce 的方式缓解,就是当有 100 个分片子结果的时候,每收到部分就先做一次聚合,这样能降低单次聚合的内存开销。上面流式聚合的整体方案已经提交给官方并合并了,将在最近的 7.7.0 版本中发布。
前面介绍了两种比较典型的用户常遇到的可用性问题。接下来对整个可用性优化做一个总结。
首先我们结合自研的优化方案和原生的方案实现了系统性的全链路限流。左图中黄色部分为自研优化,其它为原生方案。覆盖执行引擎层、传输层和接入层。另外我们对内存也做了相关的优化,内存利用率优化主要是针对写入场景,例如单条文档字段数过多上千个,每个字段值在写入过程中都会申请固定大小的 buffer,字段数过多的时候内存浪费严重,优化方案主要是实现弹性的内存 buffer。内存回收策略,这里不是指 GC 策略,主要是对于有些例如读写异常的请求及时进行内存回收。JVM GC 债务管理主要是评估 JVM Old GC 时长和正常工作时长的比例来衡量 JVM 的健康情况,特殊情况会重启 JVM 以防止长时间 hang死。
可用性优化效果,我们将公有云的 ES 集群整体可用性提升至 4 个 9,内存利用率提升 30%,高压力场景稳定性有大幅提升,基本能保证节点不会 OOM,集群不会雪崩。
下面部分是我们可用性优化相关的 PR。除了前面介绍的协调节点流式检查和内存膨胀预估以外,还包括单个查询内存限制,这个也很有用,因为有些场景如果单个查询太大会影响其它所有的请求。以及滚动重启速度优化,大集群单个节点的重启时间从 10 分钟降至 1 分钟以内,这个优化在 7.5 版本已经被合并了。如果大家遇到大集群滚动重启效率问题可以关注。
接下来介绍性能优化。
性能优化的场景主要分为写入和查询。写入的代表场景包括日志、监控等海量时序数据场景,一般能达到千万级吞吐。带 id 的写入性能衰减一倍,因为先要查询记录是否存在。查询包含搜索场景和分析场景,搜索服务主要是高并发,低延时。聚合分析主要以大查询为主,内存、CPU 开销高。
我们看下性能的影响面,左半部分硬件资源和系统调优一般是用户可以直接掌控的,比如资源不够扩容,参数深度调优等。右半部分存储模型和执行计划涉及到内核优化,用户一般不容易直接调整。接下来我们重点介绍一下这两部分的优化。
首先是存储模型优化。我们知道 ES 底层 Lucene 是基于 LSM Tree 的数据文件。原生默认的合并策略是按文件大小相似性合并,默认一次固定合并 10 个文件,近似分层合并。这种合并方式的最大优点是合并高效,可以快速降低文件数;主要问题是数据不连续,这样会导致我们在查询的时候文件裁剪的能力很弱,比如查询最近一小时的数据,很有可能一小时的文件被分别合并到了几天前的文件中去了,导致需要遍历的文件增加了。
业内典型的解决数据连续性的合并策略,比如以 Cassandra、HBase 为代表的基于时间窗口的合并策略,优点是数据按时间序合并,查询高效,且可以支持表内 TTL;不足是限制只能是时序场景,而且文件大小可能不一致,从而影响合并效率。还有一类是以 LevelDB、RocksDB 为代表的分层合并,一层一组有序,每次抽取部分数据向下层合并,优点是查询高效,但是写放大比较严重,相同的数据可能会被多次合并,影响写入吞吐。
最后是我们的优化合并策略。我们的目标是为了提升数据连续性、收敛文件数量,提升文件的裁剪能力来提高查询性能。我们实现的策略主要是按时间序分层合并,每层文件之间按创建时间排序,除了第一层外,都按照时间序和目标大小进行合并,不固定每次合并文件数量,这样保证了合并的高效性。对于少量的未合并的文件以及冷分片文件,我们采用持续合并的策略,将超过默认五分钟不再写入的分片进行持续合并,并控制合并并发和范围,以降低合并开销。
通过对合并策略的优化,我们将搜索场景的查询性能提升了 40%。
前面介绍了底层文件的存储模型优化,我们再来向上层看看执行引擎的优化。
我们拿一个典型的场景来进行分析。ES 里面有一种聚合叫 Composite 聚合大家可能都比较了解,这个功能是在 6.5 版本正式 GA 发布的。它的目的是为了支持多字段的嵌套聚合,类似 MySQL 的 group by 多个字段;另外可以支持流式聚合,即以翻页的形式分批聚合结果。用法就像左边贴的查询时聚合操作下面指定 composite 关键字,并指定一次翻页的长度,和 group by 的字段列表。那么每次拿到的聚合结果会伴随着一个 after key 返回,下一次查询拿着这个 after key 就可以查询下一页的结果。
那么它的实现原理是怎样的呢?我们先来看看原生的方案。比如这里有两个字段的文档,field1 和 field2,第一列是文档 id 。我们按照这两个字段进行 composite 聚合,并设定一次翻页的 size 是 3。具体实现是利用一个固定 size 的大顶堆,size 就是翻页的长度,全量遍历一把所有文档迭代构建这个基于大顶堆的聚合结果,如右图中的 1 号序列所示,最后返回这个大顶堆并将堆顶作为 after key。第二次聚合的时候,同样的全量遍历一把文档,但会加上过滤条件排除不符合 after key 的文档,如右图中 2 号序列所示。
很显然这里面存在性能问题,因为每次拉取结果都需要全量遍历一遍所有文档,并未实现真正的翻页。接下来我们提出优化方案。
我们的优化方案主要是利用 index sorting 实现 after key 跳转以及提前结束(early termination)。 数据有序才能实现真正的流式聚合,index sorting 也是在 6.5 版本里面引入的,可以支持文档按指定字段排序。但遗憾的是聚合查询并没有利用数据有序性。我们可以进行优化,此时大顶堆我们仍然保留,我们只需要按照文档的顺序提取指定 size 的文档数即可马上返回,因为数据有序。下一次聚合的时候,我们可以直接根据请求携带的 after key 做跳转,直接跳转到指定位置继续向后遍历指定 size 的文档数即可返回。这样避免了每次翻页全量遍历,大幅提升查询性能。这里有一个挑战点,假设数据的顺序和用户查询的顺序不一致优化还能生效吗?实际可以的,逆序场景不能实现 after key 跳转因为 lucene 底层不能支持文档反向遍历,但提前结束的优化仍然生效,仍然可以大幅提升效率。这个优化方案我们是和官方研发协作开发的,因为我们在优化的同时,官方也在优化,但我们考虑的更全面覆盖了数据顺序和请求顺序不一致的优化场景,最终我们和官方一起将方案进行了整合。该优化方案已经在 7.6 合并,大家可以试用体验。
前面从底层的存储模型到上层的执行引擎分别举例剖析了优化,实际上我们在性能层面还做了很多的优化。从底层的存储模型到执行引擎,到优化器,到上层的缓存策略基本都有覆盖。下图中左边是优化项,中间是优化效果,右边是有代表性的优化的 PR 列表。
这里简单再介绍一下其它的 PR 优化,中间这个 translog 刷新过程中锁的粗化优化能将整体写入性能提升 20%;这个 lucene 层面的文件裁剪优化,它能将带 id 写入场景性能提升一倍,当然查询也是,因为带 id 的写入需要先根据 id 查询文档是否存在,它的优化主要是在根据 id 准备遍历查询一个 segment 文件的时候,能快速根据这个 segment 所统计的最大最小值进行裁剪,如果不在范围则快速裁剪跳过,避免遍历文档;最下面的一个 PR 是缓存策略的优化,能避免一些开销比较大的缓存,大幅的降低查询毛刺。
上面这些性能优化项在我们腾讯云的 ES 版本中均有合入,大家可以试用体验。
接下来我们再看成本优化。在日志、时序等大规模数据场景下,集群的 CPU、内存、磁盘的成本占比是 1 比 4 比 8。例如一般 16 核 64GB,2-5 TB 磁盘节点的成本占比大概是这个比例。因此成本的主要瓶颈在于磁盘和内存。
成本优化的主要目标是存储成本和内存成本。
我们先来看下存储成本。
我们先来看一个场景,整个腾讯云监控是基于 ES 的,单个集群平均写入千万每秒,业务需要保留至少半年的数据供查询。我们按照这个吞吐来计算成本,1000 万 QPS 乘以时间乘以单条文档平均大小再乘以主从两个副本总共大约 14 PB 存储,大约需要 1500 台热机型物理机。这显然远远超出了业务成本预算,那我们如何才能既满足业务需求又能实现低成本呢?
来看下我们的优化方案,首先我们对业务数据访问频率进行调研,发现最近的数据访问频率较高,例如最近 5 分钟的,一小时的,一天的,几天的就比较少了,超过一个月的就更少了,历史数据偏向于统计分析。
首先我们可以通过冷热分离,把冷数据放到 HDD 来降低成本,同时利用官方提供的索引生命周期管理来搬迁数据,冷数据盘一般比较大我们还要利用多盘策略来提高吞吐和数据容灾能力。最后将超冷的数据冷备到腾讯云的对象存储 COS 上,冷备成本非常低,1GB 一个月才一毛多。
上面这些我们都可以从架构层面进行优化。是否还有其它优化点呢?基于前面分析的数据访问特征,历史数据偏向统计分析,我们提出了 Rollup 方案。Rollup 的目的是对历史数据降低精度,来大幅降低存储成本。我们通过预计算来释放原始细粒度的数据,例如秒级的数据聚合成小时级,小时级聚合成天级。这样对于用户查询时间较长的跨度报表方便展示,查询几天的秒级数据太细没法看。另外可以大幅降低存储成本,同时可以提升查询性能。
我们在 17 年的时候就实现了 Rollup 的方案并投入给了腾讯云监控使用,当然目前官方也出了 Rollup 方案,目前功能还在体验中。
下面介绍一下我们最新的 Rollup 方案的要点。
总体来说 Rollup 优化方案主要是基于流式聚合加查询剪枝结合分片级并发来实现其高效性。流式聚合和查询剪枝的优化我们前面在性能优化部分已经介绍了,我们新的 Rollup 也利用了这些优化,这里不再展开。下面介绍一下分片级并发,及并发自动控制策略。
正常的聚合查询,需要将请求发送给每个分片进行子聚合,在到协调节点做汇聚,两次聚合多路归并。我们通过给数据添加 routing 的方式让相同的对象落到相同的分片内,这样就只需要一层聚合,因为分片数据独立,多个数据对象可以实现分片级并发。 另外我们通过对 Rollup 任务资源预估,并感知集群的负载压力来自动控制并发度,这样对集群整体的影响能控制在一定的范围。右边的图是我们的优化效果,某个统计指标 30 天的存储量,天级的只需要 13 GB,小时级的只需要 250 GB,细粒度的会多一些,总体存储量下降了将近 10 倍。单个集群 150 台左右物理机即可搞定,成本缩减 10 倍。整体写入开销 rollup 资源消耗在 10% 以下。
前面是存储成本优化,下面介绍内存成本优化。
我们通过对线上集群进行分析,发现很多场景堆内内存使用率很高,而磁盘的使用率比较低。堆内存使用率为什么这么高呢?其中的 FST 即倒排索引占据了绝大部分堆内内存,而且这部分是常驻内存的。每 10 TB 的磁盘 FST 的内存消耗大概在 10 GB 到 15 GB 左右。
我们能不能对 FST 这种堆内占用比较大的内存做优化?我们的想法是把它移至堆外(off-heap),按需加载,提升堆内内存利用率,降低 GC 开销,提升单个节点管理磁盘的能力。
我们来看下 off-heap 相关的方案。首先原生版本目前也实现了 off-heap,方案是将 FST 对象放到 MMAP 中管理,这种方式实现简单,我们早期也采用了这种方式实现,但是由于 MMAP 属于 page cache 可能被系统回收掉,导致读盘操作,从而带来性能的 N 倍损耗,容易产生查询毛刺。
HBase 2.0 版本中也实现了 off-heap,在堆外建立了 cache,脱离系统缓存,但只是把数据放到堆外,索引仍然在堆内,而且淘汰策略完全依赖 LRU 策略,冷数据不能及时的清理。
我们的优化方案也是在堆外建立 cache,保证 FST 的空间不受系统影响,另外我们会实现更精准的淘汰策略,提高内存使用率,再加上多级 cache 的管理模式来提升性能。这种方式实现起来比较复杂但收益还是很明显的,下面我们来看一下详细的实现。
我们的方案是通过 LRU cache + 零拷贝 + 两级 cache 的方式实现的。首先 LRU cache 是建立在堆外,堆内有访问 FST 需求的时候从磁盘加载到 cache 中。由于 Lucene 默认的访问 FST 的方式是一个堆内的 buffer,前期我们采用了直接从堆外拷贝到堆内的 buffer 方式实现,压测发现查询性能损耗 20%,主要是堆外向堆内 copy 占了大头。
因此我们有了第二阶段优化,将 Lucene 访问 FST 的方式进行了改造,buffer 里面不直接存放 FST,而存放堆外对象的一个指针,这样实现了堆内和堆外之间的零拷贝,这里的零拷贝和我们说的 linux 中的用户态和内核态的零拷贝是两个概念。这样实现后我们压测发现查询性能还是有 7%的损耗,相较于堆内的 FST 场景。我们有没办法做到极致呢?
我们通过分析发现,这 7% 的损耗主要是根据 key 去查找堆外对象的过程,包括计算 hash,数据校验等。我们第三阶段的优化就是利用 Java 的弱引用建立第二层轻量级缓存。弱引用指向堆外的地址,只要有请求使用,这个 key 就不会被回收可以重复利用而无需重新获取。一旦不在使用,这个 key 就会被 GC 回收掉,并回收掉堆外对象指针。问题来了,堆外对象指针回收之后我们怎么清理堆外这部分内存呢?让其 LRU 淘汰?这样显然会浪费一部分内存空间。最好的办法是在堆内对象地址回收的时候直接回收堆外对象,但是 Java 没有析构的概念。这里我们利用了弱引用的 Reference Queue,当对象要被 GC 回收的时候会将对象指向的堆外内存清理掉,这样完美解决了堆外内存析构的问题,保证了堆外内存的精准淘汰,提升内存利用率。最后通过压测我们发现性能基本和原生方案 FST 在堆内的场景持平。
下面是内存优化相关的效果和收益:
通过我们的内存优化后,内存开销、数据管理能力、GC 优势明显,性能持平略有优势。单个 FST 堆内占用只需要 100 个 byte 左右即 cache key 的大小。单节点磁盘管理能力,32GB heap 能到 50 TB 左右,相较原生版本 5-10 TB(需要深度调优)有 10 倍的提升。利用官方提供的 esrally 进行性能压测,发现堆内存使用量有大幅的下降,GC 时长也有缩减,性能基本持平。
接下来是最后一块内核优化内容,扩展性优化。
我们先来看一下场景。ES 的元数据管理模型是,master 管理元数据,其它所有节点同步元数据。我们以建索引流程为例,来看看元数据的同步流程。首先是 master 分配分片,然后产生 diff 的元数据,发送给其它节点,等待大多数 master 节点返回,master 发送元数据应用请求,其它节点开始应用元数据,并根据新旧元数据推导出各自节点的分片创建任务。
这里面的瓶颈点主要有以下几点:
基于上面的瓶颈,目前原生版本只能支持大约 3-5 万分片,性能已经到达极限,创建索引基本到达 30 秒+ 甚至分钟级。节点数只能到 500 左右基本是极限了。
为此,我们提出了扩展性优化方案。
主要的优化内容包括:
前面就是所有的内核优化的内容。ES 是一款很优秀的开源大数据产品,我们将持续的建设。我们对公司内外提供了完整的托管平台,对 ES 内核各个层面做了系统性的增强优化,助力 Elastic Stack 在大数据生态中覆盖更多的场景,发展的更好。
在腾讯内部,我们实现了 ES 产品开源协同,共同优化完善 ES,避免不同的团队重复踩坑。同时我们也将优秀的方案积极的贡献给社区,和官方及社区的 ES 爱好者们共同推动 ES 的发展。以腾讯 ES 内核研发为代表的团队,截至目前我们共提交了二十多个 PR,其中有 70% 被合并,共有 6 位 ES/Lucene 的 contributor。
未来,我们将持续的优化 ES,包括可用性,性能和成本等方面。可用性方面我们会加强 ES 的故障自愈能力,朝着自动驾驶的目标发展;性能方面,搜索场景 ES 是绝对的王者,多维分析领域还有很多可优化的地方,我们希望能进一步扩展 ES 在分析领域的应用场景。成本方面,存储与计算分离正在研发中,基于腾讯自研的共享文件系统 CFS,到时会进一步缩减成本,提升性能。
最近十年,Elasticsearch 已经成为了最受欢迎的开源检索引擎,其作为离线数仓、近线检索、B端检索的经典基建,已沉淀了大量的实践案例及优化总结。然而在高并发、高可用、大数据量的 C 端场景,目前可参考的资料并不多。因此,我们希望通过分享在外卖搜索场景下的优化实践,能为大家提供 Elasticsearch 优化思路上的一些借鉴。
美团在外卖搜索业务场景中大规模地使用了 Elasticsearch 作为底层检索引擎。其在过去几年很好地支持了外卖每天十亿以上的检索流量。然而随着供给与数据量的急剧增长,业务检索耗时与 CPU 负载也随之上涨。通过分析我们发现,当前检索的性能热点主要集中在倒排链的检索与合并流程中。针对这个问题,我们基于 Run-length Encoding(RLE)[1] 技术设计实现了一套高效的倒排索引,使倒排链合并时间(TP99)降低了 96%。我们将这一索引能力开发成了一款通用插件集成到 Elasticsearch 中,使得 Elasticsearch 的检索链路时延(TP99)降低了 84%。
当前,外卖搜索业务检索引擎主要为 Elasticsearch,其业务特点是具有较强的 Location Based Service(LBS) 依赖,即用户所能点餐的商家,是由商家配送范围决定的。对于每一个商家的配送范围,大多采用多组电子围栏进行配送距离的圈定,一个商家存在多组电子围栏,并且随着业务的变化会动态选择不同的配送范围,电子围栏示意图如下:
考虑到商家配送区域动态变更带来的问题,我们没有使用 Geo Polygon[2] 的方式进行检索,而是通过上游一组 R-tree 服务判定可配送的商家列表来进行外卖搜索。因此,LBS 场景下的一次商品检索,可以转化为如下的一次 Elasticsearch 搜索请求:
- POST food/_search
- {
- "query": {
- "bool": {
- "must":{
- "term": { "spu_name": { "value": "烤鸭"} }
- //...
- },
- "filter":{
- "terms": {
- "wm_poi_id": [1,3,18,27,28,29,...,37465542] // 上万
- }
- }
- }
- }
- //...
- }
对于一个通用的检索引擎而言,Term 检索非常高效,一次 Term 检索耗时不到 0.001 ms。因此在早期时,这一套架构和检索 DSL 可以很好地支持美团的搜索业务——耗时和资源开销尚在接受范围内。然而随着数据和供给的增长,一些供给丰富区域的附近可配送门店可以达到 20000~30000 家,这导致性能与资源问题逐渐凸显。这种万级别的 Terms 检索的性能与耗时已然无法忽略,仅仅这一句检索就需要 5~10 ms。
由于 Elasticsearch 在设计上针对海量的索引数据进行优化,在过去的 10 年间,逐步去除了内存支持索引的功能(例如 RAMDirectory 的删除)。为了能够实现超大规模候选集的检索,Elasticsearch/Lucene 对 Term 倒排链的查询流程设计了一套内存与磁盘共同处理的方案。
一次 Terms 检索的流程分为两步:分别检索单个 Term 的倒排链,多个 Term 的倒排链进行合并。
可以看到,这一查询流程非常复杂且耗时,且各个阶段的复杂度都不容忽视。所有的 Term 在索引中有序存储,通过二分查找找到目标 Term。这个有序的 Term 列表就是 TermDictionary ,二分查找 Term 的时间复杂度为 O(logN) ,其中 N 是 Term 的总数量 。Lucene 采用 Finite State Transducer[3](FST)对 TermDictionary 进行编码构建 Term Index。FST 可对 Term 的公共前缀、公共后缀进行拆分保存,大大压缩了 TermDictionary 的体积,提高了内存效率,FST 的检索速度是 O(len(term)),其对于 M 个 Term 的检索复杂度为 O(M * len(term))。
在经过上述的查询,检索出所有目标 Term 的 Posting List 后,需要对这些 Posting List 求并集(OR 操作)。在 Lucene 的开源实现中,其采用 Bitset 作为倒排链合并的容器,然后遍历所有倒排链中的每一个文档,将其加入 DocIdSet 中。
伪代码如下:
- Input: termsEnum: 倒排表;termIterator:候选的term
- Output: docIdSet : final docs set
- for term in termIterator:
- if termsEnum.seekExact(term) != null:
- docs = read_disk() // 磁盘读取
- docs = decode(docs) // 倒排链的decode流程
- for doc in docs:
- docIdSet.or(doc) //代码实现为DocIdSetBuilder.add。
- end for
- docIdSet.build()//合并,排序,最终生成DocIdSetBuilder,对应火焰图最后一段。
假设我们有 M 个 Term,每个 Term 对应倒排链的平均长度为 K,那么合并这 M 个倒排链的时间复杂度为:O(K * M + log(K * M))。可以看出倒排链合并的时间复杂度与 Terms 的数量 M 呈线性相关。在我们的场景下,假设一个商家平均有一千个商品,一次搜索请求需要对一万个商家进行检索,那么最终需要合并一千万个商品,即循环执行一千万次,导致这一问题被放大至无法被忽略的程度。
我们也针对当前的系统做了大量的调研及分析,通过美团内部的 JVM Profile 系统得到 CPU 的火焰图,可以看到这一流程在 CPU 热点图中的反映也是如此:无论是查询倒排链、还是读取、合并倒排链都相当消耗资源,并且可以预见的是,在供给越来越多的情况下,这三个阶段的耗时还会持续增长。
可以明确,我们需要针对倒排链查询、倒排链合并这两个问题予以优化。
通常情况下,使用 FST 作为 Term 检索的数据结构,可以在内存开销和计算开销上取得一个很好的平衡,同时支持前缀检索、正则检索等多种多样的检索 Query,然而在我们的场景之下,FST 带来的计算开销无法被忽视。
考虑到在外卖搜索场景有以下几个特性:
因此在我们的应用场景中使用空间换取时间是值得的。
对于 Term 查询的热点:可替换 FST 的实现以减少 CPU 开销,常见的查找数据结构中,哈希表有 O(1) 的查询复杂度,将 Term 查找转变为对哈希表的一次查询。
对于哈希表的选取,我们主要选择了常见的 HashMap 和 LongObjectHashMap。
我们主要对比了 FST、HashMap 和 LongObjectHashMap(哈希表的一种高性能实现)的空间和时间效率。
注:检索类型虽然为 Long,但是我们将底层存储格式进行了调整,没有使用开源的 BKD Tree 实现,使用 FST 结构,仅与 FST 进行对比。BKD Tree 在大批量整数 terms 的场景下劣势更为明显。
我们使用十万个 <Long, Long>
的键值对来构造数据,对其空间及性能进行了对比,结果如下:
可以看到, 在内存占用上 FST 要远优于 LongObjectHashMap 和 HashMap。而在查询速度上 LongObjectHashMap 最优。
我们最终选择了 LongObjectHashMap 作为倒排链查询的数据结构。
基于上述问题,我们需要解决两个明显的 CPU 热点问题:倒排链读取 & 倒排链合并。我们需要选择合适的数据结构缓存倒排链,不再执行磁盘 /page cache 的 IO。数据结构需要必须满足以下条件:
在给出具体的解决方案之前,先介绍一下 Lucene 对于倒排合并的原生实现、RoaringBitMap、Index Sorting。
Lucene在不同场景上使用了不同的倒排格式,提高整体的效率(空间/时间),通过火焰图可以发现,在我们的场景上,TermInSetQuery 的倒排合并逻辑开销最大。
TermInSetQuery 的倒排链合并操作分为两个步骤:倒排链读取和合并。
倒排链读取
Lucene 倒排链压缩存储在索引文件中,倒排链读取需要实时解析,其对外暴露的 API 为迭代器结构。
倒排链合并
倒排链合并主要由 DocIdSetBuilder 合并生成倒排链,先使用稀疏结构存储 Doc ID,当 Doc ID 个数超过一定阈值时,升级到稠密结构(FixedBitSet)存储,实现方式如下(对应代码 IntArrayDocIdSet/BitDocIdSet):
List<int[]> array
方式存储 Doc ID,最终经过 Merge 和排序形成一个有序数组 int[],耗时主要集中在数组申请和排序。我们采用线上流量和数据压测发现该部分平均耗时约 7 ms。
当前 Elasticsearch 选择 RoaringBitMap 做为 Query Cache 的底层数据结构缓存倒排链,加快查询速率。
RoaringBitmap 是一种压缩的位图,相较于常规的压缩位图能提供更好的压缩,在稀疏数据的场景下空间更有优势。以存放 Integer 为例,Roaring Bitmap 会对存入的数据进行分桶,每个桶都有自己对应的 Container。在存入一个32位的整数时,它会把整数划分为高 16 位和低 16 位,其中高 16 位决定该数据需要被分至哪个桶,我们只需要存储这个数据剩余的低 16 位,将低 16 位存储到 Container 中,若当前桶不存在数据,直接存储 null 节省空间。RoaringBitmap有不同的实现方式,下面以 Lucene 实现(RoaringDocIdSet)进行详细讲解:
如原理图中所示,RoaringBitmap 中存在两种不同的 Container:Bitmap Container 和 Array Container。
这两种 Container 分别对应不同的数据场景——若一个 Container 中的数据量小于 4096 个时,使用 Array Container 来存储。当 Array Container 中存放的数据量大于 4096 时,Roaring Bitmap 会将 Array Container 转为 Bitmap Container。即 Array Container 用于存放稀疏数据,而 Bitmap Container 用于存放稠密数据,这样做是为了充分利用空间。下图给出了随着容量增长 Array Container 和 Bitmap Container 的空间占用对比图,当元素个数达到 4096 后(每个元素占用 16 bit ),Array Container 的空间要大于 Bitmap Container。 备注:Roaring Bitmap 可参考官方博客[4]。
Elasticsearch 从 6.0 版本开始支持 Index Sorting[5] 功能,在索引阶段可以配置多个字段进行排序,调整索引数据组织方式,可以调整文档所对应的 Doc ID。以 city_id,poi_id 为例:
如上示例所示:Index Sorting 会将给定的排序字段(如上图的 city_id 字段)的文档排序在一起,相同排序值的文档的 Doc ID 严格自增,对该字段建立倒排,那么其倒排链为自增数列。
基于以上的背景知识以及当前 Elasticsearch/Lucene 的解决方案,可以明确目前有 2 个改造点需要考虑。
对于索引倒排格式 PostingsEnum,支持接口为:
- public abstract class DocIdSetIterator {
- public abstract int docID();
- public abstract int nextDoc();
- public abstract int advance(int target);
- }
倒排仅支持单文档循环调用,不支持批量读取,因此需要为倒排增加批量顺序读取的功能。
对于多倒排链的合并,由于原结构 DocIdSetBuilder 的实现也不支持批量对数据进行合并,我们探索了评估了 Elasticsearch 用于缓存 Query Cache 的数据结构 RoaringBitMap,然而其实现 RoaringDocIdSet 也无法满足我们对缓存数据结构特性需求,主要问题:
在明确这个问题的场景下,我们可以考虑最简单的改造,支持索引倒排格式 PostingsEnum 的批量读取。并考虑了如下几种场景:
然而我们发现即使对 bitset 进行分段合并,直接对数据成段进行 OR 操作,整体开销下降并不明显。其原因主要在于:对于读取的批量结果,均为稀疏分布的 Doc ID,仅减少倒排的循环调用无法解决性能开销问题。
那么问题需要转化为如何解决 Doc ID 分布稀疏的问题。在上文提及的 Index Sorting 即一个绝佳的解决方案。并且由于业务 LBS 的特点,一次检索的全部结果集均集中在某个地理位置附近,以及我们检索仅针对门店列表 ID 的特殊场景,我们最终选择对城市 ID、 Geohash、门店 ID 进行排序,从而让稀疏分布的 Doc ID 形成连续分布。在这样的排序规则应用之后,我们得到了一组绝佳的特性:每一个商家所对应的商品,其 Doc ID 完全连续。
Run-Length Encoding[3](RLE)技术诞生于50年前,最早应用于连续的文本压缩及图像压缩。在 2014 年,第一个开源在 GitHub 的 RoaringBitmap 诞生[6],2016年,在 RoaringBitMap 的基础上增加了对于自增序列的 RLE 实现[7],并应用在 bitmap 这一场景上。
在 bitmap 这一场景之下,主要通过压缩连续区间的稠密数据,节省内存开销。以数组 [1, 2, 3, ..., 59, 60, 89, 90, 91, ..., 99, 100] 为例(如下图上半部分):使用 RLE 编码之后就变为 [1, 60, 89, 12]——形如 [start1, length1, start2, length2, ...] 的形式,其中第一位为连续数字的起始值,第二位为其长度。
在数组完全连续场景下中,对 32768 个 id (short) 进行存储,数组存储需要 64 kB,Bitmap 存储需要使用 4 kB,而 RLE 编码后直接存储仅需要 4 byte。在这一特性下,如果商家倒排链完全有序,那么商家的倒排链,可被压缩到最低仅需要两个整数即可表示。
当然 RLE 并不适用所有情况,在目标序列完全不连续的场景下,如 [1, 3, 5, 7, ... , M],RLE 编码存储需要使用 2 * M byte的空间,比数组直接存储的空间效率差一倍。
为了和 Elasticsearch 的实现保持一致,我们决定使用 RoaringBitMap 作为倒排存储的结构,以及中间结果合并的数据结构。针对 RoaringDocIdSet 我们进行了如下改造。
在对商家 ID 字段开启 Index Sorting 之后,同商家的商品 ID 已经连续分布。那么对于商家字段的倒排链就是严格自增且无空洞的整数序列。我们采用RLE编码对倒排链进行编码存储。由于将倒排链编码为 [start1, length1, start2, length2, ...],更特殊的,在我们场景下,一个倒排链的表示为 [start, length],RLE编码做到了对倒排链的极致压缩,假设倒排链为 [1, 2, ...., 1000], 用 ArrayContainer 存储,内存空间占用为 16 bit * 100 = 200 Byte, RLE 编码存储只需要 16 bit * 2 = 4 Byte。考虑到具体的场景分布,以及其他场景可能存在多段有序倒排的情况,我们最终选择了 [start1, length1, start2, length2, ...] 这样的存储格式,且 [start, start + length] 之间两两互不重叠。
对于多个商家的倒排合并流程,对于该格式的合并,我们并不需要对 M 个倒排链长度为 K 进行循环处理,这个问题转变为:如何对多组分段 [start, length] 进行排序,并将排序后的结果合并为一个数组。那么我们将原时间复杂度为 的合并流程,改造为复杂度为 O(M * logM) 的合并流程,大大降低了合并的计算耗时,减少了 CPU 的消耗。
我们在 RoaringDocIdSet 的基础上增加了 RLE Container 后,性能已经得到了明显的提升,加速了 50%,然而依然不符合我们的预期。我们通过对倒排链的数据分析发现:倒排链的平均长度不大,基本在十万内。但是其取值范围广 [ 0, Integer.MAX-1 ]。这些特征说明,如果以 RoaringDocIdSet 按高 16 位进行分桶的话,大部分数据将集中在其中连续的几个桶中。
在 Elasticsearch 场景上,由于无法预估数据分布,RoaringDocIdSet 在申请 bucket 容器的数组时,会根据当前 Segment 中的最大 Doc ID 来申请,计算公式为:(maxDoc + (1 << 16) - 1) >>> 16。这种方式可以避免每次均按照 Integer.MAX-1 来创建容器带来的无谓开销。然而,当倒排链数量偏少且分布集中时,这种方式依然无法避免大量 bucket 被空置的空间浪费;另一方面,在对倒排链进行合并时,这些空置的 bucket 也会参与到遍历中,即使它被置为了空。这就又造成了性能上的浪费。我们通过压测评估证实了这一推理,即空置的 bucket 在合并时也会占用大量的 CPU 资源。
针对这一问题,我们设计了一套用于稀疏数据的方案,实现了 SparseRoaringDocIdSet,同时保持接口与 RoaringDocIdSet 一致,可在各场景下进行复用,其结构如下:
- class SparseRoaringDocIdSet {
- int[] index; // 记录有 container 的 bucket Index
- Container[] denseSets; // 记录紧密排列的倒排链
- }
保存倒排链的过程与 RoaringDocIDSet 保持一致,在确认具体的 Container 的分桶时,我们额外使用一组索引记录所有有值的 bucket 的 location。
下图是一组分别使用 RLE based RoaringDocIdSet 和 SparseRoaringDocIdSet 对 [846710, 100, 936858, 110] 倒排链(RLE 编码)进行存储的示意图:
可以看到:在 SparseRoaringDocIdSet 实现下,所有不为空的 bucket 被紧密的排列在了一起,并在 index [] 中记录了其原始 bucket 的索引,这就避免了大量 bucket 被空置的情况。另外,在进行倒排链的合并时,就可以直接对紧密排列的 denseSet 进行遍历,并从 index [] 获得其对应的原始 bucket location,这就避免了大量的空置 bucket 在合并时带来的性能浪费。
我们分别对以下4个场景进行了压测:原生的 TermInSetQuery 对倒排链的合并逻辑、基于 FixedBitSet 的批量合并、RLE based RoaringBitmap、RLE based Dense RoaringBitmap。对 10000 个平均长度为 100 的倒排链进行合并压测,压测结果如下:
我们实现的 RLE based Dense RoaringBitmap,相比官方的基准实现耗时降低了 96%(TP99 13 ms 下降至 0.5 ms)。
至此,核心的倒排索引问题已经解决,后续主要为工程问题:如何在 Elasticsearch 系统中集成基于 RLE 的倒排格式。对于高吞吐高并发的C端在线场景,我们希望尽可能保障线上的稳定,对开源数据格式的兼容,保障前向的兼容,做到随时可降级。
工程部分主要分为两部分:倒排索引的集成和在线检索链路。以下主要介绍全量索引部分的链路设计。
倒排索引格式的改造,一般情况下,需要实现一套 PostingsFormat,并实现对应的 Reader、Writer。为了保证对原有检索语句的兼容,支持多种场景的检索,以及为了未来能够无缝的配合 Elasticsearch 的版本升级,我们并没有选择直接实现一组新的 PostingsFormat,避免出现不兼容的情况导致无法升级版本。我们选择了基于现有的倒排格式,在服务加载前后初始化 RLE 倒排,并考虑到业务场景,我们决定将 RLE 倒排全量放入内存中,以达到极致的性能。具体的解决方案为:
为了避免内存泄漏,我们也将索引删除,segment 变更的场景进行了相应的处理。
在线检索链路也采用了无侵入兼容的实现,我们实现了一套新的检索语句,并且在索引无 RLE 倒排的情况下,可以降级回原生的检索类型,更加安全。
我们基于 Elasticsearch 的插件机制,生成一组新的 Query,实现了其 AbstractQueryBuilder,实现对 Query 的解析与改写,并将 Query 与 RLE 倒排进行关联,我们通过改写具体的检索实现,将整个链路集成到 Elasticsearch 中。
对于 Elasticsearch 而言,一次检索分为这么几个阶段,可参考下图[8]。
我们将上述改动(Index Sorting + Dense Roaring Bitmap + RLE)上线到生产环境的商品索引后,性能如下: Image
至此,我们成功将全链路的检索时延(TP99)降低了 84%(100 ms 下降至 16 ms),解决了外卖搜索的检索耗时问题,并且线上服务的 CPU 也大大降低。
本文主要针对搜索业务场景中遇到的问题,进行问题分析、技术选型、压测、选择合适的解决方案、集成、灰度验证。我们最终实现了一套基于 RLE 倒排格式,作为一种新型的倒排格式,彻底解决了这个场景上的性能瓶颈,从分析至上线的流程长达数月。本文希望能提供一个思路,让其他同学在遇到 Elasticsearch 相关的性能问题时,也能遵循相同的路径,解决业务上的问题。
一般的,我们分析问题可以遵循这样的路径:
我们最终实现的关键点:
当然,我们的方案也还具有一些可以继续探索优化的地方。我们进行具体方案开发的时候,主要考虑解决我们特定场景的问题,做了一些定制化,以取得最大的性能收益。在一些更通用的场景上,也可以通过 RLE 倒排获得收益,例如根据数据的分布,自动选择 bitmap/array/RLE 容器,支持倒排链重叠的情况下的数据合并。
我们在发现也有论文与我们的想法不谋而合,有兴趣了解可以参考具体论文[9]。另外,在增量索引场景下,如果增量索引的变更量非常大,那么势必会遇到频繁更新内存 RLE 倒排的情况,这对内存和性能的消耗都不小,基于性能的考量,也可以直接将 RLE 倒排索引的结构固化到文件中,即在写索引时就完成对倒排链的编码,这样就能避免这一问题。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。