mapping 错一次,代价不是改一行配置,而是新建索引 + 全量 reindex + 切别名。字段类型不可变更,所有设计决策都得在第一次写入之前做完。
下面按"错了会怎样 → 怎么定对 → 怎么安全地改"走:不可变性 → 动态映射 → 显式定义 → 省空间开关 → 嵌套与关联 → 模板与切换 → 分片。
1. 为什么定错就难改
| 操作 | 能否在线做 | 后果 |
|---|---|---|
| 新增字段 | 能,PUT _mapping |
老文档该字段为空,查询按缺失处理 |
| 改字段类型 | 不能 | 报 mapper [x] cannot be changed from type [text] to [keyword] |
改 analyzer |
名义能,实际不能 | 只对新文档生效,老 token 仍是旧切法 |
改 doc_values / null_value |
不能 | 已写 segment 不重写,按你的 8.x 小版本核对 |
改 number_of_shards |
不能 | 只能 _split/_shrink/_rollover 或 reindex |
2. 动态映射的隐患
默认 dynamic: true,第一次见到字段就按 JSON 类型猜:
| JSON 值 | 推断类型 | 隐患 |
|---|---|---|
123 |
long |
超过 2^63 直接拒文档 |
1.23 |
float |
精度约 7 位,金额会失真 |
true |
boolean |
字符串 "true" 不转换,成 text |
"2026-11-09" |
date(date_detection 默认开) |
非标准格式误判或漏判 |
| 其他字符串 | text + keyword 子字段 |
体积双份;数字串永远无法范围查询 |
典型翻车:第一版把 price 写成 "199.00" 定成 text,第二版写数字,报 mapper_parsing_exception,整条文档被拒,只能 reindex。
dynamic 四档:true(自动加字段)、false(忽略新字段,只进 _source 不可搜)、strict(未知字段报错)、runtime(变 runtime field,查询时算)。日志用 false,业务核心用 strict——让错误在写入时暴露。
3. 显式 mapping 该定义什么
PUT /article
{
"settings": { "number_of_shards": 3, "number_of_replicas": 1 },
"mappings": {
"dynamic": "strict",
"properties": {
"title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
"author": { "type": "keyword", "ignore_above": 128 },
"price": { "type": "scaled_float", "scaling_factor": 100 },
"created": { "type": "date", "format": "strict_date_optional_time||epoch_millis" },
"status": { "type": "keyword", "null_value": "unknown" },
"raw": { "type": "object", "enabled": false }
}
}
}
| 参数 | 默认 | 什么时候显式写 |
|---|---|---|
type |
动态推断 | 永远 |
analyzer / search_analyzer |
standard |
中文、代码、邮箱等非自然语言 |
index |
true |
只取回不检索的字段设 false |
doc_values |
true |
不排序不聚合的大字段设 false |
null_value |
无 | 需要空值参与过滤/聚合时 |
ignore_above |
keyword 子字段动态映射时为 256 | 防止聚合桶爆炸 |
format |
strict_date_optional_time |
自定义日期格式必须写死 |
4. 三个省空间的开关
| 开关 | 省掉什么 | 代价 |
|---|---|---|
index: false |
倒排(posting list + 词典条目) | 不可检索;仍可 doc_values 排序聚合、仍可 _source 取回 |
doc_values: false |
列存 | 不能排序、聚合、脚本取值、terms 查询 |
enabled: false(仅 object) |
整个子树的解析与索引 | 子字段全不可查不可聚合,只能读 _source |
_source: false(索引级) |
原文 | 不能 reindex、高亮、update,只剩倒排 |
收益线性叠加:日志里"_source: false + enabled: false"的原始报文常省掉一半以上存储(按字段构成核对)。但 _source: false 等于放弃 reindex——改 mapping 的唯一后路就是 reindex,别把后路一起关。
5. 多字段:一份值,多套索引
| 用法 | 收益 | 开销 |
|---|---|---|
text + keyword |
全文检索与精确聚合并存 | 正文倒排 + 列存双份 |
加 ngram 子字段 |
前缀、子串搜索 | term 数成倍膨胀 |
加 icu 子字段 |
多语言归一化 | 又一份倒排 |
子字段不是免费的第二类型:每个都有独立倒排与 doc_values,4KB 正文挂三个子字段,存储可能接近翻倍(典型量级)。按需求挂,别"先挂上以后可能用"。
6. nested 与 object:跨对象匹配的陷阱
{ "comments": [ { "user": "a", "score": 5 }, { "user": "b", "score": 1 } ] }
object 被扁平化成 comments.user=[a,b]、comments.score=[5,1],于是 user=a AND score=1 假命中——两个条件命中不同子对象。nested 给每个子对象一个隐藏文档,匹配限制在单个子对象内:
| 维度 | object |
nested |
|---|---|---|
| 存储 | 与父文档同一 Lucene 文档 | 每子对象一个隐藏文档(block join) |
| 查询 | 字段级,会跨对象假命中 | 必须包 nested 查询 |
| 聚合 | 普通聚合 | nested + reverse_nested 两跳 |
| 成本 | 低 | 文档数放大,排序/更新更贵 |
GET /article/_search
{ "query": { "nested": { "path": "comments",
"query": { "bool": { "must": [ { "term": { "comments.user": "a" } },
{ "range": { "comments.score": { "gte": 4 } } } ] } },
"inner_hits": {} } } }
漏写 nested 包裹是"配了没生效"的头号原因,查询返回空。nested_objects.limit 默认 10000(单文档内)、nested_fields.limit 默认 50;子对象上千的评论树该反范式成独立索引。
7. join 字段:父子关系的适用面
| 限制 | 说明 |
|---|---|
每索引只能一个 join 字段 |
多关系要在同一字段里声明 |
| 父子必须同分片 | 写入按父 id routing,否则 has_child 查不到 |
| 不能跨索引 | 跨索引关联靠应用层拼 |
| 删父文档留孤儿 | 需 delete_by_query 定期清理 |
| 查询成本高 | has_child/has_parent 走 block join,比反范式慢一档 |
join 适合"父子数量悬殊、更新频繁、需整体一致"的场景(订单-明细);数据量大又只读时反范式更划算。
8. 模板:多索引共用一套 mapping
PUT _component_template/article_mappings
{ "template": { "mappings": { "dynamic": "strict", "properties": {
"title": { "type": "text", "analyzer": "ik_max_word" } } } } }
PUT _index_template/article_template
{ "index_patterns": ["article-*"], "priority": 200,
"composed_of": ["article_mappings", "article_settings"],
"template": { "settings": { "number_of_replicas": 1 } } }
| 概念 | 作用 | 优先级 |
|---|---|---|
| component template | 可复用的 settings/mappings 片段 | 被 composed_of 引用 |
| index template | 匹配 index_patterns 的完整模板 |
priority 高者覆盖同 pattern |
模板自带 template 段 |
索引级覆盖 | 胜过同名 component |
上线前用 POST _index_template/_simulate_index/article-000001 预览最终 mapping,比建完索引才发现分词器是 standard 便宜得多。
9. 别名与 reindex:改 mapping 的唯一安全路径
| 步骤 | 动作 | 校验点 |
|---|---|---|
| 1 双写 | 应用同时写 v1、v2 | 两边 _count 增速一致 |
| 2 建新索引 | 用新 mapping 建 article-v2 |
_simulate_index 通过 |
| 3 reindex | 拉历史数据 | failures 为空、created 对得上 |
| 4 抽样比对 | 关键 query 两边跑 | 命中数与 top10 一致 |
| 5 切别名 | 原子 _aliases |
读写别名都指向 v2 |
| 6 观察后删旧 | 停双写、留一周快照 | 无 mapper_parsing_exception,保留回滚窗口 |
POST _reindex?wait_for_completion=false&slices=auto
{ "source": { "index": "article-v1" },
"dest": { "index": "article-v2", "op_type": "create" } }
op_type: create 让重跑幂等:重复文档报 409 计入 version_conflicts,加 conflicts=proceed 跳过。_reindex 不复制 settings/mappings,目标索引必须先建好。别名切换要一次请求两个动作,否则留下读写分叉:
POST _aliases
{ "actions": [
{ "add": { "index": "article-v2", "alias": "article" } },
{ "remove": { "index": "article-v1", "alias": "article" } } ] }
10. 分片数怎么定
| 数据量 | 分片数 | 说明 |
|---|---|---|
| < 10GB | 1 | ES 8 默认就是 1,别按"节点数"配 |
| 10–50GB | 2–3 | 每分片控制在 10–50GB(典型量级) |
| 时间序列 | 按天/周滚动 | _rollover + is_write_index |
三条硬约束:分片数创建后不可改;堆与分片数正相关(经验值每 GB 堆不超过 20 分片量级);cluster.max_shards_per_node 默认 1000。分片过多的症状是 cluster state 变大、主节点压力高、恢复慢。
11. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
total_fields.limit 超限 |
动态映射把对象键当字段 | 调 limit 只止血,根治靠 dynamic: strict + 扁平化 |
mapper_parsing_exception |
类型冲突:历史 text,新值数字 | 写入端转字符串,或 reindex |
nested 查询返回空 |
没包 nested,或 path 写错 |
补包裹,核对全路径 |
| 聚合报 fielddata 错误 | 在 text 上聚合 |
用 field.keyword 或加子字段 |
| reindex 后数据变少 | 源索引 _source: false |
必须开 _source,或从上游重放 |
| 双写漏数据 | 双写失败无补偿、起点时间取错 | 用 _count 与 _seq_no 增量校验 |
12. 边界
| 场景 | 该选什么 | 理由 |
|---|---|---|
| 字段结构频繁变化 | dynamic: false + 应用层校验 |
避免字段爆炸,写入不被拒 |
| 强类型业务核心 | dynamic: strict |
错误前移到写入 |
| 高基数字段聚合 | keyword + eager_global_ordinals |
提前建全局序数,省首次聚合开销 |
| 需要事务一致性 | 不用 ES 当主库 | 无跨文档事务,reindex 有窗口 |
| 关联查询复杂 | 反范式冗余或外部存储 | join/nested 都有放大成本 |
第一原则不是"类型选对",而是先想清楚哪些字段要检索、哪些要聚合、哪些只取回——这决定 type、index、doc_values,其余都是细节。
13. 自测
- 为什么
PUT _mapping改analyzer返回成功,却等于没改? - 动态映射把
"199.00"定成text后,第二版写数字为什么整条文档被拒? index: false与enabled: false分别省掉什么?后者为什么只能用在 object 上?object类型下user=a AND score=1为什么假命中?nested怎么避免?- 双写 + reindex 流程里哪一步最容易漏数据?怎么校验?
- 为什么 ES 8 的默认
number_of_shards是 1,而"按节点数配分片"是过时做法?