查询缓存与请求缓存:节点级、分片级与 fielddata 的取舍

系统讲解 Elasticsearch 的三层缓存:node query cache 缓存过滤器结果、shard request cache 缓存聚合与建议结果、fielddata 缓存支撑排序与聚合,并给出失效机制、内存配额与生产调优方法。

同一个仪表盘每 30 秒刷新一次,同一批过滤条件被反复执行;同一个热门搜索词每分钟被上百个用户触发。这些重复计算如果每次都重新扫描倒排索引,CPU 会被白白烧掉。Elasticsearch 为此设计了三层缓存:节点级的 query cache 缓存过滤器匹配结果,分片级的 request cache 缓存整个请求的聚合与建议输出,fielddata 缓存则支撑排序与聚合所需的正排数据。三层缓存作用域不同、失效条件不同、内存配额也不同,用错了不但没收益,还会挤占堆内存引发 GC。本文把它们拆开讲清楚。

1. 缓存的层次与作用域

一句话总结: Elasticsearch 的缓存按作用域分为节点级与分片级两层,query cache 按段失效、request cache 按刷新失效,理解作用域是调优的前提。

1.1 三层缓存的分工

  • node query cache(过滤器缓存):节点级共享,缓存 filter 上下文中查询的匹配结果(文档 ID 位图)。只缓存 filter,不缓存 must 里的评分查询。
  • shard request cache:分片级,缓存整个搜索请求的响应体,主要覆盖 size: 0 的聚合、建议器与命中计数。
  • fielddata 缓存:分片级,缓存 text 字段的「字段值到文档」正排结构,用于排序、聚合与脚本访问。

三者的共同点是「用内存换 CPU」,区别在于缓存的内容、粒度与失效时机。

1.2 为什么分这么细

因为不同查询模式的热点不一样。过滤条件(如 status = active)在成千上万次查询里重复出现,适合按「子查询」粒度缓存;而聚合结果依赖整个分片的数据状态,只能按「整个请求」粒度缓存。粒度不同,失效条件就不同,混在一起缓存会导致命中率极低。

1.3 缓存的默认开关

query cache 与 request cache 默认都是开启的,fielddata 在 text 字段上默认关闭(fielddata: false),需要显式开启。这一点很关键:很多人以为给 text 字段排序会自动用上 fielddata,实际上会直接报错。

2. node query cache

一句话总结: query cache 缓存 filter 上下文的匹配位图,命中时跳过倒排索引扫描,是过滤条件重复率高的场景里收益最直接的缓存。

2.1 缓存的内容

当查询里有 filter 子句时,Elasticsearch 会先计算该过滤器命中的文档集合,结果表示为一个位图(bitmap),存入 query cache。后续命中同一过滤器的查询直接复用位图,跳过倒排索引遍历与集合运算。

GET /orders/_search
{
  "query": {
    "bool": {
      "filter": [
        { "term": { "status": "paid" } },
        { "range": { "created_at": { "gte": "now-7d/d" } } }
      ]
    }
  }
}

注意 range 里用了 now-7d/d 这种按天取整的写法,它保证同一天内查询表达式字符串不变,缓存才能命中。写成 now-7d 则每秒都不同,缓存永远不命中。

2.2 缓存键与命中条件

缓存键由过滤器子句的序列化形式、以及所在段的标识共同决定。这意味着两点:过滤器的写法必须完全一致(字段顺序、类型、取整方式),段发生变化时旧缓存条目失效。

2.3 内存配额

query cache 的大小由静态配置控制:

indices.queries.cache.size: 10%

默认是堆内存的 10%。它是节点级共享,所有分片竞争同一块空间,采用 LRU 淘汰。调大的收益在过滤条件高度重复的集群上明显,但如果堆本身已经吃紧,加大缓存反而加剧 GC。

2.4 什么时候不缓存

不是所有 filter 都值得缓存。过滤器命中文档数超过段内文档数的某个比例时(默认阈值由 indices.queries.cache.all_segments 与内部启发式控制),缓存位图的成本高于直接扫描的收益,Elasticsearch 会跳过缓存。所以「选择性高」的过滤器才真正受益——比如命中 1% 文档的状态过滤,而不是命中 90% 的过滤。

3. shard request cache

一句话总结: request cache 按分片缓存整个搜索响应,只对 size 为 0 的聚合与建议器生效,且必须配合按天取整的时间范围才能稳定命中。

3.1 缓存的内容与前提

request cache 缓存的是分片级搜索的完整响应体,命中时该分片直接返回缓存,不做任何计算。它只在 size: 0 时生效——也就是「只要聚合结果、不要文档」的请求。因为一旦要返回文档,_score、排序、from/size 都让结果难以复用。

