Elasticseach 的查询缓存

发布时间 2023-07-17 11:10:45作者: 章怀柔

关于 Elasticsearch 的查询缓存,你想知道的都在这里

原文地址
Elasticsearch 中有多种查询缓存,当一个查询请求执行后,他可能会被缓存下来,但是哪些查询会被缓存,哪些不会缓存,缓存了什么内容,什么时候失效,手册中并没有很系统的阐述,并且文档中也存在一些疑点,导致整个查询缓存体系容易让人迷惑。现在,我们来把他搞清楚。

Shard Request Cache

Shard Request Cache 简称 Request Cache,他是分片级别的查询缓存,每个分片有自己的缓存。该缓存采用 LRU 机制,缓存的 key 是整个客户端请求,缓存内容为单个分片的查询结果。如果一个客户端请求被数据节点缓存了,下次查询的时候可以直接从缓存获取,无需对分片进行查询。

  • 缓存的key
    缓存的实现在 IndicesRequestCache 类中,缓存的 key 是一个复合结构,主要包括shard,indexreader,以及客户端请求三个信息:
    final Key key = new Key(cacheEntity,reader.getReaderCacheHelper().getKey(),cacheKey);

cacheEntity: 主要是 shard信息,代表该缓存是哪个 shard上的查询结果。
readerCacheKey: 主要用于区分不同的 IndexReader。
cacheKey: 主要是整个客户端请求的请求体(source)和请求参数(preference、indexRoutings、requestCache等)。由于客户端请求信息直接序列化为二进制作为缓存 key 的一部分,所以客户端请求的 json 顺序,聚合名称等变化都会导致 cache 无法命中。

  • 缓存的value
    缓存的 value比较简单,就是将查询结果序列化之后的二进制数据。

Request Cache 的主要作用是对聚合的缓存,聚合过程是实时计算,通常会消耗很多资源,缓存对聚合来说意义重大。

缓存策略

并非所有的分片级查询都会被缓存。

缓存策略指哪些类型的查询需要缓存,哪些类型的缓存无需缓存。IndicesService#canCache 方法中定义了某个请求是否可以被缓存,简单的可以理解成只有客户端查询请求中 size=0的情况下才会被缓存

  • 不被缓存的条件还包括
    scroll、设置了 profile属性,查询类型不是 QUERY_THEN_FETCH,以及设置了 requestCache=false等。另外一些存在不确定性的查询例如:范围查询带有now,由于它是毫秒级别的,缓存下来没有意义,类似的还有在脚本查询中使用了 Math.random() 等函数的查询也不会进行缓存。

  • 查询结果中被缓存的内容主要包括
    aggregations(聚合结果)、hits.total、以及 suggestions等。

由于 Request Cache 是分片级别的缓存,当有新的 segment 写入到分片后,缓存会失效,因为之前的缓存结果已经无法代表整个分片的查询结果。所以分片每次 refresh之后,缓存会被清除。

缓存设置

ES 默认情况下最多使用堆内存的 1% 用作 Request Cache,这是一个节点级别的配置:
indices.requests.cache.size
Request Cache默认是开启的。你可以为某个索引动态启用或禁用缓存:

PUT /my-index/_settings
{ "index.requests.cache.enable": true }
  • 1
  • 2

或者在查询请求级别通过 request_cache 参数来控制本次查询是否使用 cache,他会覆盖索引级别的设置。

GET /my-index/_search?request_cache=true
  • 1

Node Query Cache (Filter Cache)

Shard Request Cache 缓存的是整个查询语句在某个分片上的查询结果,而Node Query Cache 缓存的是某个 filter 子查询语句,在一个 segment 上的查询结果。如果一个 segment 缓存了某个子查询的结果,下次可以直接从缓存获取,无需对 segment 进行查询。Request Cache 只对 filter 查询进行缓存,因此又称为 Filter Cache。

filter 查询与普通查询的主要区别就是不会计算评分。

Query Cache是 Lucene 层面实现的,封装在 LRUQueryCache 类中,默认开启。ES 层面会进行一些策略控制和信息统计。每个 segment 有自己的缓存,缓存的 key 为 filter 子查询(query clause ),缓存内容为查询结果,这些查询结果就是匹配到的 document numbers,保存在位图 FixedBitSet中。

缓存的构建过程是,对 segment 执行子查询的结果,先获取查询结果中最大的 document number: maxDoc

document number是 lucene 为每个 doc 分配的数值编号,fetch 的时候也是根据这个编号获取文档内容

然后创建一个大小为 maxDoc的位图:FixedBitSet,然后遍历查询命中的 doc,将FixedBitSet中对应的bit 设置为1。

