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. 坑与最佳实践
join父子必须同索引、同分片:靠routing=父ID保证;不能跨索引 join。- 父子有性能代价:
has_child/has_parent比普通查询慢、更吃内存,量大时评估。 - 一个索引里 join 关系尽量单一:多级/多种父子会让维护和性能雪上加霜。
- 优先反范式:ES 是查询引擎不是关系库,能冗余拍平就别上 join/nested。
- nested vs join 选型:明细跟主文档一起读写 →
nested;子项要独立高频更新 →join。 - 更新冗余字段用
_update_by_query:维表变更后批量刷新被冗余的字段。
相关
- 文档内数组对象:
25-nested-嵌套对象与查询.md - 批量刷新冗余字段:
32-update_by_query-delete_by_query.md - 索引重建(改关系模型):
33-index-mapping-管理.md