Elasticsearch Mapping 设计:object、flattened、nested 到关系投影
从 Elasticsearch 如何索引 JSON 出发,系统理解 object、flattened、nested、keyword、dynamic strict 与 mapping explosion,并用 Wiki relations 场景完成选型。
很多 Elasticsearch 问题表面上是查询写错了,根源却是 mapping 选错了。
object、flattened 和 nested 都能接收 JSON 对象,但它们表达的查询语义完全不同:
object适合结构稳定的普通对象;flattened适合 key 多、key 不确定、主要做精确过滤的对象;nested适合对象数组,并且多个条件必须命中同一个数组元素;- 数量很大、持续增长、需要独立更新的子记录,通常应该拆成独立文档。
本文先建立 Elasticsearch 的 JSON 索引心智模型,再逐个拆解这些类型,最后用 Wiki 页面中的 relations 关系投影完成一次生产级选型。
先记住一句话
选择 mapping 时,不要问“这个 JSON 看起来是不是嵌套的”,而要问“查询时需要保留什么关系”。
1. Elasticsearch 不是按 JSON 树搜索
写入 Elasticsearch 的 _source 可以保持完整 JSON 层级:
{
"user": {
"name": "Chico",
"address": {
"city": "Beijing"
}
}
}但普通对象在索引内部可以理解为一组点号字段:
user.name = Chico
user.address.city = Beijing_source 的职责是保存原始文档,倒排索引和 doc values 的职责是支持搜索、过滤、排序和聚合。两者不能混为一谈。
这也是理解 object、nested 和 flattened 的起点。
Elasticsearch 没有专门的 array 类型
下面的字符串数组不需要额外声明为 array:
{
"tags": ["RAG", "Elasticsearch", "Voice AI"]
}mapping 只需要:
{
"tags": {
"type": "keyword"
}
}一个字段天然可以有零个、一个或多个同类型值。因此:
{
"relation_target_ids": [
"character:character_xxx",
"scene:scene_001"
]
}本质上只是一个多值 keyword 字段,不需要 nested。
真正需要小心的是对象数组:
{
"variants": [
{
"color": "red",
"size": "S"
},
{
"color": "blue",
"size": "L"
}
]
}这里不仅有多个值,还有“red 与 S 属于同一个 variant”这种元素内关系。
2. 一张选型图
这张图里最容易被忽略的一步是:
数组里只有字符串或数字时,优先使用普通字段;不要因为它是数组就使用
nested。
3. object:固定结构的默认选择
object 是普通 JSON 对象的默认类型。下面两种写法等价:
{
"author": {
"properties": {
"name": {
"type": "keyword"
},
"age": {
"type": "integer"
}
}
}
}{
"author": {
"type": "object",
"properties": {
"name": {
"type": "keyword"
},
"age": {
"type": "integer"
}
}
}
}适合 object 的场景
- 一个文档里只有一个这样的对象;
- 子字段名称稳定;
- 每个子字段需要明确的
keyword、long、date、text等类型; - 即使是对象数组,也不需要保证多个条件来自同一个数组元素。
例如页面作者信息:
{
"author": {
"id": "user_001",
"name": "Chico Gong",
"role": "editor"
}
}mapping:
{
"author": {
"dynamic": "strict",
"properties": {
"id": {
"type": "keyword"
},
"name": {
"type": "keyword"
},
"role": {
"type": "keyword"
}
}
}
}这个结构固定,类型明确,使用 object 最直接。
object 数组的交叉匹配问题
还是这个商品数据:
{
"name": "T-Shirt",
"variants": [
{
"color": "red",
"size": "S"
},
{
"color": "blue",
"size": "L"
}
]
}如果 variants 使用普通 object,索引内部可以理解为:
variants.color = [red, blue]
variants.size = [S, L]原始数据中的组合关系丢失了。下面的查询会命中文档:
{
"query": {
"bool": {
"filter": [
{
"term": {
"variants.color": "red"
}
},
{
"term": {
"variants.size": "L"
}
}
]
}
}
}但真实数据里并不存在 red + L,只有:
red + S
blue + L两个查询条件分别命中了不同数组元素,这类问题通常被称为 cross-object match。
如果业务要求颜色与尺码必须来自同一个 variant,就要使用 nested。
4. nested:保持对象数组的元素边界
nested 是一种特殊的对象类型。它把数组中的每个对象索引为一个隐藏的 Lucene 文档,使同一个 nested 查询中的多个条件只能在同一个元素内匹配。
Mapping
{
"variants": {
"type": "nested",
"dynamic": "strict",
"properties": {
"color": {
"type": "keyword"
},
"size": {
"type": "keyword"
},
"stock": {
"type": "integer"
}
}
}
}查询同一个 variant
{
"query": {
"nested": {
"path": "variants",
"query": {
"bool": {
"filter": [
{
"term": {
"variants.color": "red"
}
},
{
"term": {
"variants.size": "L"
}
}
]
}
}
}
}
}这次不会命中,因为没有任何一个 variant 同时满足:
color = red
size = L用 inner_hits 返回真正命中的元素
{
"_source": ["name"],
"query": {
"nested": {
"path": "variants",
"query": {
"bool": {
"filter": [
{
"term": {
"variants.color": "red"
}
},
{
"range": {
"variants.stock": {
"gte": 10
}
}
}
]
}
},
"inner_hits": {
"_source": [
"variants.color",
"variants.size",
"variants.stock"
]
}
}
}
}根文档告诉你“哪个商品命中”,inner_hits 告诉你“商品里的哪个 variant 命中”。
与 nested 配套的能力包括:
nested query;inner_hits;nested aggregation;reverse_nested aggregation;- nested sorting。
nested 的代价
假设一个根文档里有 100 个 nested 对象,底层会产生:
1 个根 Lucene 文档
+
100 个 nested Lucene 文档
=
101 个 Lucene 文档因此 nested 会增加:
- Lucene 文档数量;
- 索引体积与写入成本;
- 查询、排序和聚合复杂度;
- 大数组整体更新的成本;
- 运维和可视化工具的使用限制。
nested 不是“更高级的 object”,而是为了保留对象数组元素关系而付出的额外成本。
nested 的使用边界
只有在“多个查询条件必须属于同一个数组对象”时才使用 nested。数组里只是字符串 ID,或者对象数量很大且需要独立更新时,都不应该默认使用 nested。
5. flattened:控制动态 key,而不是保存对象关系
普通 object 会为每个叶子字段创建 mapping。遇到日志属性、第三方 metadata、模型标签或插件扩展字段时,key 往往无法预先穷举:
{
"attrs": {
"provider": "minimax",
"model": "speech-2.6-turbo",
"region": "beijing",
"experiment_group": "tts_exp_03",
"customer_defined_key": "value"
}
}如果使用动态 object,这些 key 会逐个进入 mapping:
attrs.provider
attrs.model
attrs.region
attrs.experiment_group
attrs.customer_defined_key当不同租户、设备或插件不断提交新 key 时,mapping 会持续膨胀。
flattened 把整个对象作为一个 mapping 字段处理:
{
"attrs": {
"type": "flattened"
}
}对象中的叶子值仍然可以搜索,但默认按类似 keyword 的字符串语义索引。
查询指定 key
{
"query": {
"term": {
"attrs.provider": "minimax"
}
}
}查询对象中的任意叶子值
{
"query": {
"term": {
"attrs": "beijing"
}
}
}直接查询顶层 flattened 字段,会在该对象的所有叶子值中匹配。
数组值也可以使用
{
"relations": {
"RELATED_CHARACTER": [
"character:character_xxx",
"character:character_yyy"
]
}
}查询:
{
"query": {
"term": {
"relations.RELATED_CHARACTER": "character:character_xxx"
}
}
}这里数组里的每个值都是同一种稳定实体引用,不需要 nested。
flattened 的限制
默认情况下,flattened 叶子值按字符串 keyword 语义处理:
{
"attrs": {
"latency_ms": 428,
"created_at": "2026-08-05T10:00:00+08:00"
}
}不要把它误认为真正的:
latency_ms → long
created_at → date传统 flattened 查询中的数字范围、日期范围和排序仍然是字符串语义。例如字典序中:
"10" < "2"因此以下字段应该提升为显式 typed field:
- 数值范围查询;
- 日期范围查询;
- IP 查询;
- 需要正确数值或日期排序的字段;
- 高频聚合、过滤和排序字段;
- 需要全文分词或高亮的文本字段。
常见生产设计是:
{
"provider": {
"type": "keyword"
},
"latency_ms": {
"type": "integer"
},
"created_at": {
"type": "date"
},
"attrs": {
"type": "flattened"
}
}也就是:
核心查询字段 → 显式 typed mapping
长尾动态 metadata → flattened
完全不查询的原始 JSON → enabled: false版本说明
当前 Elastic 文档已经展示了 flattened 的 typed sub-fields 和 passthrough 能力,可以给少数已知 key 声明 long、date、ip 等类型。但较早的 Elasticsearch 版本并不具备相同能力。
生产系统不要只看“current”文档就直接使用新参数。应先确认集群版本,并用对应版本文档和测试集群验证。为了兼容不同版本,把核心字段提升到根级 typed field 仍然是最稳妥的设计。
6. 四种常见方案对比
| 方案 | Mapping 数量 | 保留对象数组元素关系 | 类型能力 | 典型场景 |
|---|---|---|---|---|
| 普通多值字段 | 一个字段 | 不涉及 | 完整 | 标签、ID 列表、语言列表 |
object | 每个叶子字段独立映射 | 否 | 完整 | 固定结构的单对象 |
flattened | 整个对象通常一个映射 | 否 | 默认 keyword 语义 | 任意属性、动态标签、关系 key → ID 数组 |
nested | 子字段有 mapping,每个元素变隐藏文档 | 是 | 完整 | 商品规格、实体提及、带属性的关系数组 |
| 独立文档 | 每条记录独立 | 天然保留 | 完整 | 弹幕、评论、事件、海量关系边 |
enabled: false | 内部不建 mapping | 不适用 | 不可查询 | 原始模型输出、审计 payload |
7. dynamic: strict 与 mapping explosion
生产索引经常采用:
{
"mappings": {
"dynamic": "strict",
"properties": {
"id": {
"type": "keyword"
}
}
}
}此时写入一个未声明的根字段:
{
"id": "page_001",
"relations": {}
}文档会被拒绝。strict 的语义是:
检测到新字段时抛出异常,必须先显式增加 mapping。
这是一种很有价值的保护:拼错字段名、投影版本不一致、上游偷偷加字段,都不会无声进入索引。
为什么又需要 flattened
严格 schema 与动态业务属性并不矛盾。可以把边界设计成:
根级 schema:dynamic: strict
开放扩展点:显式声明 flattened例如:
{
"mappings": {
"dynamic": "strict",
"properties": {
"id": {
"type": "keyword"
},
"attrs": {
"type": "flattened"
},
"relations": {
"type": "flattened"
}
}
}
}根级字段仍然严格,attrs 和 relations 内部的 key 则可以扩展,且不会为每个 key 创建独立 mapping。
Mapping explosion 是什么
当普通动态对象不断产生新 key 时,mapping 会越来越大。字段、对象层级、multi-fields、field aliases 和 mapped runtime fields 都会占用 mapping 数量。
Elasticsearch 使用 index.mapping.total_fields.limit 防止 mapping 无限增长。这个限制不是容量目标,而是保护线。简单提高上限只能延后问题,不能修复错误的数据模型。
常见治理方式:
- 已知核心字段使用显式 mapping;
- 根级使用
dynamic: strict或dynamic: false; - 任意 key-value 使用
flattened; - 只保存不搜索的数据使用
enabled: false; - 从入口控制 key 数量、长度和命名;
- 把超大、持续增长的子记录拆成独立文档。
flattened 也需要应用层校验
flattened 不会为内部 key 建立独立 mapping,因此 ES 也不会帮你发现这种拼写错误:
RELATED_CHARACTER
RELATED_CHARCTER
RELATED_CHARACTOR如果关系类型是受控词表,应在 Go 代码、Schema Registry 或写入管道中校验:
var allowedRelationTypes = map[string]struct{}{
"RELATED_CHARACTER": {},
"USED_IN_SCENE": {},
"USES_ASSET": {},
"BELONGS_TO_EPISODE": {},
}如果关系类型允许插件扩展,也应限制格式与长度,例如:
^[A-Z][A-Z0-9_]{0,63}$同时避免关系 key 包含点号,因为点号用于 flattened 子路径查询。
8. 周边 mapping 工具
enabled: false:只保存,不搜索
{
"raw_model_output": {
"type": "object",
"enabled": false
}
}写入的 JSON 仍然保留在 _source,但 ES 会跳过内容解析,内部字段不可搜索、不可排序、不可聚合。
适合:
- 原始模型输出;
- 调试快照;
- 第三方原始 payload;
- 只用于回溯和审计的数据。
它与 flattened 的区别是:
flattened → 有限搜索
enabled: false → 完全不搜索dynamic: false:未知字段留在 _source
dynamic: false 下,未知字段不会进入 mapping,也不可搜索,但普通索引中仍可保留在 _source。
它适合允许上游多带字段、但不希望索引失败的场景。与 strict 相比,它更宽松,也更容易掩盖字段拼写错误。
Runtime fields:低频计算,不急着 reindex
Runtime field 在查询时从 _source 或其他字段计算,适合:
- 试验新的字段逻辑;
- 临时修复 mapping;
- 低频衍生字段;
- 在正式 reindex 前验证设计。
代价是查询时计算。高频过滤、排序和聚合字段仍应优先建立正常索引字段。
subobjects: false:点号字段不展开
subobjects: false 解决的是:
metrics.time.min是否被解释为多层对象路径。
它不等于 flattened。前者仍然为每个字段建立明确 mapping,只是不创建中间对象;后者把整个未知对象作为一个字段处理。
Parent-child join:最后再考虑
join 可以在同一个索引里建立父子文档关系,但会引入 routing、global ordinals 和查询开销。
多数搜索系统更适合:
独立文档
+
冗余必要的父级过滤字段只有当父子数量差异极大、无法合理反范式、且确实需要父子查询时,才值得考虑 join。
9. 实战:Wiki relations 应该怎么存
现在有一类 Wiki 页面关系数据。MySQL/Page 需要保留完整展示结构:
{
"relations": {
"RELATED_CHARACTER": [
{
"entity_id": "character_xxx",
"entity_type": "character",
"title": "齐夏"
}
],
"USED_IN_SCENE": [
{
"entity_id": "scene_001",
"entity_type": "scene",
"title": "雨夜对话"
}
]
}
}这个结构适合作为业务事实和页面展示数据,但 ES 不需要复制所有可变字段。
搜索真正需要的是:
关系类型 + 稳定目标引用因此 ES 投影可以简化为:
{
"relations": {
"RELATED_CHARACTER": [
"character:character_xxx"
],
"USED_IN_SCENE": [
"scene:scene_001",
"scene:scene_002"
],
"USES_ASSET": [
"asset:asset_001"
]
},
"relation_target_ids": [
"character:character_xxx",
"scene:scene_001",
"scene:scene_002",
"asset:asset_001"
]
}为什么只保存稳定引用
关系里的 title 不是稳定事实:
- 人物可能改名;
- 页面标题可能调整;
- 多语言环境中标题不唯一;
- 同一个实体可能有别名。
如果把 title 冗余到每个关系中,就要处理级联更新和过期数据。ES 关系投影只保存稳定 ID,需要展示时再读取目标实体。
这里的:
character:character_xxx严格说是带类型前缀的 entity reference,而不只是裸 ID。继续使用 relation_target_ids 没问题,但团队需要统一命名含义;新系统也可以命名为 relation_target_refs。
推荐 mapping
{
"mappings": {
"dynamic": "strict",
"properties": {
"id": {
"type": "keyword"
},
"title": {
"type": "text",
"analyzer": "cjk"
},
"relations": {
"type": "flattened",
"depth_limit": 4
},
"relation_target_ids": {
"type": "keyword"
},
"attrs": {
"type": "flattened"
}
}
}
}为什么选择 flattened:
- 关系类型 key 可能持续增加;
- 每个 key 下只是同类型的稳定字符串引用;
- 查询主要是精确过滤;
- 不需要保持对象内部多个属性的组合关系;
- 不希望每增加一种关系都修改 mapping。
后续新增:
{
"relations": {
"REFERENCES": [
"asset:a1"
],
"GENERATED_BY": [
"task:t1"
],
"BELONGS_TO_EPISODE": [
"episode:e1"
]
}
}不需要新增 ES mapping 字段。
查询指定关系类型
查“RELATED_CHARACTER 指向齐夏”的页面:
{
"query": {
"term": {
"relations.RELATED_CHARACTER": "character:character_xxx"
}
}
}同时满足人物和场次
{
"query": {
"bool": {
"filter": [
{
"term": {
"relations.RELATED_CHARACTER": "character:character_xxx"
}
},
{
"term": {
"relations.USED_IN_SCENE": "scene:scene_012"
}
}
]
}
}
}这两个条件可以来自不同关系 key,因为业务问题本来就是:
页面关联了人物 A
并且
页面用于场次 B不要求它们属于同一个关系对象,所以不需要 nested。
多个人物任意一个命中
{
"query": {
"terms": {
"relations.RELATED_CHARACTER": [
"character:character_xxx",
"character:character_yyy"
]
}
}
}不区分关系类型
从技术能力上说,可以直接查询顶层 flattened 字段:
{
"query": {
"term": {
"relations": "character:character_xxx"
}
}
}这会搜索 relations 内所有叶子值。
因此,relation_target_ids 并不是完成“不区分关系类型查询”的硬性要求。
为什么仍然可以保留 relation_target_ids
保留它的价值不是弥补 flattened 无法查询,而是建立明确、稳定的搜索契约:
{
"query": {
"term": {
"relation_target_ids": "character:character_xxx"
}
}
}适合保留的情况:
- 反向关系过滤是高频 API;
- 不希望查询层依赖
relations的内部存储方式; - 以后
relations可能混入来源、置信度或其他非 ID 值; - 希望字段名明确表达“所有关系目标”;
- 需要与旧的
out_links查询能力兼容。
对当前 Wiki 系统,我倾向于保留它。它是一个有意设计的反范式投影字段,而不是无意义重复。
10. 为什么这里不用 nested
如果 ES 保存的是下面这种对象数组:
{
"relations": [
{
"relation_type": "RELATED_CHARACTER",
"target_id": "character_xxx",
"target_type": "character",
"source": "llm",
"confidence": 0.95
},
{
"relation_type": "USED_IN_SCENE",
"target_id": "scene_001",
"target_type": "scene",
"source": "manual",
"confidence": 1.0
}
]
}并且查询要求:
relation_type = RELATED_CHARACTER
AND target_id = character_xxx
AND source = llm
AND confidence >= 0.9这些条件必须属于同一条关系记录,此时才应该使用 nested。
当前结构已经把关系类型放在外层 key,数组值又只有稳定字符串引用:
{
"RELATED_CHARACTER": [
"character:character_xxx"
]
}不存在需要维持的对象属性组合,使用 nested 不会增加查询能力,只会增加复杂度。
11. 什么时候升级成独立关系文档
当关系从“页面搜索投影”变成一等业务实体,并出现以下需求时,应考虑独立关系表或关系索引:
- 每条关系有独立 ID;
- 有来源、置信度、状态、创建时间、审核人;
- 关系需要单独新增、修改、删除;
- 单个页面包含大量关系;
- 需要按关系边做统计;
- 需要大量反向查询;
- 需要图遍历、多跳分析或关系版本管理。
独立关系文档可以设计为:
{
"relation_id": "rel_001",
"source_ref": "page:page_001",
"relation_type": "RELATED_CHARACTER",
"target_ref": "character:character_xxx",
"source": "llm",
"confidence": 0.95,
"status": "active",
"created_at": "2026-08-05T10:00:00Z"
}mapping:
{
"relation_id": {
"type": "keyword"
},
"source_ref": {
"type": "keyword"
},
"relation_type": {
"type": "keyword"
},
"target_ref": {
"type": "keyword"
},
"source": {
"type": "keyword"
},
"confidence": {
"type": "float"
},
"status": {
"type": "keyword"
},
"created_at": {
"type": "date"
}
}页面索引仍可保留少量常用关系投影,用于低延迟过滤;关系索引负责完整关系生命周期。这种“双层模型”比把无限关系塞进一个 nested 数组更可控。
12. 在严格索引里上线新字段
当前索引是:
{
"dynamic": "strict"
}而 mapping 里没有 relations。因此应用代码先上线并直接写入新字段,会导致整个文档被拒绝。
发布顺序应该是:
1. 修改仓库 mapping
{
"relations": {
"type": "flattened",
"depth_limit": 4
},
"relation_target_ids": {
"type": "keyword"
}
}这保证未来创建 _v2、_v3 索引时具备正确 mapping。
2. 更新当前物理索引
新增字段可以使用 Update Mapping API:
PUT kb_gateway_wiki_v1/_mapping
{
"properties": {
"relations": {
"type": "flattened",
"depth_limit": 4
},
"relation_target_ids": {
"type": "keyword"
}
}
}修改仓库里的 JSON 文件,不等于已经修改运行中的 ES 集群。两处都要处理。
3. 部署投影代码
投影器开始输出:
{
"relations": {
"RELATED_CHARACTER": [
"character:character_xxx"
]
},
"relation_target_ids": [
"character:character_xxx"
]
}4. 回填历史文档
新增 mapping 不会让旧文档自动产生新字段。需要重新投影或执行可控的回填任务。
5. 类型变更要走新索引
如果字段已经被创建成 object,不能直接原地改成 flattened 或 nested。标准流程是:
创建 kb_gateway_wiki_v2
→ 使用新 mapping
→ reindex 或重新投影
→ 校验文档数与查询结果
→ alias 从 v1 切到 v2
→ 延迟删除 v1这也是业务代码只访问稳定 alias、物理索引使用 _v1、_v2 的主要价值。
13. Go 投影结构
业务模型与搜索模型不要共用同一个结构体。
MySQL / Page 模型
type RelationRef struct {
EntityID string `json:"entity_id"`
EntityType string `json:"entity_type"`
Title string `json:"title"`
}
type Page struct {
Relations map[string][]RelationRef `json:"relations"`
}它保留完整展示信息。
ES 文档模型
type WikiDocument struct {
Relations map[string][]string `json:"relations,omitempty"`
RelationTargetIDs []string `json:"relation_target_ids,omitempty"`
}它只保留搜索需要的稳定引用。
投影函数
func ProjectRelations(
input map[string][]RelationRef,
) (map[string][]string, []string, error) {
relations := make(map[string][]string, len(input))
targetSet := make(map[string]struct{})
for relationType, refs := range input {
if err := validateRelationType(relationType); err != nil {
return nil, nil, err
}
values := make([]string, 0, len(refs))
perTypeSet := make(map[string]struct{}, len(refs))
for _, ref := range refs {
if ref.EntityID == "" || ref.EntityType == "" {
continue
}
target := ref.EntityType + ":" + ref.EntityID
if _, exists := perTypeSet[target]; exists {
continue
}
perTypeSet[target] = struct{}{}
targetSet[target] = struct{}{}
values = append(values, target)
}
if len(values) > 0 {
sort.Strings(values)
relations[relationType] = values
}
}
targets := make([]string, 0, len(targetSet))
for target := range targetSet {
targets = append(targets, target)
}
sort.Strings(targets)
return relations, targets, nil
}排序不是搜索所必需的,但有助于:
- 生成稳定投影哈希;
- 减少无意义的文档更新;
- 让测试结果可重复;
- 方便 diff 和问题排查。
投影哈希必须包含规范化后的 relations,否则关系变化可能不会触发 ES 更新。
14. 应该补哪些测试
Mapping 测试
验证:
dynamic: strict生效;relations是flattened;relation_target_ids是keyword;- 未声明的根字段会被拒绝;
- 新关系 key 可以正常写入;
- 关系 key 不会创建新的 mapping 字段。
投影测试
至少覆盖:
- 空 relations;
- 一个关系类型、一个目标;
- 多关系类型;
- 重复目标去重;
- 空 ID 或空类型;
- 非法关系 key;
- title 变化不会改变稳定目标引用;
- 输出顺序稳定;
- 投影哈希随关系变化而变化。
查询测试
至少验证:
- 指定关系类型精确查询;
- 不区分关系类型查询;
- 多目标
terms查询; - 人物与场次同时过滤;
- 不存在目标时不命中;
- 回填前后文档数量一致。
发布测试
检查:
mapping 文件
当前物理索引 mapping
写入投影
搜索 _source 白名单
查询参数
历史数据回填
alias 指向
监控与告警任何一项漏掉,都可能出现“代码看起来完成了,但线上查不到”的状态。
15. 常见反模式
反模式一:看到 JSON 数组就用 nested
{
"tags": ["RAG", "ES"]
}这是普通 keyword 数组,不是 nested。
反模式二:所有 metadata 都用 object + dynamic true
短期写入方便,长期容易 mapping explosion。未知 key 应先判断是否适合 flattened。
反模式三:把所有字段都塞进 flattened
这样会失去数值、日期、IP、全文检索和高亮等完整能力。核心字段仍应显式建模。
反模式四:为了展示方便,把可变 title 当关系事实
关系应保存稳定引用,展示名称由目标实体提供。
反模式五:把几万条弹幕 nested 到一集文档
弹幕、评论、事件等高数量子记录应该独立成文档,并冗余 drama_id、episode_id、second 等过滤字段。
反模式六:只修改 mapping 文件,不修改在线索引
仓库配置、运行中的物理索引和历史数据是三个不同层面,必须分别处理。
反模式七:提高 total fields limit 代替数据建模
提高限制可能暂时消除报错,但会把 mapping 体积、集群状态和查询开销问题推迟到更难处理的阶段。
16. 最终选择口诀
字符串、数字或 ID 数组
→ 普通 keyword / long / date 等多值字段
固定结构的单个对象
→ object
动态 key-value,主要做精确匹配
→ flattened
对象数组,多个条件必须命中同一个元素
→ nested
只保存原始 JSON,不搜索
→ object + enabled: false
子记录很多、持续增长、独立更新
→ 独立文档 / 独立索引
父子差异极大且无法合理冗余
→ 最后再考虑 join对本文的 Wiki relations 场景,结论是:
MySQL:
relations = 关系类型 → RelationRef 对象数组
作为完整事实和展示数据
Elasticsearch:
relations = 关系类型 → 稳定引用数组
类型使用 flattened
Elasticsearch:
relation_target_ids = 所有目标引用的去重并集
类型使用 keyword,作为稳定的跨关系查询契约这个方案既保留了关系类型查询,又避免了 nested 成本和动态 mapping 膨胀,同时给未来的关系模型升级留出了边界。
参考资料
- Elastic:Object field type
- Elastic:Arrays
- Elastic:Nested field type
- Elastic:Flattened field type
- Elastic:dynamic mapping parameter
- Elastic:Mapping limit settings
- Elastic:enabled mapping parameter
- Elastic:Update mapping API examples
- Elastic:Join field type
- Elastic 8.19:Flattened field type
讨论
继续讨论这篇笔记
有问题、补充案例或不同观点,可以通过 GitHub Discussions 继续交流。