public void collect(int doc) {
    bitSet.set(doc);
}
  • 1
  • 2
  • 3

例如查询结果为[1,2,5],则FixedBitSet中的结果为[1,1,0,0,1],当整个查询有多个filter子查询时,交并集直接对位图做位运算即可。

下面用一个例子来说明 Query Cache结构。
如下查询语句包含两个子查询,分别是对 date 和 age 字段的 range 查询,Lucene 在查询过程中遍历每个 segment,检查其各自的 LRUQueryCache 能否命中,在本例中,segment 2命中了对 age 字段的缓存,segment 8 命中了对 age 和 date 两个字段的缓存,将会直接返回结果。由于 segment 2 没有命中 date 字段缓存,将会执行查询过程。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TKKLpdp6-1638693021599)(media/16386891873887/16386911879041.jpg)]
uniqueQueries 的作用是记录了分片的所有 segment 中已经被缓存下来的子查询,并基于 LinkedHashMap 实现 LRU 淘汰机制。

缓存策略

并非所有的 filter 查询都会被缓存下来。
QueryCache策略决定某个查询要不要缓存。ES 使用 UsageTrackingQueryCachingPolicy作为默认的缓存策略,在这个策略中,判断某个查询要不要缓存,主要关注两点:

  • 某些类型的查询,永远不会缓存,目前 shouldNeverCache 方法中定义了以下类型:TermQuery、MatchAllDocsQuery、MatchNoDocsQuery、以及子查询为空的BooleanQuery、DisjunctionMaxQuery。
  • 某条 query 的 访问频率大于等于特定阈值之后,该 query结果才会被缓存。对于访问频率,主要分为2类,一类是访问2次就会被缓存,包括: MultiTermQuery、MultiTermQueryConstantScoreWrapper、TermInSetQuery、PointQuery 在 isCostly方法中定义。其余类型的查询访问5次会被缓存。

最近使用次数如何统计的?lucene 里维护一个大小为256的环形缓冲,最近使用过的 query 会做一下 hash 保存到这个缓冲里,当缓冲满的时候直接覆盖最后一个,被访问一次也不会调整他在缓冲里的顺序。因此“最近访问频率”可以理解成:在最近的256个历史记录里,query 被访问的次数。

在一些场景中,客户端请求经常会带有多个 filter 子查询,而最终结果是同时匹配所有 filter 子查询的文档,假设客户端请求中有两个子查询,第一个子查询匹配到了10个文档,第二个子查询匹配到了1亿个文档,最终取交集的时候Lucene 会从结果数量最少的集合(称为 leader)开始遍历,只需要10次匹配完成交集的过程,而为第二个子查询的1亿个结果建立缓存却花费了大量的时间,增大了查询延迟。如果这个巨大的缓存在后续被访问的次数较少,则缓存带来的优点得不偿失。因此在对一个子查询进行缓存之前,先检查 leader 命中的文档数量,如果子查询比 leader 的结果集大于某个阈值,则认为缓存成本过高,不对该子查询进行缓存。目前的版本中,阈值是直接对子查询匹配的文档数量除以 250:

if (cost / skipCacheFactor > leadCost) {
    //跳过缓存构建过程
    return supplier.get(leadCost);
}
  • 1
  • 2
  • 3
  • 4

cost 为当前子查询匹配到的文档数量,skipCacheFactor为固定值 250,在开启了 indices.queries.cache.all_segments 的情况下会被设置为1。leadCost为 leader子查询匹配到的文档数量。
所有的子查询会在每个 segment 上都执行,所以上述计算缓存成本的过程会在每个 segment 上进行。但是这种成本计算方式不会考虑被跳过缓存的子查询未来被访问的频率。

携程大神 wood 叔曾经在社区提到过使用 range 查询时传入的数值范围精度问题,如果传入的是秒级的时间单位,那么子查询被缓存后只能在1秒内被命中,因为范围值变化对缓存来说是一个新的 Query。特别是被缓存的结果集巨大时,构建缓存花费很多时间,而在之后又很快不再访问。所以在 range 查询时指定粗一些的粒度有利于缓存命中,例如时间范围查询中使用 now/h,使用小时级别的单位,可以让缓存在1小时内都可能被访问到。

缓存设置

默认情况下节点的 Query cache最多缓存 10000个子查询的结果(LRU 的大小),或者最多使用堆内存的10%,可以通过配置来调整:

indices.queries.cache.count  #默认 10000
indices.queries.cache.size   #默认 10%
  • 1
  • 2

