嵌套与父子关联查询:nested、join 字段与性能取舍

系统讲解 Elasticsearch 处理关联数据的三种方式:对象数组的扁平化陷阱、nested 类型与 nested 查询、join 字段的父子文档模型与 has_child/has_parent 查询,并给出三者在查询能力、写入成本与性能上的取舍依据。

订单有多个商品行、文章有多个评论、商品有多个规格——关系型数据库里用外键表达的一对多,到了 Elasticsearch 就没有了 JOIN。默认的 object 类型会把嵌套数组「拍平」,导致 颜色是红色 且 尺码是 XL 这种条件匹配到「红色 L 码」和「蓝色 XL 码」的文档。要正确处理这类数据,只有两条路:nested 把子对象作为独立文档索引并保持边界,join 字段把父子文档放在同一分片内用关系连接。两者的能力、成本与适用场景差异很大,选错会带来数倍的性能代价。

1. 关联数据的三种建模方式

一句话总结: 关联数据在 Elasticsearch 里有扁平对象、nested、join 三种建模方式,选择取决于「是否需要跨子对象保持边界」与「子文档是否需要独立更新」。

1.1 扁平对象与它的陷阱

默认的 object 类型不保留数组元素的边界:

PUT /products/_doc/1
{
  "name": "T 恤",
  "variants": [
    { "color": "red", "size": "L" },
    { "color": "blue", "size": "XL" }
  ]
}

写入时实际存储的字段是 variants.color: [red, blue] 与 variants.size: [L, XL],两个数组的对应关系丢失了。此时查询「color 是 red 且 size 是 XL」会错误命中——因为两个条件各自都能在数组里找到值。这是最经典的数据建模事故。

1.2 nested 类型的解法

把 variants 声明为 nested,每个数组元素被索引成一个隐藏的独立文档,保留自己的字段组合。查询时用 nested 查询指定路径,条件在同一个子文档内求值,边界问题消失。

代价是:每个 nested 文档都是 Lucene 层面的独立文档,一个含 100 个 nested 对象的父文档会产生 101 个内部文档,写入放大约百倍,查询也需要额外的 join 操作。

1.3 join 字段的解法

join 字段在同一索引内定义父子关系,父子文档都是真实文档,共享同一分片。父文档可以有多个子文档,查询时用 has_child 与 has_parent 在两侧之间跳转。它的优势是子文档可以独立更新,不需要重写整个父文档;劣势是父子必须在同一分片,且关系查询的性能开销比 nested 更大。

1.4 三条路的定位

方式子对象独立性写入成本查询成本适用场景
object无最低最低不需要保持边界的多值字段
nested整体重写高中子对象少且更新不频繁
join独立更新低高子文档多且频繁变更

2. nested 类型与写入

一句话总结: nested 把每个数组元素索引成独立隐藏文档,保证字段组合的边界,代价是写入放大与整父重写。

2.1 映射定义

PUT /products
{
  "mappings": {
    "properties": {
      "name": { "type": "text" },
      "variants": {
        "type": "nested",
        "properties": {
          "color": { "type": "keyword" },
          "size": { "type": "keyword" },
          "stock": { "type": "integer" }
        }
      }
    }
  }
}

一旦把字段定义为 nested,就不能用普通查询直接查它的子字段,必须走 nested 查询,否则会报错或匹配不到。

2.2 写入与更新

nested 文档随父文档一起写入,不支持单独更新某个 nested 对象。要修改一个子对象,必须重写整个父文档(或用 Painless 脚本做局部替换,但底层仍是整父重写):

POST /products/_update/1
{
  "script": {
    "source": "ctx._source.variants[0].stock = params.stock",
    "params": { "stock": 0 }
  }
}

对子对象数量大的文档,这意味着每次改动都要重新索引全部子对象,是 nested 最主要的成本来源。

2.3 限制嵌套深度

Elasticsearch 通过 index.mapping.nested_objects.limit 限制单个文档的 nested 对象总数,默认 10000。超过会直接拒绝写入。深层嵌套(nested 里再套 nested)开销是乘性的,通常建议只嵌套一层,深层结构改用父子关系或拆索引。

2.4 数量与体积的权衡

nested 的收益来自「子对象边界必须保持」这个需求。如果一个父文档只有两三个子对象且几乎不更新,nested 的开销可以接受;如果有上千个子对象且频繁更新,nested 会让每次写入都变得非常昂贵。

3. nested 查询与聚合

一句话总结: nested 查询通过 path 定位子文档,支持 score_mode 控制子文档评分如何汇总到父文档,聚合则需 nested 加 reverse_nested 才能回到父级维度。

3.1 基本 nested 查询

