嵌套对象与父子关系:一对多怎么存

22 May 2026 – wusfe · 4 min read

嵌套对象与父子关系:一对多怎么存

订单表里一条订单有多个商品项。用户表里一个用户有多条收货地址。博客系统里一篇文章有几十条评论。

这些"一对多"关系在 ES 里怎么存?你可能会把商品列表直接写成 JSON 数组塞进去——

{
  "order_id": "001",
  "items": [
    { "name": "iPhone", "price": 8999, "qty": 1 },
    { "name": "AirPods", "price": 1299, "qty": 2 }
  ]
}

然后你写个查询:找出包含 iPhone 且单价大于 5000 的订单。这条数据本应命中——iPhone 单价 8999,大于 5000。但 ES 返回的结果让你怀疑人生:数据在,搜不出来;或者搜出来一条你不该匹配到的。

根因:ES 没有"数组内对象独立"这个概念。把数组扁平化后,所有字段被摊成平行的多值列表,关联关系全部丢失。这篇就来拆解三种解法:object 数组的坑、nested 的用法、join 的适用场景。

Object 数组的摊平问题

ES 内部对 object 数组的处理方式是"扁平化"——把所有字段拉成平行的多值列表:

// 你存的
{
  "items": [
    { "name": "iPhone",  "price": 8999 },
    { "name": "AirPods", "price": 1299 }
  ]
}

ES 内部存储等价于:

{
  "items.name":  ["iPhone", "AirPods"],
  "items.price": [8999, 1299]
}

iPhone 和 1299 之间的关联断了。于是这条查询:

GET /orders/_search
{
  "query": {
    "bool": {
      "must": [
        { "term": { "items.name": "iPhone" } },
        { "range": { "items.price": { "lt": 1000 } } }
      ]
    }
  }
}

实际含义变成了:"存在一个 name 叫 iPhone 的 item,且存在一个 price 小于 1000 的 item"——但不要求这两个条件是同一条 item。所以 iPhone(8999) 的订单会因为 AirPods(1299<1000 不满足) 而命不中,或反过来命中了不该命中的。

结论:object 数组只适合不需要跨字段关联查询的场景。

Nested:独立索引每一个子对象

nested 类型把数组中的每个对象当作独立的隐藏文档来索引,在子对象之间维护关联关系。

定义 Mapping

PUT /orders
{
  "mappings": {
    "properties": {
      "order_id": { "type": "keyword" },
      "items": {
        "type": "nested",
        "properties": {
          "name":  { "type": "keyword" },
          "price": { "type": "double" },
          "qty":   { "type": "integer" }
        }
      }
    }
  }
}

查询

GET /orders/_search
{
  "query": {
    "nested": {
      "path": "items",
      "query": {
        "bool": {
          "must": [
            { "term":  { "items.name": "iPhone" } },
            { "range": { "items.price": { "gt": 5000 } } }
          ]
        }
      }
    }
  }
}

现在 iPhone 和 8999 的关联被保持了——只有当前 item 同时满足两个条件才会命中。

Nested 聚合

找出每个商品的平均销售数量:

GET /orders/_search
{
  "size": 0,
  "aggs": {
    "by_item": {
      "nested": { "path": "items" },
      "aggs": {
        "item_name": {
          "terms": { "field": "items.name" },
          "aggs": {
            "avg_qty": { "avg": { "field": "items.qty" } }
          }
        }
      }
    }
  }
}

Nested 的性能代价

每更新一个 nested 对象,ES 需要重写整个父文档及其所有 nested 子文档。如果你的 nested 数组有 100 个元素,改其中 1 个就意味着 101 次内部文档重写。

判断标准:子对象数通常 < 1000 且不频繁独立更新 → nested;否则 → join。

Join:真正的父子关系

join 类型把父子文档存为完全独立的文档,通过 _parent 关联。

定义 Mapping

PUT /blog
{
  "mappings": {
    "properties": {
      "my_join_field": {
        "type": "join",
        "relations": {
          "article": "comment"
        }
      },
      "title":   { "type": "text" },
      "content": { "type": "text" }
    }
  }
}

写入父子文档

// 父文档(文章)
POST /blog/_doc/1
{ "title": "ES Join 详解", "content": "...", "my_join_field": "article" }

// 子文档(评论)
POST /blog/_doc/c1?routing=1
{ "content": "好文!", "my_join_field": { "name": "comment", "parent": "1" } }

POST /blog/_doc/c2?routing=1
{ "content": "学到了", "my_join_field": { "name": "comment", "parent": "1" } }

注意 routing=1所有子文档必须和父文档在同个分片上,所以写入子文档时必须指定 routing 为父文档 ID。

查询

// has_child:找出有评论包含"好文"的文章
GET /blog/_search
{
  "query": {
    "has_child": {
      "type": "comment",
      "query": { "match": { "content": "好文" } }
    }
  }
}

// has_parent:找出属于标题包含"Join"的文章的所有评论
GET /blog/_search
{
  "query": {
    "has_parent": {
      "parent_type": "article",
      "query": { "match": { "title": "Join" } }
    }
  }
}

Join 的代价

  • 子文档和父文档必须在同一分片,数据倾斜风险
  • has_child 查询比 nested 慢得多
  • 文档独立意味着更新子文档不需要重写父文档——这是 join 相比 nested 最大的优势

三种方案对比

特性 Object 数组 Nested Join
子对象独立性 ❌ 扁平化 ✅ 独立索引 ✅ 独立文档
跨字段关联查询 ❌ 不准
更新子对象成本 高(重写全文档) 低(只更新一个)
查询速度
子对象数量上限 无硬限制 < 10,000(官方建议) 数百万
聚合准确性 不准 准(需分别聚合)
适用场景 无需跨字段查询的简单数组 订单商品、多规格 SKU 文章评论、用户地址

选型决策树

一对多关系 → 需要跨字段查询吗?
  ├── 不需要 → Object 数组(够用且最快)
  └── 需要
      ├── 子对象少(< 1000)且不频繁独立更新 → Nested
      └── 子对象多 或 频繁独立更新 → Join

生产实践建议

  1. 默认用 object 数组——大多数一对多场景不需要跨字段查询,别一上来就 nested
  2. 能 nested 就别 join——性能好一截,也更直观
  3. 子文档数量敏感——nested 一个文档里有 10000 个子对象时,更新开销巨大,考虑 join 或拆分索引
  4. join 的 routing 是关键——忘了设置 routing,父子文档分散到不同分片,查不出来

总结

ES 不是关系数据库,一对多建模要走和 MySQL 完全不同的思路。一句话记住:

  • 不要跨字段查 → Object 数组
  • 要跨字段查,子对象少 → Nested
  • 要跨字段查,子对象多或频繁改 → Join