7.1 对象数组的陷阱:nested 与父子文档


文档摘要

7.1 对象数组的陷阱:nested 与父子文档 本节摘要:默认的 object 类型会把数组里的对象"拍平"存储——字段各自成数组,对象内部的关系丢失,跨字段匹配开始张冠李戴。nested 类型把每个子对象存成独立的隐藏小文档保住关系;更新频繁的场景则用 join 字段做父子文档,子文档可单独增删改。本节从翻车现场讲到选型推演,关系建模的三个选项一次配齐。 一个翻车现场 拍平是怎么发生的 工单带多条回复,每条回复有作者与正文。最直觉的建模: 需求来了:找"陈舟说过加急"的工单。查询用 bool 组合两个条件——作者等于陈舟、正文含加急: 结果这条工单命中了。

7.1 对象数组的陷阱:nested 与父子文档

本节摘要:默认的 object 类型会把数组里的对象"拍平"存储——字段各自成数组,对象内部的关系丢失,跨字段匹配开始张冠李戴。nested 类型把每个子对象存成独立的隐藏小文档保住关系;更新频繁的场景则用 join 字段做父子文档,子文档可单独增删改。本节从翻车现场讲到选型推演,关系建模的三个选项一次配齐。

一个翻车现场

拍平是怎么发生的

工单带多条回复,每条回复有作者与正文。最直觉的建模:

PUT tickets_flat/_doc/1 { "title": "退款进度咨询", "replies": [ { "author": "陈舟", "text": "已加急处理" }, { "author": "林一", "text": "查到退款单号" } ] }

需求来了:找"陈舟说过加急"的工单。查询用 bool 组合两个条件——作者等于陈舟、正文含加急:

GET tickets_flat/_search { "query": { "bool": { "must": [ { "match": { "replies.text": "加急" } }, { "term": { "replies.author": "陈舟" } } ] } } }

结果这条工单命中了。可"加急"是陈舟说的、"查单号"才是林一说的——查询的语义是"同一条回复里",返回的却是"工单里任何一条回复含加急,且任何一条回复作者是陈舟"。拍平的机理:object 数组存储时展开成字段数组,上面的文档实际变成 author 数组(陈舟、林一)与 text 数组(已加急处理、查到退款单号)——谁说了什么这层关系,在索引结构里不存在了。

拍平与保全的对比

拍平与保全的对比

nested 的用法

映射把 replies 声明为 nested,查询套一层 nested 上下文:

PUT tickets_nested { "mappings": { "properties": { "title": { "type": "text" }, "replies": { "type": "nested", "properties": { "author": { "type": "keyword" }, "text": { "type": "text" } } } } } }
GET tickets_nested/_search { "query": { "nested": { "path": "replies", "query": { "bool": { "must": [ { "match": { "replies.text": "加急" } }, { "term": { "replies.author": "陈舟" } } ] } }, "inner_hits": { "_source": ["replies.author", "replies.text"] } } } }

nested 查询指明路径,bool 在每条回复的小文档内部求值——"陈舟说过加急"语义精确落地。inner_hits 顺手把匹配上的那条回复带回来,前端能高亮"命中是哪一条",产品体验直接受益。聚合同样要包 nested 上下文,否则桶里看不到隐藏文档的词条。

join:更新频繁时的第三选项

工单的回复像流水一样来,每次新回复都重写整条工单(nested 的宿命)不划算。join 字段把关系显式化:

PUT tickets_join { "mappings": { "properties": { "ticket_relation": { "type": "join", "relations": { "ticket": "reply" } }, "title": { "type": "text" }, "author": { "type": "keyword" }, "text": { "type": "text" } } } }
PUT tickets_join/_doc/reply-9001?routing=doc-1001 { "ticket_relation": { "name": "reply", "parent": "doc-1001" }, "author": "陈舟", "text": "已加急处理" }

父文档 ticket 与子文档 reply 分属两条记录,靠 routing 指定同分片(路由值用父 id),子文档来去自由——新增回复只写一条子文档,父文档纹丝不动。查询侧用 has_child(按回复挑工单)与 has_parent(按工单挑回复)。代价是查询更慢、连接更贵:join 的场景是把"更新频繁"换"查询变贵",与 nested 的"写入即重写父文档"互为镜像。

一次选型推演

背景:商品检索,每个商品三到八个变体(颜色加尺寸),变体的库存与价格每天全量刷新数次;主查询是"红色 XL 且库存大于零"。推演:object 先排除——跨变体误匹配(红色 M 与蓝色 XL 的库存会串)不可接受。nested 与 join 之间看更新频率:变体数据由上游定时任务全量刷新,天然是"整条商品文档重写"的形态,nested 的重写代价与刷新模型吻合;join 的优势(子文档零散增删)用不上,还搭上查询变贵。结论:nested。结果:上线后查询准确,刷新任务按商品粒度批量重写,吞吐达标。变式:若变体价格改为实时联动(每次成交即更新),join 或"变体独立索引加应用层聚合"就值得重新评估——选型跟着写入模型走,不跟着口味走。

⚠️ 常见坑:nested 层级套 nested 层级,索引膨胀与查询连接成本双高。实践里 nested 一层足够;更深的层级几乎总有更平的建模(拆索引、预计算字段)。

💡 关键直觉:object 是"合住",nested 是"隔壁单间",join 是"分开住但同小区"。查询要求越贴近"同一条子记录内",住得越分开;写入越频繁,越倾向分开住。

三选项对号入座

选项 关系保全 写入代价 查询代价 适用
object 最低 最低 只按单字段过滤
nested 整文档重写 中(连接隐藏文档) 数组小、整条刷新
join 父子 子文档独立增删 高(跨文档连接) 子记录高频变化

考核要点补充

  • inner_hits 的价值:命中时指出"是哪一条子记录匹配",前端能定位到具体回复,产品体验直接受益。
  • routing 一致性:join 子文档的写入与父查询都依赖父 id 路由,漏带 routing 写入直接失败,这是新手第一坑。

关系形态讲完,下一节让文档带上经纬度出生:地理数据的检索与聚合。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U