GET /products/_search
{
  "query": {
    "nested": {
      "path": "variants",
      "query": {
        "bool": {
          "filter": [
            { "term": { "variants.color": "red" } },
            { "term": { "variants.size": "XL" } }
          ]
        }
      },
      "score_mode": "max"
    }
  }
}

两个 term 都在同一个 nested 子文档内求值,边界被正确保持。score_mode 取值包括 avg、max、min、sum、none,决定子文档分数如何影响父文档的最终得分。

3.2 inner_hits

想看到具体命中了哪个子对象,用 inner_hits:

GET /products/_search
{
  "query": {
    "nested": {
      "path": "variants",
      "query": { "term": { "variants.color": "red" } },
      "inner_hits": { "size": 3 }
    }
  }
}

响应里每个父文档会附带命中的子文档列表,这是展示「订单里哪几个商品匹配」的标准做法。

3.3 nested 聚合

按子字段聚合时要先进入 nested 上下文:

GET /products/_search
{
  "size": 0,
  "aggs": {
    "variants": {
      "nested": { "path": "variants" },
      "aggs": {
        "colors": { "terms": { "field": "variants.color" } }
      }
    }
  }
}

3.4 reverse_nested 回到父级

如果要在 nested 聚合内部统计「父文档数量」(而不是子对象数量),用 reverse_nested:

GET /products/_search
{
  "size": 0,
  "aggs": {
    "variants": {
      "nested": { "path": "variants" },
      "aggs": {
        "colors": {
          "terms": { "field": "variants.color" },
          "aggs": { "products": { "reverse_nested": {} } }
        }
      }
    }
  }
}

reverse_nested 的 doc_count 是包含该颜色变体的商品数量。忘了用它,得到的就是变体数量而非商品数量——这是 nested 聚合最常见的错误。

4. join 字段与父子文档

一句话总结: join 字段在同一分片内建立父子关系,父子都是真实文档,支持子文档独立更新,但关系查询比 nested 更慢。

4.1 映射定义

PUT /blog
{
  "mappings": {
    "properties": {
      "title": { "type": "text" },
      "body": { "type": "text" },
      "comment": { "type": "text" },
      "relation": { "type": "join", "relations": { "post": "comment" } }
    }
  }
}

relations 定义关系名,post 是父、comment 是子。一个 join 字段可以定义多条关系(如 question 对 answer、answer 对 vote),但每个索引只能有一个 join 字段。

4.2 写入父子文档

父文档指定关系名:

PUT /blog/_doc/post-1
{
  "title": "Elasticsearch 建模",
  "relation": { "name": "post" }
}

子文档用 parent 指定所属父文档的 ID:

PUT /blog/_doc/comment-1?routing=post-1
{
  "comment": "写得很清楚",
  "relation": { "name": "comment", "parent": "post-1" }
}

routing 是必须的:父子必须在同一分片,而路由值必须与父文档一致,否则会写到别的分片,导致关系失效。

4.3 独立更新与删除

子文档可以单独更新,不影响父文档:

POST /blog/_update/comment-1?routing=post-1
{
  "doc": { "comment": "补充一点" }
}

这正是 join 相对 nested 的核心优势。删除子文档同理,只需带对 routing。

4.4 分片与路由约束

父子文档必须共分片,这是 join 的硬约束。它带来两个后果:一是必须显式指定 routing,容易出错;二是分片分布不均时,某些分片的父子数据可能远多于其他分片。此外,如果父文档很多,分片数量必须在索引创建时规划好——join 字段的父子关系依赖分片内的文档 ID 映射,reindex 到不同分片数会破坏关系。

5. has_child 与 has_parent

一句话总结: has_child 用子文档条件筛父文档,has_parent 用父文档条件筛子文档,两者都可通过 inner_hits 返回匹配的另一侧文档。

5.1 has_child 查询

「找出有评论包含关键词的文章」:

GET /blog/_search
{
  "query": {
    "has_child": {
      "type": "comment",
      "query": { "match": { "comment": "建模" } },
      "score_mode": "avg",
      "inner_hits": {}
    }
  }
}

score_mode 控制子文档得分如何汇总为父文档得分。inner_hits 返回命中的子文档。

5.2 has_parent 查询

「找出所属文章标题含 Elasticsearch 的评论」:

GET /blog/_search
{
  "query": {
    "has_parent": {
      "parent_type": "post",
      "query": { "match": { "title": "Elasticsearch" } },
      "score": true
    }
  }
}

score: true 让父文档的得分传递给子文档,默认 false(子文档得分为常量 1)。

5.3 parent_id 查询

已知父文档 ID,直接查它的全部子文档:

GET /blog/_search
{
  "query": {
    "parent_id": { "type": "comment", "id": "post-1" }
  }
}

这是最高效的关系查询,它直接利用路由定位分片,不需要全局 join。