每个分片有一个 LRUQueryCache 对象,在分片的所有 segment 上已经被缓存的子查询列表记录在 uniqueQueries中,10000个子查询的阈值就是对 uniqueQueries 大小的限制。例如如果一个子查询命中了2个 segment,LRU 缓存里会把两个 segment 的位图都缓存起来,但是 cache count 只算1个。

由于缓存是为每个 segment 建立的,当 segment 合并的时候,被删除的 segment 其关联 cache 会失效。其次,对于体积较小的 segment 不会建立Query cache,因为他们很快会被合并。因此一个 segment的 doc 数量需要大于10000,并且占整个分片的3%以上才会走 cache 策略。

Query Cache 支持索引级设置来启用或禁用缓存,但是不支持请求级别的设置。通过 index.queries.cache.enabled 配置来设置某个索引是否开启缓存,该配置不支持动态调整,要更改索引配置,需要先关闭索引。

小结

Query Cache 和 Request Cache 一个是 Lucene 层面的实现,一个是 ES 层面的实现,其名称容易让人迷惑。在缓存机制上都使用 LRU,Request Cache 访问1次会就考虑缓存,Query Cache会根据不同的查询计算其访问频率,达到一定频率才会考虑缓存。**Request Cache的主要用途是对聚合结果的缓存,Query Cache的主要用途是对数值类型的 filter 子查询。**我们整理一下两者主要区别,如下表:

缓存类型查询对象失效访问频率缓存 Key缓存 Value
Query Cache 分段 merge 与频率有关 filter 子查询 FixedBitSet
Request Cache 分片 refresh 与频率无关 整个客户端请求 查询结果序列化

其他缓存

  1. fielddata 对于 text 类型进行聚合等获取字段值的行为时才会用到 fielddata,他会占用大量内存。fielddata默认关闭,因为在 text 类型上执行聚合一般是没有意义的,因此这里不深入讨论。
  2. pagecache 查询过程中读取 Lucene 文件时会被操作系统 pagecache 缓存,pagecache使用类似 LRU 的策略对数据进行淘汰,但是由于 pagecache 对所有文件一视同仁,并且难以控制,此处也不做深入讨论。

手工清除缓存

对于 ES 和 Lucene 层面的三种缓存,我们可以 REST API手工清除他们,下面的命令会清除指定索引或全部索引的缓存:

POST /<index>/_cache/clear    #清理指定索引的 cache,支持多个
POST /_cache/clear            #清理整个集群的 cache
  • 1
  • 2

同时可以通过 fields 参数指定清理部分字段的缓存:

POST /my-index/_cache/clear?fields=foo,bar 
  • 1

不建议在生产环境中进行手动清除 cache,会对查询性能有较大的影响。手工清除缓存一般只用于测试和验证的目的。

监控缓存

对于缓存的监控指标我们通常需要关心缓存占用的空间大小,以及命中率等信息,一般需要在两个层面观测缓存使用情况,一个是节点级别,一个是索引级别。

节点级别

下面的命令可以比较方便的观测每个节点上缓存占用的空间大小以及 request cache的命中率:
GET /_cat/nodes?v&h=name,queryCacheMemory,fielddataMemory,requestCacheMemory,requestCacheHitC
命令输出如下,缓存命中率需要根据 HitCount和 MissCount 自己计算:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Q9Zr9cUz-1638693021601)(media/16386891873887/16386927341786.jpg)]
query cache的缓存命中可以通过 Nodes stats API 来查看:
GET /_nodes/stats/indices/query_cache,request_cache,fielddata?pretty
该命令将对每个节点都输出以下详细信息。

"query_cache": {
  "memory_size_in_bytes": 1110305640,
  "total_count": 45109997,
  "hit_count": 1192144,
  "miss_count": 43917853,
  "cache_size": 1309,
  "cache_count": 51509,
  "evictions": 50200
},
"fielddata": {
  "memory_size_in_bytes": 11858456,
  "evictions": 0
},
"request_cache": {
  "memory_size_in_bytes": 136748541,
  "evictions": 46055,
  "hit_count": 19041642,
  "miss_count": 1970274
}

 

 

关于 Nodes stats API 和 cat Nodes API 的更多参数请参考官网手册。

索引级别

索引级别的信息可以通过 Index stats API来查看,对于缓存方面的指标可以使用如下命令:
{index}/_stats/query_cache,fielddata,request_cache?pretty&human
其输出信息与 Nodes stats API 类似,不再赘述。
本文基于 Elasticsearch 7.7版本。

转载自:https://blog.csdn.net/u013870094/article/details/121731290