灯下哥谭 灯下哥谭
首页
关于
  • Hermes Agent 平台
  • Claude Code
  • OpenClaw
  • GPU 推理节点运维
  • DeepSeek Harness
  • MySQL 运维知识地图
  • Elasticsearch 运维知识地图
  • Redis 运维知识地图
  • TiDB 体系
  • DBA 常用 SQL 与命令
  • Nginx 运维知识地图
  • Prometheus 监控
  • Docker
  • Systemd
  • Iptables
  • Firewalld
  • Sshd
  • MySQL8 运维 SOP 手册
  • MySQL 实战 45 讲(读书笔记)
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

灯下哥谭

灯还亮着
首页
关于
  • Hermes Agent 平台
  • Claude Code
  • OpenClaw
  • GPU 推理节点运维
  • DeepSeek Harness
  • MySQL 运维知识地图
  • Elasticsearch 运维知识地图
  • Redis 运维知识地图
  • TiDB 体系
  • DBA 常用 SQL 与命令
  • Nginx 运维知识地图
  • Prometheus 监控
  • Docker
  • Systemd
  • Iptables
  • Firewalld
  • Sshd
  • MySQL8 运维 SOP 手册
  • MySQL 实战 45 讲(读书笔记)
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • MySQL

  • Redis

  • 高性能KV

  • TiDB

  • Elasticsearch

    • Elasticsearch 运维知识地图:从安装配置到排障加速恢复
    • Elasticsearch 集群安装:RPM 裸机生产部署 vs docker-compose 快速起集群
    • 给Elasticsearch集群添加用户密码
    • Elasticsearch 分片和副本:容量怎么规划,改分片数为什么这么麻烦
    • Elasticsearch集群节点磁盘使用分配不均解决办法
    • Elasticsearch 索引模板与映射:Composable 模板、mapping 与动态模板
    • Elasticsearch 分页查询三种方案:from/size、search_after、scroll 怎么选
    • Elasticsearch字符串搜索方式
    • Elasticsearch使用wildcard字段模糊匹配
    • Elasticsearch 数据迁移方案对比:esm vs Logstash
    • Nginx Mirror 模块实现三套ES写入网关
    • ES排障两件套:慢查询日志阈值配置 + tcpdump 抓包看真实请求
    • ES 集群恢复太慢?三个参数加速节点/分片恢复
    • Elasticsearch 常用 DSL 语句(速查表)
    • ES 集群 Yellow 复盘:1023 个副本永远分配不出去,问题不在磁盘
    • ES 的 fielddata:一个亚秒级查询如何吃掉 20% 的堆
      • 一、一句话先记住
      • 二、倒排索引的盲区
      • 三、fielddata 就是那张「翻过来的表」
        • 关键特性(务必记住)
        • 生命周期详解
        • 为什么占用是「持续缓慢爬升」
      • 四、doc_values:现代替代品
      • 五、什么时候躲不开 fielddata
        • 1. text 类型(分词过的)
        • 2. _id 字段
      • 六、重启会让 fielddata 归零,但这不解决问题
      • 七、堆积的危害(按严重度递增)
        • 1. 白占堆内存
        • 2. 晋升老年代,推高 GC 成本
        • 3. 垫高熔断器基线,误伤正常查询
        • 4. 极端情况 OOM
        • 5. 最麻烦的:它没有告警面
      • 八、配置参数
      • 九、实战案例:报表查询把 _id 喂进了堆
        • 背景
        • 后果
        • 排查难点
        • 修复
        • 收尾:存量不会自己消失
      • 十、决策图
      • 十一、排查清单
      • 十二、三句话总结
  • 数据管道

  • 其他数据库

  • 数据库
  • Elasticsearch
灯下哥谭
2026-09-18
目录

ES 的 fielddata:一个亚秒级查询如何吃掉 20% 的堆原创

一条耗时只有几百毫秒的查询,够不着慢日志阈值,监控上风平浪静,却在一个多月里悄悄吃掉了集群 1.8GB 堆内存。

根因是 fielddata。这篇把它讲透:它是什么、为什么存在、什么时候会咬人,以及怎么在它咬人之前发现。

# 一、一句话先记住

fielddata 是 ES 为了做聚合/排序,在 JVM 堆里临时造的一份「文档 → 字段值」的正排表。

它是 doc_values 出现之前的老办法。今天它出现在你的集群里,基本都意味着有人写了一个本不该这么写的查询。

后面的内容都是在解释这句话。


# 二、倒排索引的盲区

先复习倒排索引(inverted index)存什么:

词项(Term) 文档列表(Posting List)
TWD [doc1, doc3, doc7]
USD [doc2, doc5]
JPY [doc4]

