同一个仪表盘每 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 的响应,需显式开启 |
| fielddata | text 字段正排,默认关闭,应用 keyword 替代 |
| 失效单位 | 段是基本单位,refresh 触发失效 |
| 缓存键 | 查询表达式字符串,取整才能稳定命中 |
| 观测手段 | nodes stats 与 indices stats 看命中与驱逐 |
| 调优顺序 | 先改查询写法,再调配额 |
| 分层配合 | 只读历史索引命中率远高于热索引 |
缓存的本质是「用空间换时间」,而 Elasticsearch 的三层缓存把这笔交易拆成了三份不同期限的合约。理解每层的失效条件,比记住配额参数更重要。当索引规模增长到单节点放不下、又希望保留查询能力时,可搜索快照提供了另一条路;而缓存与内存的深层关系,与 JVM 堆和 GC 调优紧密相关,这两条线索在后续文章中会继续展开。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。