嵌套对象与父子关系:一对多怎么存
嵌套对象与父子关系:一对多怎么存
订单表里一条订单有多个商品项。用户表里一个用户有多条收货地址。博客系统里一篇文章有几十条评论。
这些"一对多"关系在 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
生产实践建议
- 默认用 object 数组——大多数一对多场景不需要跨字段查询,别一上来就 nested
- 能 nested 就别 join——性能好一截,也更直观
- 子文档数量敏感——nested 一个文档里有 10000 个子对象时,更新开销巨大,考虑 join 或拆分索引
- join 的 routing 是关键——忘了设置 routing,父子文档分散到不同分片,查不出来
总结
ES 不是关系数据库,一对多建模要走和 MySQL 完全不同的思路。一句话记住:
- 不要跨字段查 → Object 数组
- 要跨字段查,子对象少 → Nested
- 要跨字段查,子对象多或频繁改 → Join