它擅长的问题:「找出 Currency=TWD 的文档」→ 直接读 TWD 那一行,快得不讲道理。这是搜索引擎的本职工作。

它答不了的问题:「doc1 的 Currency 是什么?」

这个方向它压根没存。

而聚合(aggregation)和排序(sort)问的恰恰就是这个方向。因为聚合的执行方式是逐文档遍历:拿到 doc1,问它 Currency 是啥,丢进对应的桶;再拿 doc2……

想回答它,只有一个办法:把整张表翻过来重建一遍。


# 三、fielddata 就是那张「翻过来的表」

doc1 → TWD
doc2 → USD
doc3 → TWD
doc4 → JPY
...
1
2
3
4
5

这份反方向的结构,就是 fielddata。它的职责只有一件事:让聚合和排序能按 docID 直接取到字段值。

用数据库的话说:倒排索引是「按值建的索引」,fielddata 是现场重建出来的「正排列存」。

# 关键特性(务必记住)

特性 说明
存储位置 仅 JVM 堆内存,磁盘上没有任何副本
数据来源 从倒排索引实时推导出来的派生数据
生命周期 ① 首次聚合时加载 → ② 常驻堆内当缓存 → ③ 不随查询结束释放
粒度 每个段(segment)× 每个字段,独立管理
GC 可见性 是活缓存不是垃圾对象,GC 不会回收它

# 生命周期详解

第一步:加载(Load)

某字段第一次被用于聚合/排序时,ES 遍历该字段在当前 segment 里的全部词项,逐个展开成 docID 列表,反向填进一个数组。字段有多少不同的值、索引有多少文档,就扫多少。大 segment 上这个过程耗时明显。

第二步:缓存(Cache)

填好的结构不会扔掉,留在堆里当缓存——ES 认为「你聚合过一次,很可能还会再聚」。第二次聚合同一字段直接命中,不用再翻。

第三步:常驻(Resident)

查询结束不释放。 它不是垃圾,GC 管不着。它会一直占着堆,直到发生下面五件事之一:

  1. Segment 合并(merge)把旧段淘汰掉
  2. 索引被 close
  3. 索引被删除
  4. ILM 把索引 close / freeze / delete 掉(注意:单纯 rollover 不释放,旧索引还在,fielddata 也还在)
  5. 进程重启
  6. 手动 POST /<index>/_cache/clear?fielddata=true

注意上面没有任何一项跟「查询结束」有关。 这是 fielddata 和普通缓存最不一样的地方。

# 为什么占用是「持续缓慢爬升」

因为粒度是 每段 × 每字段。

新数据写入 → 产生新 segment → 新 segment 第一次被聚合到 → 再加载一份。

所以即使查询逻辑一个字没改,只要数据还在写,fielddata 就会持续缓慢往上爬,而不是一次性跳到某个值就停住。趋势曲线在缓慢单调上升,是识别这个问题最可靠的信号。


# 四、doc_values:现代替代品

ES 2.x 之后引入了 doc_values,干的是完全一样的活(也是 doc → value),但实现方式完全不同。

对比项 fielddata doc_values
构建时机 查询时现场构建 写入时就建好
存储位置 JVM 堆内存 磁盘文件(.dvd / .dvm)
读取方式 堆内直接访问 靠 OS page cache
内存压力 吃堆,GC 管不了,无法换出 不占堆,内存紧张时 OS 自己换出
重启之后 消失,需重新构建 文件还在,直接读
谁在用 只剩 text(需手动开)和 _id keyword、数值、日期、ip、布尔——默认全开

结论:日常写的 sum()、terms()、date_histogram() 聚合,走的全是 doc_values,根本碰不到 fielddata。安静、不吃堆。


# 五、什么时候躲不开 fielddata

只有两种字段没有 doc_values 可用:

# 1. text 类型(分词过的)

text 字段默认 fielddata: false。不手动开,ES 直接拒绝:

{
  "error": {
    "type": "illegal_argument_exception",
    "reason": "Fielddata is disabled on text fields by default..."
  }
}
1
2
3
4
5
6

这道闸是 ES 故意设的,等于立了块「此路危险」的牌子。

正确做法:别开 fielddata,改用 .keyword 子字段(它有 doc_values)。

{ "aggs": { "by_title": { "terms": { "field": "title.keyword" } } } }
1

# 2. _id 字段

最坏的情况。 _id 每文档唯一,基数 = 文档数,fielddata 条目一个都省不掉——文档有多少条,它就有多少条。

ES 官方也正因为这个,把「对 _id 聚合或排序」标成了 deprecated。


