一个业务索引跑了半年,写入一直正常。某天开始零星报 Limit of total fields [1000] has been exceeded,两天后整个索引拒绝写入,上游的同步任务积压了几十万条。翻 mapping 发现字段数从 400 涨到了 1,012——涨的方式不是"加了 600 个字段",而是加了一个带自由结构的对象。
动态 mapping 的默认行为是"见到新字段就建映射"。这个默认在项目初期很省事,在项目半年后变成一颗定时炸弹:它的计数单位是"字段路径",不是"业务字段"。嵌套对象 payload: {a, b, c} 是三个字段路径,日志类数据里的动态标签更是成倍增长。
1. 动态 mapping 把"一个对象"变成"一串字段"
| 你写入的文档 | 实际建立的字段路径 | 字段数 |
|---|---|---|
{"name": "x"} |
name(且 text + keyword 两个) |
2 |
{"user": {"name": "x", "age": 3}} |
user.name、user.age |
4 |
{"tags": {"k1": "v", "k2": "v"}} |
tags.k1、tags.k2 |
4 |
{"labels": {"user-123": "vip"}} |
labels.user-123 |
2 |
第一行的细节最容易被忽略:字符串字段默认建为 text(分词)+ keyword(精确),一个业务字段占两个字段位。所以"1000 的上限"对业务字段来说只有约 500。
第三、四行是爆炸的典型形状:把"标签/属性/元数据"这类键会变的 map 直接塞进文档,每出现一个新键就新增一个字段路径,而字段一旦建立就不会自动消失——即使那条文档被删了,mapping 里的字段还在。
// 一次典型的爆炸过程(6 个月)
月1 业务字段 180 → mapping 360 正常
月3 加"扩展属性" map,键来自上游 → 620
月5 上游给标签加了环境前缀 → 880
月6 一条异常数据带了 120 个动态键 → 1005
月6 下一条写入直接 400: Limit of total fields [1000]
// 注意:删掉那些文档也不会让字段数下降
2. 为什么是"硬失败"而不是"变慢"
字段数超限后,写入直接返回 400,不是降级、不是丢字段。所以它的表现是"突然写不进去",而不是"慢慢变慢"——这与大多数人预期的"容量问题"不同。
| 阈值 | 默认值 | 超限行为 |
|---|---|---|
index.mapping.total_fields.limit |
1000 | 写入 400 拒绝 |
index.mapping.depth.limit |
20 | 拒绝 |
index.mapping.nested_fields.limit |
50 | 拒绝 |
index.mapping.nested_objects.limit |
10000 | 拒绝 |
// 查字段数的三种口径,数字会不一样,认第一个(它对应上限)
$ curl -s 'localhost:9200/myidx/_stats' | grep -o '"total_fields[^}]*'
$ curl -s 'localhost:9200/myidx/_mapping' | wc -c // 只反映体积,不是字段数
$ curl -s 'localhost:9200/_cluster/stats' | grep -o '"field_count[^,]*'
// 第三条是全集群合计,不是单索引上限——别拿它判断会不会炸
第二、三行是"嵌套"的独立上限。这三项都是索引级设置,改它需要关索引或 _settings 更新(部分可动态改),而调大上限只是把炸弹推远:字段越多,集群状态(cluster state)越大,主节点每次更新都要广播它——最终会先在主节点上出问题。
3. 三类爆炸源,各自的判据
| 爆炸源 | 形状 | 判据 |
|---|---|---|
| 动态标签/属性 map | 键由业务数据决定 | 键的基数是否可控(能否枚举) |
| 多版本字段并存 | title、title_v2、title_new |
旧字段是否还在写 |
| 日志的动态维度 | labels.pod、labels.trace |
维度是否会被高基数污染 |
| 嵌套对象层层展开 | a.b.c.d 深度增长 |
深度是否随数据变 |
第一行的判据最实用:键的基数是否可枚举。status(几个值)可以做成字段,user_id(百万级)绝不能——后者要么放 keyword 的数组里,要么放 flattened,要么根本不进 ES。
// 反例:把高基数 map 直接写进去
{"doc_id": 1, "attrs": {"user-8821": "vip", "user-8822": "new"}}
// mapping 会为每个 user-xxxx 建一个字段 → 基数多大就炸多大
// 正解一:flattened,整张 map 当一个字段
{"attrs": {"type": "flattened"}}
// 正解二:改成"键值数组",键作为值而不是字段名
{"attrs": [{"k": "user-8821", "v": "vip"}, {"k": "user-8822", "v": "new"}]}
4. 处置:四种手段,代价不同
| 手段 | 效果 | 代价 | 适用 |
|---|---|---|---|
关掉 dynamic(dynamic: false) |
新字段不入索引,但存在 _source |
新字段搜不到 | 结构基本稳定 |
dynamic: strict |
未知字段直接拒绝写入 | 上游必须先对齐 schema | 契约明确的场景 |
flattened 类型 |
整张 map 一个字段,可整体搜 | 不能对内部键做精确聚合 | 标签/属性 map |
| 显式 mapping + 模板 | 完全可控 | 前期工作量大 | 核心业务索引 |
第一、二行的差别值得记住:false 是"静默接受、不索引"(写入成功但搜不到),strict 是"明确拒绝"。前者在生产上更难发现——它不报错,只是某些查询突然查不到东西了。
// 一个可用的动态模板:把"看起来像 map 的字段"统一压成 flattened
{
"dynamic_templates": [
{ "attrs_as_flattened": {
"path_match": "attrs.*",
"mapping": { "type": "flattened" } } },
{ "strings_as_keyword": {
"match_mapping_type": "string",
"mapping": { "type": "keyword" } } }
]
}
// 第二条同时解决"一个字符串占两个字段位"的问题
5. 已经炸了怎么办:先止血再重构
字段超限的现场处置顺序与"设计阶段"完全不同,先要让写入恢复。
// 处置顺序(按可逆性从高到低)
1 临时调大上限(改 settings,可逆):limit 1000 → 3000
2 定位新字段来源:对比 7 天前后的 mapping diff,找近期新增的路径前缀
3 把爆炸源字段改成 flattened(需要重建索引,走别名)
4 重建完成后把上限调回 1000,防止再涨
5 加监控:字段数 > 70% 时告警
// 关键:第 1 步只是窗口,不是修复;第 4 步必须做,否则下次还会炸
第 2 步的 mapping diff 是最省时间的定位手段:把当前 mapping 与一周前的快照按字段路径排序后 diff,新增的那几十个路径会直接指向"哪个上游字段在变"。这比翻写入日志快一个量级。
6. 排错:五种"写不进去"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 400 且提示 total fields | 字段数超限 | 按第 5 节顺序处置 |
| 400 且提示 depth | 嵌套层数超限 | 扁平化文档结构 |
| 写入成功但搜不到新字段 | dynamic: false |
显式加 mapping 或用 strict |
| 字段数只增不减 | 删文档不影响 mapping | 必须重建索引 |
| 主节点 CPU 飙高 | cluster state 过大(字段太多) | 同上,重建 + 收敛 |
第四行是很多人第一次遇到时的困惑点:mapping 是索引级元数据,不是文档级。删掉那条"带了 120 个动态键"的异常文档,字段依旧存在;唯一清掉的方式是重建索引。
7. 边界:什么情况不该用 ES 存这类数据
| 场景 | 为什么不该 | 替代 |
|---|---|---|
| 键完全自由的 KV/属性 | 必然字段爆炸 | MongoDB / 关系表 / 对象存储 |
| 高基数维度做聚合 | 桶爆炸 + 字段爆炸 | 预聚合表或 ClickHouse |
| 只需按 id 查原文 | 不需要倒排 | KV 存储 |
| 结构频繁演进 | 每次都要重建 | 明确的宽表 + 版本化字段名 |
| 日志里带自由标签 | 高基数污染 | 固定维度 + 数值化 |
判断口径一句话:当"这个字段名会不会由数据决定"的答案是"会"时,它就不该成为 ES 的字段——要么 flattened 成一张 map,要么变成"键值数组",要么换个存储。字段数上限不是配置问题,是模型问题的报警器。
8. 自测
- 为什么一个业务字符串字段在 mapping 里通常占两个字段位?
- 嵌套对象
{"user":{"name","age"}}会建立几个字段路径?为什么"多存一个对象"会翻倍字段? dynamic: false与dynamic: strict的行为差别是什么?哪一个在生产上更难发现?- 删掉那条带大量动态键的文档,字段数会下降吗?为什么?
flattened类型解决了什么、牺牲了什么?- 什么判据能让你在写入前就判断"这个 map 会炸"?
9. 一句话总结
字段爆炸的本质是"把由数据决定的键名当成 schema":动态 mapping 见到新键就建字段,而 mapping 是索引级元数据、删文档不会回收。后果是硬失败(400 拒绝写入),不是变慢。判据只有一条——键的基数是否可枚举:可枚举就显式 mapping,不可枚举就用 flattened / 键值数组 / 换存储。调大上限只是把炸弹推远,收敛字段数才是修复。