Elasticsearch的TermsQuery慢查询分析和优化

前言

本篇文章主要记录业务上的一个TermsQuery优化和分析的过程和一些思考。

在使用ES的时候,经常会遇到慢查询,这时候可以利用profile进行分析,当利用profile也查看不出什么端倪时候,可以尝试通过阅读代码查看查询为什么这么慢。如下是一个我们内部业务的一个慢查询,经常出现4s左右的延时,一模一样的查询,但是延时不一样,且很难复现。

{
  "from": 0,
  "size": 10,
  "query": {
    "bool": {
      "must": [
        { "term": { "field_1": {"value": "a" }}},
        { "terms": {"field_2": [ "a", "b", "c","f", "e", "f", "g", "h", "i" ]} },
        { "range": { "time": {"from": 1,  "to": 10}}}
      ]
    }
  },
  "sort": [{ "index_sort_field": { "order": "asc"}} ],
  "track_total_hits": false
}

优化和分析

当前索引单shard1亿多条数据,写入较少但是比较稳定,集群写入查询、cpu、io等资源都不紧张。且查询使用了index_sort,没有访问_source文件,没有访问total_hit,表面上已经没有了什么优化空间。

首先利用es的profile分析这个查询,并且请求中禁用了request_cache,但是并没有发现任何问题,查询响应速度很快,也没有任何的慢查询,因此通过profile去复现并分析这个问题,所以直接简单暴力的将shard进行扩容,降低单shard的容量。

1. shard扩容

当前单shard1亿的容量并不是很大,通过扩容将原来的30个shard扩容到了60个shard,效果还是十分明显的,偶尔的慢查询降低到了2s。

原理分析

之前单shard1亿的容量,shard扩容后变成了单shard5000万数据,那么单shard需要查询和计算的总量变少一倍,理论上确实可以降低一倍查询延时。

但是shard扩容也不能解决所有问题,比如当shard扩容到更多时候,效果并不是成倍的下降,特别是在节点不多时候,一个节点上放了更多的shard副本,反而增加了集群的各种负担,比如:

  1. shard变多造成refrensh、bulk、search等相关线程池压力可能更大
  2. 单节点需要串行处理更多的meta信息
  3. shard越多越容易出现search的查询长尾问题:个别shard查询较慢,从而拖累整个查询造成整体查询响应变慢
  4. 当shard超过256个后,查询会分为2个批次进行查询,查询延时会直接升高一倍

因此,这里并没有继续将shard扩容,而是想通过其他方式进行慢查询分析。

2. 复现问题

既然线上很稳定的隔几分钟出现一次慢查询,那么一定不是巧合和一些异常抖动,因此需要尝试其他方式来复现这个问题。

首先怀疑到cache让索引查询变得很快,所以查看了这个索引的queryCache命中率,大约接近80%的命中率,很显然我们需要降低cache命中率来复现这个问题。一般降低cache命中率的方式有:

  1. 提高写入吞吐。
    • 当前写入比较少,几秒钟或者几分钟一条数据,因此造成很多数据都有缓存且没有失效。
  1. 提高查询qps
    • 使用queryCache需要拿到读锁,如果并发很高导致查询线程拿不到锁,那么将利用不到缓存。
      • queryCache是LRU策略的cache,且cache结果很大概率被其它线程改写为新的造成cache失效,因此这里是一个读锁
      • ES认为在高并发情况下,阻塞等待读锁的时间可能已经将查询执行完毕,因此没有必要一直阻塞等待读锁
原理分析

通过这两种策略,可以将慢查询很容易的复现出来,通过查看profile发现:

  1. 没有长尾问题
  2. terms查询十分耗时

问题已经定位到了TermsQuery,那么问题就变成了:

  1. 为什么TermsQuery比较慢?
  2. 为什么有时候慢有时候快?

TermsQuery是ES中的概念,它会转化为Lucene的TermInSetQuery。从下面的Lucene的代码注释中,我们可以看到:

  1. 需要构建DocIdSet的查询会比较慢
  2. term等点查特别快

Elasticsearch的TermsQuery慢查询分析和优化

TermInSetQuery要进行多个term查询,通常term查询是很快的,但是合并多个倒排链去构建DocIdSet会比较慢,最终导致TermIndexSetQuery很慢。TermInSetQuery合并倒排链使用的是DocIdSetBuilder,其流程和RoaringDocIdSet不一致,其中DocIdSetBuilder的流程为:

Elasticsearch的TermsQuery慢查询分析和优化

RoaringDocIdSet.Builder中有很多更多的策略,相比DocIdSetBuilder更优一些,但是Terms查询的倒排链合并用不到这些优化:

Elasticsearch的TermsQuery慢查询分析和优化

在TermInSetQuery中,我们发现lucene还做了一个优化,当terms中term的个数小于16个时候,会将terms的查询转化为bool的should查询,直接合并倒排链可能比上面的构建bitSet会更快,可以用到RoaringDocIdSet的各种优化。这里也给了一些优化的灵感。

Elasticsearch的TermsQuery慢查询分析和优化

综上,整个TermInSetQuery中构建DocIdSet的核心查询流程流程为:

Elasticsearch的TermsQuery慢查询分析和优化

那么,为什么有时候慢有时候快呢,根据profile的第三个特征,基本上可以定位到是缓存问题,因此接下来我们来分析一下缓存问题。

3. 缓存分析