# 六、重启会让 fielddata 归零,但这不解决问题

为什么重启会清空它? 因为它纯在堆里、磁盘无副本。进程一死,堆没了,里面的一切跟着消失。重启后 ES 起来是一个空堆,没有任何「fielddata 文件」可以加载回来——它本来就不是持久化数据。下次谁再聚合,就地现翻一份新的。

对照 doc_values 就很清楚:那是磁盘上的真实文件,重启后原封不动还在,page cache 冷一点而已。

所以「重启 → fielddata 归零」不是 ES 做了什么清理动作,而是它从来就没打算让这东西活过进程生命周期。

⚠️ 最容易踩的坑:重启让数字归零,不代表问题修好了。只要导致它增长的查询还在跑,它就会重新爬上来。

重启只是把温度计甩了一下,不退烧。

排查时如果看到「某天数字暴跌」,先去查那天是不是重启过(GET /_cat/nodes?h=name,uptime),别把重启误当成修复生效的证据。


# 七、堆积的危害(按严重度递增)

# 1. 白占堆内存

堆是 ES 最稀缺的资源,要同时喂给:写入缓冲(indexing buffer)、query / request cache、segment 元数据、聚合运行时内存。

fielddata 占掉一块,所有人都得挤一挤。而且早就没人查的旧索引,那块内存依然在交房租。

# 2. 晋升老年代,推高 GC 成本

fielddata 是长寿对象,会被 GC 从新生代晋升到老年代。老年代被这种「永远不死」的东西垫高水位后:

  • 老年代 GC / Full GC 更频繁
  • 单次 GC 停顿更长
  • 表现出来就是查询延迟周期性毛刺

严重时 GC 停顿超过集群故障检测超时,节点被判定失联、掉出集群,触发分片重分配——一个本来只是「内存有点满」的问题,升级成集群抖动。

# 3. 垫高熔断器基线,误伤正常查询

parent 熔断器统计的堆包含 fielddata。基线被垫高后,正常查询申请内存时更容易撞线被拒。

尤其阴险的是:熔断被拒时 ES 可能返回 HTTP 200 + total: 0,看起来像「查询成功但没数据」,巡检脚本完全察觉不到。

配套铁律:看到聚合结果 total == 0,一定要顺手看 _shards.failed,别直接当成「确实没数据」。

# 4. 极端情况 OOM

节点直接挂掉,恢复时间以小时计。

# 5. 最麻烦的:它没有告警面

不报错、不打日志、不触发任何常规阈值,就是安静地啃掉你 20% 的堆。

真正会响的那个(fielddata 熔断器)是撞墙才响的东西——响的时候已经在拒绝查询了。所以这类问题只能靠主动去看。


# 八、配置参数

参数 默认值 说明
indices.fielddata.cache.size 无上限 ES 在这件事上不自我保护
indices.breaker.fielddata.limit 40% 堆 fielddata 专用熔断器
indices.breaker.total.limit 95% 堆 parent 熔断器,统计口径含 fielddata

熔断触发时报错长这样:

circuit_breaking_exception: [parent] Data too large, data for [<http_request>]
would be [...], which is larger than the limit of [...]
1
2

注意 indices.fielddata.cache.size 默认无上限这一条——意味着没有任何东西会主动帮你把它控制住,除非你自己配。


# 九、实战案例:报表查询把 _id 喂进了堆

# 背景

一个后台报表,要按「用户 / 站点 / 分类 / 类型 / 币种」五个维度分组,统计每组的金额合计和记录笔数。

它用 composite 聚合做分组(因为维度组合多、需要分页),笔数是这么算的:

