ES 父子文档:为什么我弃用了 Nested

做简历搜索的都知道,一份简历不是一个扁平文档——它是「基本信息 + 多段工作经历 + 多段教育经历 + 技能」的结构化聚合。放到 ES 里怎么建模,直接决定了检索的灵活度和写入的代价。

我先用 Nested,后来全部迁到了 Parent-Join(父子文档)。这篇文章说说为什么。

一、先看两种方案

Nested:把子文档内嵌成数组

1
2
3
4
5
6
7
{
  "name": "张三",
  "work_experiences": [
    { "company": "A公司", "title": "后端工程师", "start": "2018-01" },
    { "company": "B公司", "title": "架构师", "start": "2020-01" }
  ]
}

Nested 的每个子对象会被当成独立文档做索引(Lucene 层面),所以能正确支持"多条件匹配同一段经历"这种查询。这是它比普通 object 数组强的地方——object 数组做不到「A 公司 AND 架构师」必须是同一条记录。

Parent-Join:父子分文档存储

1
2
3
4
// 父文档:简历
{ "id": "resume-123", "name": "张三", "join_field": "resume" }
// 子文档:一段工作经历
{ "id": "exp-1", "resume_id": "resume-123", "company": "A公司", "join_field": { "name": "work_experience", "parent": "resume-123" } }

父子文档把「简历」和「经历」拆成两类独立文档,通过 join_field 关联。查询时用 has_child / has_parent

二、Nested 的致命问题:写放大

简历场景和普通商品/订单场景最大的不同是:单段经历会频繁被单独修改

比如候选人在 A 公司的一段经历时间写错了,只需要改这一段。但在 Nested 模型下:

改任何一个子项 = 整份简历文档重新索引(reindex)——包含所有工作经历、教育经历、技能标签。

一份 10 年经验的简历,可能有 5-6 段经历 + 4 段教育 + 技能,文档体积轻松到几十 KB。高频修改场景下:

  1. 写入放大严重:改 1 个字段,全文档重索引
  2. 版本冲突频繁:两个并发修改互相覆盖(乐观锁版本号对不上)
  3. 索引压力大:segment merge 频繁,磁盘 IO 和 CPU 双高

我们简历库百万级,每天大量的"候选人更新一段经历"操作,Nested 的写放大直接让写入集群吃紧。

三、Parent-Join 的取舍

迁到 Parent-Join 后,单段履历就是独立子文档,改一段只 reindex 那一个子文档,写放大问题消失,并发修改互不影响。

但代价也很明确:

  1. 关联查询有开销has_child 查询要在父子文档之间做关联,比 Nested 的同文档查询慢(需要内存缓存子文档的 join 关系)
  2. 父文档依赖:查询子文档要先找到父文档,join 关系保存在内存中,父文档频繁变更会刷新这个关系
  3. 别名限制:父子文档必须用同一个索引别名,不能跨索引 join

我的取舍原则:写多读少的场景(简历高频更新、低频深检索)→ Parent-Join;读多写少、子文档整体替换的场景(订单、商品规格)→ Nested 或纯扁平。

四、落地要点

  1. 控制子文档规模:单父文档的子文档数别太多(我们单份简历子文档控制在 20 以内),太多时 join 内存占用和查询延迟都会上升
  2. 热数据预热:高频简历的 join 关系会驻留内存,配合 index.join.terms_order 优化
  3. 只索引必要字段:子文档不索引大文本(如工作描述全文),检索后按需取
  4. 回收站机制:删除简历用软删除,避免父文档删除导致子文档孤儿

五、结果

迁移后:

  • 单段经历更新只 reindex 1 个子文档,写入压力降了一个数量级
  • 百万级人才库多条件组合召回稳定在 50ms 以内
  • 并发更新简历不再互相踩版本号

总结

Nested 不是错,是场景错配。简历这种「父稳定、子高频变更」的模型,天生适合父子文档。选择存储模型之前,先算一笔账:你的写放大系数是多少?子文档单独更新的频率有多高? 这两个问题想清楚,建模不会跑偏。

如果你也在做简历/档案/台账类检索,欢迎留言讨论。