3.2 开启与使用

默认开启,但请求必须显式声明可以缓存:

GET /logs-*/_search?request_cache=true
{
  "size": 0,
  "query": { "range": { "@timestamp": { "gte": "now-1d/d" } } },
  "aggs": {
    "by_service": { "terms": { "field": "service", "size": 20 } }
  }
}

?request_cache=true 是必要条件,即使索引级默认开启也需要它。常见做法是在客户端里对仪表盘类请求统一加上这个参数。

3.3 索引级开关

可以按索引控制:

PUT /logs-2026.10/_settings
{
  "index.requests.cache.enable": true
}

写入频繁的索引,request cache 会不断失效,命中率上不去,不如关掉省内存。而只读的历史索引,命中率会非常高。

3.4 缓存键与失效

request cache 的键是「请求体的序列化形式 + 分片标识」。任何让请求体字符串变化的因素都会导致缓存未命中:now 的取值方式、聚合里的 size 参数、字段顺序。失效时机是分片发生 refresh 时——只要该分片有新段产生(新文档写入或删除),整个分片的 request cache 全部清空。

这也解释了为什么它对只读索引特别友好:没有写入就没有 refresh,缓存可以长期有效。

4. fielddata 缓存

一句话总结: fielddata 是 text 字段的正排结构,支撑排序与聚合,默认关闭且开销巨大,正确做法是用 keyword 字段替代。

4.1 为什么需要正排

倒排索引回答「哪些文档包含这个词」,而排序与聚合需要回答「这个文档的这个字段值是什么」。后者需要正排(doc values)。keyword、数值、日期等字段默认就有 doc values,存在磁盘上、按需加载、内存开销可控。而 text 字段默认没有 doc values,因为它经过分词后没有「一个字段值」的概念。

4.2 开启 fielddata 的代价

如果硬要对 text 字段排序或聚合,只能开启 fielddata:

PUT /articles/_mapping
{
  "properties": {
    "title": {
      "type": "text",
      "fielddata": true
    }
  }
}

开启后 Elasticsearch 会把该字段所有词项加载进堆内存,构建「词项 → 文档」的映射。对高基数字段(如正文),这可能吃掉几 GB 堆内存,并且加载时会造成明显的延迟毛刺。官方明确建议:不要在生产环境开启 text 字段的 fielddata。

4.3 配额与断路器

fielddata 有断路器保护:

indices.breaker.fielddata.limit: 40%
indices.breaker.fielddata.overhead: 1.03

默认限制为堆的 40%,超过则抛出 CircuitBreakingException 而不是把节点 OOM。这个限制的存在说明官方对它的定位就是「不该被大量使用」。

4.4 正确的替代方案

需要聚合或排序的文本字段,应该在建索引时同时定义 keyword 子字段:

PUT /articles
{
  "mappings": {
    "properties": {
      "category": {
        "type": "text",
        "fields": {
          "keyword": { "type": "keyword" }
        }
      }
    }
  }
}

聚合与排序用 category.keyword,全文检索用 category。这是 Elasticsearch 数据建模里最基本的一条规则。

5. 缓存失效机制

一句话总结: 段是失效的基本单位,任何 refresh 都会清空对应分片的 request cache 并使相关段的 query cache 条目失效。

5.1 段与失效

Elasticsearch 的索引由不可变的段组成,refresh 产生新段,merge 合并旧段。缓存条目都绑定到具体的段上,段一旦被 merge 或删除,引用它的缓存条目就失效。这带来一个推论:写入越频繁,缓存命中率越低。

5.2 写入频率的影响

对持续写入的索引,每秒 refresh 一次意味着每秒都可能产生新段,query cache 里旧的位图需要与新段结果合并(新段的匹配结果单独缓存,查询时合并),request cache 则直接整体失效。所以 request cache 对写多读少的索引几乎没有价值。

5.3 主动清理

缓存无法按条目精确清理,但可以整体清空:

POST /my-index/_cache/clear
POST /my-index/_cache/clear?query=true
POST /my-index/_cache/clear?request=true
POST /my-index/_cache/clear?fielddata=true

注意这是昂贵的操作,会引发缓存重建风暴,生产上只在排障时使用,且应避免在高峰期执行。

5.4 稳定的查询表达式

最容易被忽视的失效来源是「查询表达式不稳定」。now-1h 每秒都不同,缓存键每次都变;now-1h/h 按小时取整,一小时内键不变。所有带时间范围的缓存友好查询都应该做取整:

now-1h/h     按小时取整,缓存友好
now-1h/m     按分钟取整
now/d        按天取整,仪表盘最常用
now-1h       不取整,缓存永不命中

6. 观测与调优

一句话总结: 用 nodes stats 与 indices stats 观察缓存的命中数、驱逐数与内存占用,先确认命中率再决定是否调大配额。

6.1 观察 query cache

curl -s "localhost:9200/_nodes/stats/indices/query_cache?pretty"

关键字段:hit_count、miss_count、cache_size、evictions、memory_size_in_bytes。命中率 = hit / (hit + miss),如果命中率低于 10% 且 evictions 很高,说明缓存空间不足或者查询表达式不稳定。

6.2 观察 request cache

curl -s "localhost:9200/_stats/request_cache?pretty"

这是索引级统计,关注 hit_count 与 miss_count 的比值。对仪表盘类索引,命中率应该在 80% 以上;如果很低,先检查请求是否带了 request_cache=true 以及时间范围是否取整。

6.3 观察 fielddata

curl -s "localhost:9200/_nodes/stats/indices/fielddata?pretty&fields=*"

如果发现某个 text 字段的 fielddata 占用很大,说明有人开启了它,应该推动改用 keyword 子字段。

6.4 配额调整

query cache 的调整原则是「先看堆压力,再看命中率」。堆使用率长期低于 60% 且 GC 健康时,可以把 indices.queries.cache.size 从 10% 提到 15% 或 20%。如果堆已经吃紧,加大缓存是饮鸩止渴。

7. 生产实践与常见坑

一句话总结: 缓存调优的顺序是「先让查询可缓存、再看命中率、最后调配额」,绝大多数缓存问题出在查询写法而不是配置。

7.1 让查询可缓存

  • 过滤条件放进 filter 上下文,不要放进 must。
  • 时间范围做取整(now-1d/d),避免表达式漂移。
  • 聚合请求统一加 request_cache=true。
  • 避免在过滤条件里用 script,脚本过滤的缓存效果差。

7.2 不要过早调大配额

默认配额是经过大量场景验证的。在动配置之前,先用 stats 确认命中率问题真实存在。很多团队把 query cache 从 10% 调到 30% 后发现毫无改善,因为真正的瓶颈是查询表达式每次都变。

7.3 冷热数据分离

只读的历史索引缓存命中率天然高,写入频繁的热索引命中率低。用 ILM 把历史数据滚动到只读阶段,并给只读索引单独的数据层,可以让缓存收益最大化。这也是分层架构的核心收益之一。

7.4 常见坑清单

  • must 里的过滤条件:不会被 query cache 缓存,白白浪费 CPU。
  • now-1h 不取整:缓存键每秒变化,命中率为零。
  • 给 text 开启 fielddata:堆内存暴涨,最终 OOM。
  • 在写多索引上期待 request cache 收益:refresh 频繁导致缓存不断失效。
  • 忘记 request_cache=true:索引级开启不等于请求级开启。
  • 高峰期清缓存:会引发所有分片同时重建缓存,延迟飙升。

7.5 与其他优化的配合

缓存是「锦上添花」而非「雪中送炭」。一个本身要扫描上亿文档的查询,缓存命中时很快,未命中时依旧很慢,缓存只是提高了平均速度、掩盖了问题。真正的优化顺序是:先优化查询结构与映射设计,再考虑分片与路由,最后才是缓存。

8. 总结

环节要点
query cache节点级缓存 filter 位图,默认堆 10%
request cache分片级缓存 size 为 0 的响应,需显式开启
fielddatatext 字段正排,默认关闭,应用 keyword 替代
失效单位段是基本单位,refresh 触发失效
缓存键查询表达式字符串,取整才能稳定命中
观测手段nodes stats 与 indices stats 看命中与驱逐
调优顺序先改查询写法,再调配额
分层配合只读历史索引命中率远高于热索引

缓存的本质是「用空间换时间」,而 Elasticsearch 的三层缓存把这笔交易拆成了三份不同期限的合约。理解每层的失效条件,比记住配额参数更重要。当索引规模增长到单节点放不下、又希望保留查询能力时,可搜索快照提供了另一条路;而缓存与内存的深层关系,与 JVM 堆和 GC 调优紧密相关,这两条线索在后续文章中会继续展开。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「elasticsearch」更多文章

  1. 可搜索快照与冻结层:把冷数据放进对象存储还能查
  2. 分页与深度分页:from/size、search_after、PIT 与 scroll
  3. 嵌套与父子关联查询:nested、join 字段与性能取舍