从零搞懂 ES 8.x 缓存机制:Query、Request 和 Fielddata 到底怎么用?
你有没有遇到过这样的场景?
一个 Kibana 仪表盘,每 30 秒刷新一次,前几次加载慢得像卡顿,但从第三次开始突然变得飞快——仿佛系统“热身”完毕。这背后是谁在默默发力?
又或者,在面试中被问到:“为什么filter能缓存而query不行?”、“text 字段做聚合为什么会 OOM?”……这些看似简单的问题,其实都直指 Elasticsearch 的核心命门:缓存机制。
尤其在 ES 8.x 版本中,缓存的设计更加精细,但同时也更考验开发者对底层原理的理解。如果你只是知道“有三种缓存”,却说不清它们何时生效、如何失效、该怎么调优,那在真实项目或技术深挖型面试里,很容易露怯。
今天我们就抛开文档式的罗列,带你从工程实践的角度重新理解 ES 的三大缓存:Query Cache、Request Cache 和 Field Data Cache。不讲空话,只聊你能用得上的硬核知识。
filter 查询为啥能缓存?揭开 Query Cache 的真相
先来回答那个经典问题:“为什么要把条件写进bool.filter而不是bool.must?”
答案并不只是“性能更好”这么简单。关键在于:只有 filter 上下文中的查询才能进入 Query Cache。
它到底缓了什么?
Query Cache 并没有缓存最终结果,它缓的是“哪些文档命中了这个条件”——也就是一个bitset(位集)。比如某个 filter 是status: active,执行后会生成一个长长的二进制串:
10110010... (第 n 位为 1 表示第 n 个文档匹配)下次再出现同样的 filter 条件时,ES 直接复用这个 bitset,省去了遍历倒排索引的开销。
这就像你查电话簿,第一次花时间翻到了所有姓“张”的人;第二次有人再问“姓张的有哪些?”,你直接掏出记好的名单就行。
分片级缓存 + 每秒清空一次?
是的,你没看错。Query Cache 是按分片粒度维护的,每个分片都有自己独立的一份缓存。而且,默认情况下,只要发生 refresh,整个分片的 Query Cache 就会被清空。
为什么这么激进?因为新增文档可能也满足之前的 filter 条件。为了保证结果准确,只能全删重算。
这意味着:
- 如果你的索引是“写多读少”型(如日志流),refresh 太频繁会导致 Query Cache 基本无效;
- 反之,如果是静态数据或低频更新的数据集,Query Cache 的命中率会非常高。
怎么让它更持久一点?
可以通过调整 refresh interval 来降低清空频率:
PUT /my-index { "settings": { "refresh_interval": "30s" } }这对报表类、归档类查询非常有用。虽然牺牲了一点实时性,但换来的是极高的缓存命中率和稳定的响应延迟。
内存控制与命中门槛
默认配置下,Query Cache 最多占用 JVM 堆内存的 10%:
indices.queries.cache.size: 10%但它也不是“一查就缓”。一个 filter 必须至少被执行两次,才会被考虑加入缓存。这是为了避免缓存那些偶然出现、不会再用的冷门条件。
小贴士:你可以通过
_nodes/stats查看当前缓存状态:
bash GET /_nodes/stats/query_cache关注
hit_count和cache_size,如果eviction_count很高,说明缓存空间不足,需要扩容或优化查询模式。
请求缓存:让仪表盘秒开的秘密武器
如果说 Query Cache 是“中间加速器”,那 Request Cache 就是“终极快照”——它缓存的是整个搜索请求的完整响应体,包括 hits 列表、aggregations、suggesters 等所有内容。
它适合什么样的场景?
典型的就是监控大盘、BI 报表、API 接口轮询这类“请求固定 + 高频访问”的业务。
举个例子:
GET /logs-*/_search { "query": { "range": { "@timestamp": { "gte": "now-1h" } } }, "aggs": { "errors_by_service": { "terms": { "field": "service.keyword" } } } }这个请求每 30 秒由前端发起一次。第一次走完整流程耗时 200ms,第二次发现请求完全一样,直接从 Request Cache 返回结果,耗时不到 5ms。
它是怎么判断“一样”的?
ES 会对整个请求体做哈希,生成 cache key,包含以下要素:
- 查询 DSL 结构
- 排序规则
- 分页参数(from/size)
- 索引列表
- 搜索类型(query_then_fetch 等)
哪怕只是from=10改成from=20,也会导致 cache key 不同,无法命中。
所以深度分页(如
from=10000)不仅拖慢查询,还会制造大量无法复用的缓存条目,严重浪费内存。
写入一次,缓存全废?
没错。Request Cache 的失效策略非常严格:只要目标索引有任何写操作(index/delete/update),该索引对应的所有缓存条目都会被清除。
这也是合理的——数据变了,结果自然不能复用旧的。
因此,在持续写入的日志系统中,Request Cache 的有效性取决于轮询周期与写入频率的关系。如果每秒都有新日志写入,那你基本别指望它能长期有效。
不过有个例外:副本分片可以各自缓存相同请求的结果。也就是说,即使主分片刚清空了缓存,副本仍可能命中,从而提升整体服务能力。
如何手动关闭?什么时候该关?
有时候你需要确保拿到最新数据,比如调试或审计场景。这时可以在请求中显式禁用:
{ "query": { "match_all": {} }, "size": 10, "request_cache": false }设置"request_cache": false后,本次请求既不会读缓存也不会写入缓存。
注意:这不是全局关闭,而是单次请求级别的控制,非常灵活。
text 字段聚合为何危险?Field Data Cache 的代价
现在我们来看最让人头疼的一个缓存:Field Data Cache。
当你尝试对一个text字段进行 terms 聚合时,ES 往往会报错:
Fielddata is disabled on text fields by default. Set fielddata=true ...于是很多人顺手加上"fielddata": true,然后上线跑了一周,JVM 内存飙升,GC 频繁,最后 OutOfMemoryError。
这就是 Field Data Cache 的“甜蜜陷阱”。
它干了什么事?
Lucene 的倒排索引适合查找“哪个文档包含某个词”,但不适合统计“每个文档里的词分别是什么”——而这正是排序和聚合需要的。
所以 ES 需要把 text 字段的内容加载到堆内存中,构建一种叫正排索引的结构(类似 doc values),这个过程依赖 Field Data Cache。
问题是:这个结构完全驻留在 JVM 堆内存中,且对高基数字段(如用户描述、评论内容)来说,内存消耗可能是 GB 级别的。
为什么 keyword 就安全?
因为keyword类型默认开启doc_values = true,它的正排数据存储在操作系统的文件系统缓存中(off-heap),不受 JVM GC 影响,也不占用堆内存。
这才是现代 ES 推荐的做法。
正确建模方式:multi-field + keyword
最佳实践是使用 multi-field 映射:
PUT /my-index { "mappings": { "properties": { "description": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } } } } }这样:
- 全文检索走description(text);
- 聚合分析走description.keyword(keyword),无需启用 fielddata,性能更好也更稳定。
ignore_above表示超过 256 字节的值将不被索引为 keyword,防止异常长文本撑爆内存。
缓存管理:LRU + 内存上限
Field Data Cache 使用 LRU(最近最少使用)算法自动淘汰冷数据。你可以通过配置限制其最大内存使用:
indices.fielddata.cache.size: 30%但这只是“软限制”。当内存紧张时,ES 会主动驱逐部分条目。真正要解决风险,还得从源头杜绝滥用 fielddata。
三种缓存如何协同工作?一个真实案例拆解
让我们回到开头提到的运维监控仪表盘场景:
每 30 秒查询过去一小时的错误日志数量,按服务名分组展示。
{ "query": { "bool": { "filter": [ { "range": { "@timestamp": { "gte": "now-1h" } } }, { "term": { "level": "ERROR" } } ] } }, "aggs": { "by_service": { "terms": { "field": "service.keyword" } } } }来看看这一条请求是如何被层层加速的:
Query Cache 发力
第一次执行时,两个 filter 条件分别生成 bitset 并缓存。后续请求直接复用,跳过 Lucene 查找。Request Cache 接棒
整个请求结构不变,cache key 一致,第二次起直接返回 JSON 响应,连聚合都不用重新计算。避开了 Field Data 坑
因为用了service.keyword,走的是 doc_values,完全不需要加载到堆内存,避免了潜在的 OOM 风险。
最终效果:首屏加载较慢,之后几乎瞬时返回,用户体验极佳。
实战建议:如何设计高效的缓存策略?
光懂原理不够,你还得会用。以下是我们在生产环境中总结出的实用准则:
✅ 应该怎么做?
| 场景 | 推荐做法 |
|---|---|
| 提升 filter 性能 | 所有非评分条件放入bool.filter |
| 减少重复计算 | 对固定查询启用 Request Cache |
| 支持聚合分析 | 文本字段务必定义.keyword子字段 |
| 控制内存使用 | 根据负载调整各类缓存大小比例 |
❌ 绝对不要做的事
- 对 text 字段开启 fielddata 做高频聚合;
- 在动态分页(如
from=${Math.random()})请求上依赖 Request Cache; - 在每秒 refresh 的索引中期望 Query Cache 有高命中率;
- 忽视
_nodes/stats中的 eviction_count 和 memory_size 指标。
监控命令清单
定期检查缓存健康状况:
# 查看所有节点缓存统计 GET /_nodes/stats/query_cache,request_cache,fielddata # 计算 Query Cache 命中率 ( hit_count / (hit_count + miss_count) ) * 100% # 观察是否频繁淘汰 # eviction_count 持续增长 → 缓存空间不足命中率低于 60%?赶紧查查是不是查询太分散,或者缓存配得太小。
写在最后:缓存不是银弹,理解才是根本
Elasticsearch 的缓存机制不是为了炫技,而是为了解决真实的性能瓶颈。但在 8.x 版本中,随着向量检索、机器学习等新功能引入,资源竞争更加激烈,盲目依赖缓存反而可能导致 GC 风暴或内存溢出。
真正的高手,不会只问“怎么开缓存”,而是思考:
- 我的数据模型是否合理?
- 查询模式能否标准化?
- 写入频率与读取需求是否匹配?
当你能结合业务特点,权衡实时性与性能、灵活性与稳定性,才算真正掌握了 ES 的精髓。
下次面试官再问“ES 有哪些缓存”,别再说“三种”就完了。试着告诉他:
“Query Cache 加速 filter 执行,但受 refresh 制约;Request Cache 缓存整条响应,适合静态请求;Field Data Cache 危险但可用,但我们有更好的替代方案。”
这才是让人眼前一亮的回答。
如果你正在搭建搜索平台、优化日志系统,或者准备迎接一场硬核面试,不妨停下来想想:你现在的缓存策略,真的发挥出价值了吗?欢迎在评论区分享你的实战经验。