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
...
2
3
4
5
这份反方向的结构,就是 fielddata。它的职责只有一件事:让聚合和排序能按 docID 直接取到字段值。
用数据库的话说:倒排索引是「按值建的索引」,fielddata 是现场重建出来的「正排列存」。
# 关键特性(务必记住)
| 特性 | 说明 |
|---|---|
| 存储位置 | 仅 JVM 堆内存,磁盘上没有任何副本 |
| 数据来源 | 从倒排索引实时推导出来的派生数据 |
| 生命周期 | ① 首次聚合时加载 → ② 常驻堆内当缓存 → ③ 不随查询结束释放 |
| 粒度 | 每个段(segment)× 每个字段,独立管理 |
| GC 可见性 | 是活缓存不是垃圾对象,GC 不会回收它 |
# 生命周期详解
第一步:加载(Load)
某字段第一次被用于聚合/排序时,ES 遍历该字段在当前 segment 里的全部词项,逐个展开成 docID 列表,反向填进一个数组。字段有多少不同的值、索引有多少文档,就扫多少。大 segment 上这个过程耗时明显。
第二步:缓存(Cache)
填好的结构不会扔掉,留在堆里当缓存——ES 认为「你聚合过一次,很可能还会再聚」。第二次聚合同一字段直接命中,不用再翻。
第三步:常驻(Resident)
查询结束不释放。 它不是垃圾,GC 管不着。它会一直占着堆,直到发生下面五件事之一:
- Segment 合并(merge)把旧段淘汰掉
- 索引被 close
- 索引被删除
- ILM 把索引 close / freeze / delete 掉(注意:单纯 rollover 不释放,旧索引还在,fielddata 也还在)
- 进程重启
- 手动
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..."
}
}
2
3
4
5
6
这道闸是 ES 故意设的,等于立了块「此路危险」的牌子。
正确做法:别开 fielddata,改用 .keyword 子字段(它有 doc_values)。
{ "aggs": { "by_title": { "terms": { "field": "title.keyword" } } } }
# 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 [...]
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" } }
}
}
}
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
⚠️ 清完当场看堆水位反而会上升。这是正常的:clear 做的是解除引用,那块内存从「活着的缓存」变成「待回收的垃圾」,但在下一轮 GC 扫到之前,它仍然算在 heap.current 里。真正的下降要等 GC 跑完。
不清也行——段合并会慢慢把它磨掉,索引滚出生命周期后自然消失。手动清的真正价值是拿到一条干净的零基线:往后只要这个数字重新从 0 开始爬,就说明那个查询被写回去了,或者有服务实例没滚到新版本。等于白捡一个回归监控点。
# 十、决策图
聚合 / 排序需要 "doc → value"
│
▼
字段有 doc_values?
(keyword/数值/日期/ip/布尔 默认都有)
│
┌────┴────┐
是 否
│ │
▼ ▼
读磁盘 .dvd 字段是 text 还是 _id?
(正常路径) │
┌──┴──┐
text _id
│ │
▼ ▼
改用 .keyword 改读 doc_count
子字段 或换个唯一字段
│ │
└──┬───┘
▼
只有实在没办法时才开 fielddata
(堆内存告警!)
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
两个容易踩的坑:
_cat/fielddata的<field>位置是字段名不是索引名。想按索引看,得用第 3 条的_stats。- 如果集群上每个节点都跑了一份 exporter 抓全集群指标,那么在监控系统里查 fielddata 会拿到重复三份的数据。查询时必须钉死单个节点标识,否则数字会翻倍/三倍。
# 十二、三句话总结
- fielddata = 堆内现建的正排表,为聚合/排序服务;doc_values 是它的磁盘版替代品,现在默认全开。
- 它只增不减,查询结束不释放,默认无上限,且没有告警面——只能主动盯趋势曲线。
- 看到它,先怀疑查询写错了,尤其是对
text或_id做聚合。九成情况有零成本的替代写法。