"aggregations": {
  "group_by_all": {
    "composite": {
      "size": 5000,
      "sources": [ "... 五个维度 ..." ]
    },
    "aggregations": {
      "total_amount": { "sum":         { "field": "Amount" } },
      "order_count":  { "value_count": { "field": "_id" } }
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

问题就出在 order_count 这一行。

# 后果

_id 没有 doc_values(见第五节),被迫走 fielddata。而 composite 是分页的,一次完整报表要翻很多页,每页都触发一次加载。

  • 单个索引:约 1300 万文档
  • fielddata 占用:约 520 MB
  • 换算:约 41 字节/文档(正好是 _id 字符串加结构开销的量级)
  • 多个月份索引累计:约 1.8 GB

最糟的是:旧月份索引早就没人查了,那 1.5 GB 却一直躺在堆里不释放——因为没触发 merge,索引也没删。

# 排查难点

这个查询耗时只有 245–763 毫秒,够不着慢日志默认的 5 秒阈值,所以慢日志里完全看不见它。

教训:判断这类问题不能看慢日志有没有记录,要看 fielddata 的趋势曲线是否仍在单调爬升。

一个亚秒级、看起来人畜无害的查询,完全可以在监控盲区里吃掉你 20% 的堆。

# 修复

关键发现:composite 的每个桶本来就免费返回 doc_count。

  • _id 每文档必有且唯一
  • 所以 value_count(_id) 的结果 = 桶内文档数 = doc_count
  • 而且是精确值,不是估算
  • doc_count 是 Lucene 遍历桶时顺手就计上的,零额外开销

也就是说,原查询绕了个大圈:把 _id 整个 fielddata 化装进堆,再数一遍个数,得到一模一样的数字。

改法:删掉 value_count(_id) 子聚合,客户端把 buckets[i].order_count.value 改读 buckets[i].doc_count。

代价:

  • 不用改 mapping
  • 不用重建索引
  • 不用重启集群
  • 查询结果完全一致

纯客户端一行改动。

# 收尾:存量不会自己消失

改完之后已经加载的那部分不会自动退还(evictions = 0,它不是被淘汰的缓存)。要立刻回收得手动清:

POST /<index-pattern>/_cache/clear?fielddata=true
1

⚠️ 清完当场看堆水位反而会上升。这是正常的:clear 做的是解除引用,那块内存从「活着的缓存」变成「待回收的垃圾」,但在下一轮 GC 扫到之前,它仍然算在 heap.current 里。真正的下降要等 GC 跑完。

不清也行——段合并会慢慢把它磨掉,索引滚出生命周期后自然消失。手动清的真正价值是拿到一条干净的零基线:往后只要这个数字重新从 0 开始爬,就说明那个查询被写回去了,或者有服务实例没滚到新版本。等于白捡一个回归监控点。


# 十、决策图

聚合 / 排序需要 "doc → value"
         │
         ▼
   字段有 doc_values?
   (keyword/数值/日期/ip/布尔 默认都有)
         │
    ┌────┴────┐
    是         否
    │          │
    ▼          ▼
读磁盘 .dvd   字段是 text 还是 _id?
(正常路径)      │
              ┌──┴──┐
            text    _id
              │      │
              ▼      ▼
        改用 .keyword  改读 doc_count
        子字段         或换个唯一字段
              │      │
              └──┬───┘
                 ▼
        只有实在没办法时才开 fielddata
            (堆内存告警!)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 十一、排查清单

怀疑 fielddata 有问题时,按这个顺序走:

# 1. 谁在占?(按节点 × 字段列出)
GET /_cat/fielddata?v&s=size:desc

# 2. 只看某个字段
GET /_cat/fielddata?v&fields=_id

# 3. 精确到索引级(_cat 做不到,要用 _stats)
GET /<index-pattern>/_stats/fielddata?fields=*&level=indices

# 4. 熔断器当前状态与触发次数
GET /_nodes/stats/breaker

# 5. 节点 uptime —— 确认数字暴跌是不是重启造成的假象
GET /_cat/nodes?v&h=name,uptime,heap.percent,fielddata.memory_size

# 6. 手动清空(确认过原因之后再做)
POST /<index-pattern>/_cache/clear?fielddata=true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

两个容易踩的坑:

  • _cat/fielddata 的 <field> 位置是字段名不是索引名。想按索引看,得用第 3 条的 _stats。
  • 如果集群上每个节点都跑了一份 exporter 抓全集群指标,那么在监控系统里查 fielddata 会拿到重复三份的数据。查询时必须钉死单个节点标识,否则数字会翻倍/三倍。

# 十二、三句话总结

  1. fielddata = 堆内现建的正排表,为聚合/排序服务;doc_values 是它的磁盘版替代品,现在默认全开。
  2. 它只增不减,查询结束不释放,默认无上限,且没有告警面——只能主动盯趋势曲线。
  3. 看到它,先怀疑查询写错了,尤其是对 text 或 _id 做聚合。九成情况有零成本的替代写法。
#Elasticsearch#性能优化#JVM
上次更新: 9/18/2026

← ES 集群 Yellow 复盘:1023 个副本永远分配不出去,问题不在磁盘 Kafka 日常操作与配置指南→

最近更新
01
DeepSeek Harness 实战 06|学习笔记:插件、工具、技能不在同一个维度上 原创
09-11
02
DeepSeek Harness 实战 05|让两个编码 Agent 共用一份长期记忆 原创
09-09
03
DeepSeek Harness 实战 04|学习笔记:从「已知限制」里读出三处设计张力 原创
09-08
更多文章>
Theme by Vdoing
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式