订单有多个商品行、文章有多个评论、商品有多个规格——关系型数据库里用外键表达的一对多,到了 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 三者的性能对比
| 维度 | object | nested | join |
|---|---|---|---|
| 写入放大 | 无 | 按子对象数放大 | 无 |
| 子文档独立更新 | 不支持 | 不支持 | 支持 |
| 关系查询开销 | 无 | 中 | 高 |
| 分片约束 | 无 | 无 | 必须共分片 |
| 最大子对象数 | 无 | 默认 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 都是重武器,用之前先问一句:这个需求真的需要关联查询,还是可以通过冗余字段绕开?回答了这个问题,选型就不再困难。接下来把视角转向查询侧——当结果集很大时,分页方式的选择会直接决定系统能否稳定运行。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。