lucene中的具体cache策略管理在UsageTrackingQueryCachingPolicy中,具体的执行流程为:

Elasticsearch的TermsQuery慢查询分析和优化

是否允许缓存是一个比较复杂的问题,相关因素按顺序拆解为如下几个:

  1. Query的内部实现中,isCacheable函数可以决定是否允许缓存
  2. 当前segment是否允许被缓存以及缓存,主要检查当前segment中的文件数量是否达到阈值
  3. 是否可以拿到缓存的读写锁,并发高了拿不到锁则不能使用缓存
  4. IndexReader是否支持缓存,取决于IndexReader#getCoreCacheHelper的实现,如果返回null则不支持缓存
  5. QueryCachingPolicy中的策略

根据上面的一些判断标准,我们发现了2个需要注意的地方:

1. TermInSetQuery内部的isCacheable有什么特殊点吗?

当前我们使用的是TermInSetQuery,其内部的是否允许缓存策略有一些特殊之处,当terms中的term总size过大的时候不允许进行cache,当前阈值为1k,是一个比较小的值。lucene作者认为terms越长则代表着潜在有更多的term,那么其需要cache的内容可能很大,造成cache的浪费和内存溢出的风险。同时,我们分析terms的长度过长可能导致内容越不容易被下次利用到,因此这个角度考虑也有一定的道理。

线上的慢查询中,有大部分都是正好超过1k长度的,因此TermInSetQuery的缓存利用不起来,查询速度也就会变得很慢,这也是线上慢查询的一个原因之一。

Elasticsearch的TermsQuery慢查询分析和优化

2. UsageTrackingQueryCachingPolicy中哪些查询允许缓存?

根据如下shouldCache函数的代码可以得到:

    • 不允许缓存的有:TermQuery,MatchAllDocsQuery,MatchNoDocsQuery,empty的复合查询
    • 耗时的查询需要最近(256次)执行过2次以上才可以缓存
    • 不耗时的查询需要最近执行过5次才可以查询,但是BooleanQuery和DisjunctionMaxQuery最近执行过4次就可以被缓存

Elasticsearch的TermsQuery慢查询分析和优化

4. 转化为shouldQuery

TermInSetQuery的本质是多个term查询,一个term查询很快,多个term查询很慢,是因为需要将需要将多个term查询的倒排链求或集。我们线上的查询结构包含3个部分: 一个term,一个range,一个terms,最终需要将3个结果进行合并求交集。

Elasticsearch的TermsQuery慢查询分析和优化

测试发现,term包含结果200个,range包含100个,terms包含1亿个,并且已知我们线上的terms查询中的单词过多,且命中率很高,整个查询过程需要先求出terms查询的命中DocId集合,而求出这单shard命中1亿的DocId集合,速度肯定很慢很慢。求出这个DocId结合后,再和其它两个查询条件term、range进行结果合并,因此速度会特别慢。

这时候我们的优化方向变成了去掉terms查询,避免terms查询内部提前构造docIdSet,根据terms的语义,和shouldQuery比较相似,因此我们直接转化成为shouldQuery,并且设置minShouldMatch=1。通过这种改造,语义上并没有发生变化,但是可以避免1亿的docIdSet提前构建。

Elasticsearch的TermsQuery慢查询分析和优化

优化后,可以利用到Lucene的DocIdSet合并优化,会优先从最小长度的DocIdSet集合开始遍历,因此最大也就遍历100次,而之前的写法在构造terms的DocIdSet时候,需要先合并内部的倒排链,需要遍历1亿次,性能显然很差,而优化后节省的循环次数降低了100万倍。而之前的写法,只有在缓存命中时候才会有和现在一样的效果,特别依赖缓存,造成查询结果十分不稳定。

当查询中包含terms查询,且shouldQuery没有被利用时候,都可以采用类似的优化策略。当然,我们也可以修改Lucene的合并DocIdSet的策略,将该优化加入到其中。

5. 提高集群缓存

根据官方社区的建议,可以将集群的缓存相关参数调大,提高缓存的命中率。除了cache相关的大小优化外,上文中缓存分析中提高的TermInSetQuery的缓存相关参数也可以在适当的场景进行调账,提高缓存的使用率。

总结

本文主要通过profile查看可能潜在的问题原因,然后分析源码,查找缓存失效原理并提高缓存利用率,以及合理利用现有的Lucene的DocIdSet合并的优化,最终达到了查询延时下降数十倍的效果。

通过上述的分析,我们可以总结TermsQuery查询变慢的原因如下:

  1. cache失效
  2. Terms查询内部合并倒排链太慢

给出的优化建议如下:

  1. 提高缓存利用率
    • 直接修改es层面的cache相关参数,如果有能力或者有特殊场景,还可以修改lucene层面的cache相关参数,包括LRU策略、TermsQuery的是否cache相关参数
    • 并发较大时候缓存会失效,对缓存依赖较高的场景可以通过
  1. 尽量去掉提前的无效倒排链合并
    • 通过改写shouldQuery是一个有效的做法。如果嵌套复杂,可能不能通过该手段进行改写。这时候需要了解一些查询结果集合进行合并的知识,自己在写查询语句的时候,尽量先产生较小的结果集。
  1. 合理进行shard扩容
    • 根据我们的经验,一般情况下一个shard维护5千万到2亿数据即可,过多或者过少都不是一个很好的选择。

参考

上一篇:在 Ubuntu Linux 中使用 WebP 图片


下一篇:C++定义自己的异常