5.4 关系查询的性能特征

has_child 与 has_parent 需要在分片内做全局 join:先把一侧文档全部匹配出来,再与另一侧关联。对大规模数据集,这比 nested 的 join 更昂贵,因为它涉及真实文档而非同一文档内的隐藏文档。ES 内部用全局序号(global ordinals)优化,但首次构建也有开销。

6. 性能取舍与选型

一句话总结: 子对象少且整体更新选 nested,子文档多且频繁独立变更选 join,能用扁平对象解决就不要用这两者。

6.1 三者的性能对比

维度objectnestedjoin
写入放大无按子对象数放大无
子文档独立更新不支持不支持支持
关系查询开销无中高
分片约束无无必须共分片
最大子对象数无默认 10000无硬限制

6.2 选型判断树

  • 不需要保持子对象边界(如标签列表):用 keyword 数组或普通 object。
  • 子对象少(十几个以内)、查询频繁、更新少:用 nested。
  • 子文档多、需要独立更新或独立删除:用 join。
  • 关系复杂、需要多表 join:考虑在应用层做,或把数据冗余成宽表。

6.3 冗余优先

很多所谓的「关联查询」需求,其实可以通过冗余字段解决。订单文档里直接存一份商品的名称与分类,就不需要 join 商品索引。代价是数据冗余与一致性维护,但查询性能最好。Elasticsearch 的设计哲学本就是「为查询而冗余」,而不是「为范式而拆分」。

6.4 量级与压测

nested 与 join 可以在同一索引内共存,但复杂度会显著上升,除非有明确收益否则不建议叠加。选型还要考虑数据量级:十万级文档下三种方式的差异不明显,到亿级才会拉开数量级的差距,务必用真实量级压测验证。

7. 生产实践与常见坑

一句话总结: nested 的坑集中在边界与更新,join 的坑集中在路由与分片,两者都需要在建模阶段就规划好。

7.1 nested 的坑

  • 忘记 nested 查询:直接用 variants.color 查询会报错,因为 nested 字段必须通过 path 访问。
  • nested 聚合忘了 reverse_nested:统计出的是子对象数而非父文档数。
  • 子对象过多:超过 nested_objects.limit 直接写入失败。
  • 频繁整父重写:更新一个子字段要重写整个文档,写入吞吐骤降。
  • nested 数组顺序:nested 内部保留顺序,但排序与聚合不依赖顺序。

7.2 join 的坑

  • 忘记 routing:子文档写入不带 routing 会落到错误分片,关系查询找不到它,且不会报错。
  • reindex 破坏关系:改分片数会让父子落到不同分片,关系全部失效。
  • has_child 的 score_mode 默认值:默认是 none,父文档得分不反映子文档相关性,容易得到「排序看起来不对」的结果。
  • 父子文档数量悬殊:一个父文档配十万个子文档时,has_parent 会非常慢。
  • join 字段唯一性:每个索引只能有一个 join 字段,设计时要一次规划到位。

7.3 监控与验证

关系查询的慢日志尤其重要。关注 has_child 与 has_parent 的查询耗时,如果单次超过几百毫秒,说明数据模型需要重新审视。同时用 _validate/query 确认查询确实走了关系路径而不是被优化成了全表扫描。

7.4 迁移策略

从 object 迁移到 nested 需要重建索引并重写数据;从 nested 迁移到 join 更复杂,需要拆分文档并重建父子关系。建模阶段的一次性决策质量,直接决定了后期的迁移成本,建议在预发环境用真实数据量做压测来验证选型。

8. 总结

环节要点
扁平陷阱object 数组丢失元素边界,条件会跨元素误匹配
nested 本质每个子对象索引成隐藏文档,保住边界
nested 成本写入按子对象数放大,更新需整父重写
nested 查询必须走 nested path,聚合需 reverse_nested 回父级
join 本质父子真实文档共分片,子文档可独立更新
join 约束必须指定 routing,reindex 不得改分片数
关系查询has_child、has_parent、parent_id 三种形态
选型原则冗余优先,其次 nested,最后 join

关联数据的建模没有银弹,本质是在「查询灵活度、写入成本、维护复杂度」之间找平衡。Elasticsearch 提供的 nested 与 join 都是重武器,用之前先问一句:这个需求真的需要关联查询,还是可以通过冗余字段绕开?回答了这个问题,选型就不再困难。接下来把视角转向查询侧——当结果集很大时,分页方式的选择会直接决定系统能否稳定运行。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「elasticsearch」更多文章

  1. 可搜索快照与冻结层:把冷数据放进对象存储还能查
  2. 分页与深度分页:from/size、search_after、PIT 与 scroll
  3. 磁盘水位与容量规划:三档水位、分片规划与扩容决策