前言
本篇文章主要记录业务上的一个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副本,反而增加了集群的各种负担,比如:
- shard变多造成refrensh、bulk、search等相关线程池压力可能更大
- 单节点需要串行处理更多的meta信息
- shard越多越容易出现search的查询长尾问题:个别shard查询较慢,从而拖累整个查询造成整体查询响应变慢
- 当shard超过256个后,查询会分为2个批次进行查询,查询延时会直接升高一倍
因此,这里并没有继续将shard扩容,而是想通过其他方式进行慢查询分析。
2. 复现问题
既然线上很稳定的隔几分钟出现一次慢查询,那么一定不是巧合和一些异常抖动,因此需要尝试其他方式来复现这个问题。
首先怀疑到cache让索引查询变得很快,所以查看了这个索引的queryCache命中率,大约接近80%的命中率,很显然我们需要降低cache命中率来复现这个问题。一般降低cache命中率的方式有:
- 提高写入吞吐。
- 当前写入比较少,几秒钟或者几分钟一条数据,因此造成很多数据都有缓存且没有失效。
- 提高查询qps
- 使用queryCache需要拿到读锁,如果并发很高导致查询线程拿不到锁,那么将利用不到缓存。
- queryCache是LRU策略的cache,且cache结果很大概率被其它线程改写为新的造成cache失效,因此这里是一个读锁
- ES认为在高并发情况下,阻塞等待读锁的时间可能已经将查询执行完毕,因此没有必要一直阻塞等待读锁
原理分析
通过这两种策略,可以将慢查询很容易的复现出来,通过查看profile发现:
- 没有长尾问题
- terms查询十分耗时
问题已经定位到了TermsQuery,那么问题就变成了:
- 为什么TermsQuery比较慢?
- 为什么有时候慢有时候快?
TermsQuery是ES中的概念,它会转化为Lucene的TermInSetQuery。从下面的Lucene的代码注释中,我们可以看到:
- 需要构建DocIdSet的查询会比较慢
- term等点查特别快
TermInSetQuery要进行多个term查询,通常term查询是很快的,但是合并多个倒排链去构建DocIdSet会比较慢,最终导致TermIndexSetQuery很慢。TermInSetQuery合并倒排链使用的是DocIdSetBuilder,其流程和RoaringDocIdSet不一致,其中DocIdSetBuilder的流程为:
RoaringDocIdSet.Builder中有很多更多的策略,相比DocIdSetBuilder更优一些,但是Terms查询的倒排链合并用不到这些优化:
在TermInSetQuery中,我们发现lucene还做了一个优化,当terms中term的个数小于16个时候,会将terms的查询转化为bool的should查询,直接合并倒排链可能比上面的构建bitSet会更快,可以用到RoaringDocIdSet的各种优化。这里也给了一些优化的灵感。
综上,整个TermInSetQuery中构建DocIdSet的核心查询流程流程为:
那么,为什么有时候慢有时候快呢,根据profile的第三个特征,基本上可以定位到是缓存问题,因此接下来我们来分析一下缓存问题。
3. 缓存分析
lucene中的具体cache策略管理在UsageTrackingQueryCachingPolicy中,具体的执行流程为:
是否允许缓存是一个比较复杂的问题,相关因素按顺序拆解为如下几个:
- Query的内部实现中,isCacheable函数可以决定是否允许缓存
- 当前segment是否允许被缓存以及缓存,主要检查当前segment中的文件数量是否达到阈值
- 是否可以拿到缓存的读写锁,并发高了拿不到锁则不能使用缓存
- IndexReader是否支持缓存,取决于IndexReader#getCoreCacheHelper的实现,如果返回null则不支持缓存
- QueryCachingPolicy中的策略
根据上面的一些判断标准,我们发现了2个需要注意的地方:
1. TermInSetQuery内部的isCacheable有什么特殊点吗?
当前我们使用的是TermInSetQuery,其内部的是否允许缓存策略有一些特殊之处,当terms中的term总size过大的时候不允许进行cache,当前阈值为1k,是一个比较小的值。lucene作者认为terms越长则代表着潜在有更多的term,那么其需要cache的内容可能很大,造成cache的浪费和内存溢出的风险。同时,我们分析terms的长度过长可能导致内容越不容易被下次利用到,因此这个角度考虑也有一定的道理。
线上的慢查询中,有大部分都是正好超过1k长度的,因此TermInSetQuery的缓存利用不起来,查询速度也就会变得很慢,这也是线上慢查询的一个原因之一。
2. UsageTrackingQueryCachingPolicy中哪些查询允许缓存?
根据如下shouldCache函数的代码可以得到:
- 不允许缓存的有:TermQuery,MatchAllDocsQuery,MatchNoDocsQuery,empty的复合查询
- 耗时的查询需要最近(256次)执行过2次以上才可以缓存
- 不耗时的查询需要最近执行过5次才可以查询,但是BooleanQuery和DisjunctionMaxQuery最近执行过4次就可以被缓存
4. 转化为shouldQuery
TermInSetQuery的本质是多个term查询,一个term查询很快,多个term查询很慢,是因为需要将需要将多个term查询的倒排链求或集。我们线上的查询结构包含3个部分: 一个term,一个range,一个terms,最终需要将3个结果进行合并求交集。
测试发现,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提前构建。
优化后,可以利用到Lucene的DocIdSet合并优化,会优先从最小长度的DocIdSet集合开始遍历,因此最大也就遍历100次,而之前的写法在构造terms的DocIdSet时候,需要先合并内部的倒排链,需要遍历1亿次,性能显然很差,而优化后节省的循环次数降低了100万倍。而之前的写法,只有在缓存命中时候才会有和现在一样的效果,特别依赖缓存,造成查询结果十分不稳定。
当查询中包含terms查询,且shouldQuery没有被利用时候,都可以采用类似的优化策略。当然,我们也可以修改Lucene的合并DocIdSet的策略,将该优化加入到其中。
5. 提高集群缓存
根据官方社区的建议,可以将集群的缓存相关参数调大,提高缓存的命中率。除了cache相关的大小优化外,上文中缓存分析中提高的TermInSetQuery的缓存相关参数也可以在适当的场景进行调账,提高缓存的使用率。
总结
本文主要通过profile查看可能潜在的问题原因,然后分析源码,查找缓存失效原理并提高缓存利用率,以及合理利用现有的Lucene的DocIdSet合并的优化,最终达到了查询延时下降数十倍的效果。
通过上述的分析,我们可以总结TermsQuery查询变慢的原因如下:
- cache失效
- Terms查询内部合并倒排链太慢
给出的优化建议如下:
- 提高缓存利用率
- 直接修改es层面的cache相关参数,如果有能力或者有特殊场景,还可以修改lucene层面的cache相关参数,包括LRU策略、TermsQuery的是否cache相关参数
- 并发较大时候缓存会失效,对缓存依赖较高的场景可以通过
- 尽量去掉提前的无效倒排链合并
- 通过改写shouldQuery是一个有效的做法。如果嵌套复杂,可能不能通过该手段进行改写。这时候需要了解一些查询结果集合进行合并的知识,自己在写查询语句的时候,尽量先产生较小的结果集。
- 合理进行shard扩容
- 根据我们的经验,一般情况下一个shard维护5千万到2亿数据即可,过多或者过少都不是一个很好的选择。
参考
- Lucene查询原理 https://zhuanlan.zhihu.com/p/35814539
- Lucene 7.2 禁用了TermInSetQuery的cache https://issues.apache.org/jira/browse/LUCENE-8058
- ES社区提高queryCacheSize参数 https://github.com/elastic/elasticsearch/pull/30655/files
- LRU cache介绍 https://www.amazingkoala.com.cn/Lucene/Search/2019/0506/57.html