先记住这个答案
嵌入适合一对一或一对少且子数据总是随父数据读写的场景,引用适合一对多、子数据独立访问或增长无界的场景。判断时考虑基数、数据增长边界和访问模式。嵌入减少读取次数但改写整个文档,引用支持按需加载但增加关联查询。
- 一对少且随父文档读,优先嵌入
- 数据量无界增长时,必须用引用
- 读写模式决定存储方式,而非范式
嵌入与引用的机制差异
嵌入是把子文档直接嵌入父文档字段,一次读父文档同时拿到所有子数据,但写入会重写整个父文档,数组无界会导致文档膨胀甚至超过16MB BSON限制。引用将子文档独立存储,通过 _id 或外键关联,支持单个子文档独立更新,但读取需要额外查询或聚合 $lookup。
关键判断:子文档数量是否可控、是否随父文档高频整体读取。一对少量(如用户-地址)可嵌入;一对大量且独立增长(如文章-评论)应引用,因为评论无限增长且常按时间翻页,嵌入会导致每次发表评论重写整篇文章文档。
产品详情页的评论设计
假设一个电商产品详情页:每次显示产品信息时同时要显示最近20条评论。如果评论总数通常不超过几百,且产品下架后评论不再增长,可以选择嵌入,把最近评论数组存在产品文档中。但支持评论数和爆发增长后,单个产品若积累数万条评论,会撑爆16MB限制。实际选择引用:评论独立集合,按 productId 分页查询最近评论。
嵌入式能一次查询返回产品和最近评论,但每发一条新评论必须带产品字段做数组 $push,并触发整个文档重写。引用式则每个评论独立 insert,只写评论文档,代价是查看产品概览时可能要先查评论数量,可以用 $lookup 或冗余计数。权衡后引用更适合可增长的社交数据。
易失效的条件与代价
嵌入最容易失效的边界是数组无界增长。当用户评论或订单条目数量不可控时,即使从“平均不多”起步,一旦出现爆款,单个文档可能超过16MB且频繁写入导致严重碎片。另一个边界是数组需要部分更新(例如修改其中某条评论),嵌入时需要定位到嵌套数组元素,可能被迫读写整个文档。
处理方式是在监测到子文档平均超过一定量(如100个)或单个父文档接近0.5MB时主动切到引用。切换时既要发布新代码支持引用读取,又要写迁移脚本把已有嵌套数据搬到新集合,期间要处理新旧并存,可通过应用层双写或后台迁移并用标志位区分。
容易答错的地方
- 关系复杂就全部引用
- 有人但凡一对多就引用,结果大量单文档小查询。正确的取舍是由具体访问模式驱动,比如博客文章与作者、文章与标签这些高频同屏数据仍应嵌入,减少往返次数。
- 嵌入就一定能抗住读压力
- 有人为减少查询把海量子数据嵌入,结果整文档变大,每次写入和查询都更慢。索引扫描只覆盖部分字段,但16MB限制和重写开销是物理墙,嵌入不能无限制。
面试官还会怎么问?
如果子文档数量未知但很可能一直很小,仍需考虑无界增长吗?
需要,这是硬边界。即使当前基数小,也应显式设计上限(例如用户支付卡最多5张),否则无界增长会击穿嵌入的可行性。设定业务上限,并在写入时禁止超过该上限,才能保住嵌入的好处。
读写比例悬殊时,嵌入选择会变化吗?
会。若子数据几乎从不更新而查询频繁,即使基数略大,嵌入也可能有利,因为读取时一次取回。反之更新频繁则引用更好,避免整文档重写。需要统计真实读写比再做决定。
$lookup 是不是引用时的性能瓶颈?
$lookup 执行的是数据库端左连接,可用但消耗取决于索引和数据量。需关注子集合的索引和批量查询能力。多个 $lookup 不必然超时,但可能增加复杂度和耗时,是否用应用层拆分查询,建议通过实际性能测试判断,而非默认认为 $lookup 不佳。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。