ES 父子文档:为什么我弃用了 Nested
做简历搜索的都知道,一份简历不是一个扁平文档——它是「基本信息 + 多段工作经历 + 多段教育经历 + 技能」的结构化聚合。放到 ES 里怎么建模,直接决定了检索的灵活度和写入的代价。
我先用 Nested,后来全部迁到了 Parent-Join(父子文档)。这篇文章说说为什么。
一、先看两种方案
Nested:把子文档内嵌成数组
|
|
Nested 的每个子对象会被当成独立文档做索引(Lucene 层面),所以能正确支持"多条件匹配同一段经历"这种查询。这是它比普通 object 数组强的地方——object 数组做不到「A 公司 AND 架构师」必须是同一条记录。
Parent-Join:父子分文档存储
|
|
父子文档把「简历」和「经历」拆成两类独立文档,通过 join_field 关联。查询时用 has_child / has_parent。
二、Nested 的致命问题:写放大
简历场景和普通商品/订单场景最大的不同是:单段经历会频繁被单独修改。
比如候选人在 A 公司的一段经历时间写错了,只需要改这一段。但在 Nested 模型下:
改任何一个子项 = 整份简历文档重新索引(reindex)——包含所有工作经历、教育经历、技能标签。
一份 10 年经验的简历,可能有 5-6 段经历 + 4 段教育 + 技能,文档体积轻松到几十 KB。高频修改场景下:
- 写入放大严重:改 1 个字段,全文档重索引
- 版本冲突频繁:两个并发修改互相覆盖(乐观锁版本号对不上)
- 索引压力大:segment merge 频繁,磁盘 IO 和 CPU 双高
我们简历库百万级,每天大量的"候选人更新一段经历"操作,Nested 的写放大直接让写入集群吃紧。
三、Parent-Join 的取舍
迁到 Parent-Join 后,单段履历就是独立子文档,改一段只 reindex 那一个子文档,写放大问题消失,并发修改互不影响。
但代价也很明确:
- 关联查询有开销:
has_child查询要在父子文档之间做关联,比 Nested 的同文档查询慢(需要内存缓存子文档的 join 关系) - 父文档依赖:查询子文档要先找到父文档,join 关系保存在内存中,父文档频繁变更会刷新这个关系
- 别名限制:父子文档必须用同一个索引别名,不能跨索引 join
我的取舍原则:写多读少的场景(简历高频更新、低频深检索)→ Parent-Join;读多写少、子文档整体替换的场景(订单、商品规格)→ Nested 或纯扁平。
四、落地要点
- 控制子文档规模:单父文档的子文档数别太多(我们单份简历子文档控制在 20 以内),太多时 join 内存占用和查询延迟都会上升
- 热数据预热:高频简历的 join 关系会驻留内存,配合
index.join.terms_order优化 - 只索引必要字段:子文档不索引大文本(如工作描述全文),检索后按需取
- 回收站机制:删除简历用软删除,避免父文档删除导致子文档孤儿
五、结果
迁移后:
- 单段经历更新只 reindex 1 个子文档,写入压力降了一个数量级
- 百万级人才库多条件组合召回稳定在 50ms 以内
- 并发更新简历不再互相踩版本号
总结
Nested 不是错,是场景错配。简历这种「父稳定、子高频变更」的模型,天生适合父子文档。选择存储模型之前,先算一笔账:你的写放大系数是多少?子文档单独更新的频率有多高? 这两个问题想清楚,建模不会跑偏。
如果你也在做简历/档案/台账类检索,欢迎留言讨论。