第六阶段 51 · join 父子关系与关系建模(ES 里怎么做 JOIN)
2026/8/4 18:39:40 网站建设 项目流程

51 · join 父子关系与关系建模(ES 里怎么做 JOIN)

阶段:第六阶段 / 进阶专题
ES:join字段 +has_child/has_parent、反范式、terms lookup、enrich | PostgreSQL:JOIN、表继承、物化视图


1. 概念:ES 没有真正的跨索引 JOIN

这是从 PG 转过来最需要转变的观念:ES 不擅长 JOIN,它鼓励“用存储换查询速度”
处理关系有四条路线,按推荐度:

方案关系落在哪适合代价
反范式冗余(首选)写入时拍平进一篇文档关系简单、读多写少数据冗余、更新要同步
nested(第 25 篇)同一篇文档内的数组对象主文档+有限明细,一起读写子项更新要重索引整篇
join(父子)同一索引里的独立父/子文档子项需独立频繁更新、一对多量大慢、吃内存、不能跨索引
terms lookup / enrich查询时/写入时做轻量关联用维表补字段有限场景

一句话:能反范式就别 join。join/nested 是不得已才用。


2. PostgreSQL 对照

-- PG:跨表 JOIN 很自然SELECTo.*,c.nameFROMorders oJOINcustomers cONc.id=o.customer_id;-- PG 表继承(父子)CREATETABLEbase_event(...);CREATETABLEclick_event()INHERITS(base_event);

ES 对应:

  • 跨表 JOIN →反范式(把customer_name写进 order 文档)或 enrich。
  • 表继承/父子 →join字段(同一索引里父子文档)。

3.join字段(父子关系)

3.1 mapping:声明父子关系

PUT orders_idx { "mappings": { "properties": { "my_join": { "type": "join", "relations": { "order": "item" } // order 是父,item 是子 }, "order_no": { "type": "keyword" }, "sku": { "type": "keyword" }, "qty": { "type": "integer" } } } }

3.2 写入父、写入子(子必须带 routing = 父ID,保证同分片)

# 父文档 PUT orders_idx/_doc/order-1 { "order_no": "SO-1", "my_join": "order" } # 子文档:routing 指向父,parent 指定父ID PUT orders_idx/_doc/item-1?routing=order-1 { "sku": "A", "qty": 10, "my_join": { "name": "item", "parent": "order-1" } }

3.3 查询:has_child / has_parent

# 找“包含 sku=A 子项”的父订单 GET orders_idx/_search { "query": { "has_child": { "type": "item", "query": { "term": { "sku": "A" } }, "inner_hits": {} // 可选:带出命中的子文档 } } } # 反过来:找某父订单下的所有子项 GET orders_idx/_search { "query": { "has_parent": { "parent_type": "order", "query": { "term": { "order_no": "SO-1" } } } } }

4. Spring Boot 实现

@ComponentpublicclassDoc51Join{@AutowiredprivateElasticsearchClientelasticsearchClient;/** has_child:找包含指定 sku 子项的父订单 */publicList<Map<String,Object>>parentsHavingSku(StringindexName,Stringsku)throwsIOException{SearchResponse<Map>resp=elasticsearchClient.search(s->s.index(indexName).query(q->q.hasChild(hc->hc.type("item").query(cq->cq.term(t->t.field("sku").value(sku))).scoreMode(ChildScoreMode.None)// 不需要子分影响父分.innerHits(ih->ih))),// 带出命中的子文档Map.class);returnresp.hits().hits().stream().map(Hit::source).filter(Objects::nonNull).collect(Collectors.toList());}/** 写子文档:必须带 routing = 父ID */publicvoidindexChild(StringindexName,StringchildId,StringparentId,Map<String,Object>childDoc)throwsIOException{childDoc.put("my_join",Map.of("name","item","parent",parentId));elasticsearchClient.index(i->i.index(indexName).id(childId).routing(parentId)// ★ 关键:与父同分片.document(childDoc));}}

import:co.elastic.clients.elasticsearch._types.query_dsl.ChildScoreMode
子文档写入/查询/删除都要带routing=父ID,否则找不到或落错分片。


5. 反范式与轻量关联(更推荐的日常做法)

5.1 反范式(首选)

写入时就把维表字段冗余进主文档,查询零 join:

// order 文档直接带上客户名,而不是只存 customer_id 再去 join{"order_no":"SO-1","customer_id":"C9","customer_name":"Acme","amount":1000}

维表变了要同步更新冗余字段(用_update_by_query,第 32 篇)。读多写少时这笔账很划算。

5.2 terms lookup(用一篇文档的值去过滤)

GET orders_idx/_search { "query": { "terms": { "customer_id": { "index": "vip_customers", "id": "batch-1", "path": "ids" // 取那篇文档的 ids 数组做 IN } } } }

5.3 enrich processor(写入时自动补字段)

在 ingest pipeline 里配 enrich policy,把维表字段在写入时自动拼进文档——相当于 ETL 的 lookup join,查询时就是普通字段。


6. 坑与最佳实践

  1. join父子必须同索引、同分片:靠routing=父ID保证;不能跨索引 join。
  2. 父子有性能代价has_child/has_parent比普通查询慢、更吃内存,量大时评估。
  3. 一个索引里 join 关系尽量单一:多级/多种父子会让维护和性能雪上加霜。
  4. 优先反范式:ES 是查询引擎不是关系库,能冗余拍平就别上 join/nested。
  5. nested vs join 选型:明细跟主文档一起读写 →nested;子项要独立高频更新 →join
  6. 更新冗余字段用_update_by_query:维表变更后批量刷新被冗余的字段。

相关

  • 文档内数组对象:25-nested-嵌套对象与查询.md
  • 批量刷新冗余字段:32-update_by_query-delete_by_query.md
  • 索引重建(改关系模型):33-index-mapping-管理.md

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询