先记住这个答案
嵌入数组适合一对多中“多”侧数量有界且每次读取都随父文档一起获取的场景,如订单项;引用适合无界增长或子文档需要单独查询、分页、更新的场景,如商品评论。真正的边界是数组是否“无界”:当数组可能长期超过1000个元素、父文档大小预计超过4MB,或更新子项导致明显写放大时,就应拆分为子集合引用,用$lookup或多次查询组装数据。
- 无界数组必须拆为引用
- 有界且随父查询则嵌入
- 阈值看文档大小与写放大
数组与引用的访问代价差异
数组嵌入把一对多的子节点直接放入父文档字段,读取时一次find就能返回全部子数据,且能在单文档内原子更新子项。代价是每次修改任意子项,MongoDB都必须重写整个父文档,若数组不断增加,写入和存储开销随文档大小线性上升。
子集合引用把子项存为独立文档,父文档中只保存子文档的ObjectId数组。这样子项可独立创建、更新、删除,不会重写父文档,但读取“父+所有子”需要先查父、再按ObjectId批量查子,或用聚合$lookup。若子项本身也需要按条件分页,引用还能直接对子集合建索引。
订单项嵌入与商品评论引用
一个电商系统有两个典型一对多:订单项和商品评论。订单项平均2-10条,下单后不再变,每次查询订单详情必须连同商品快照一起返回,因此把订单项嵌入order.items,用一条读获取全部数据,更新订单时不冲突,完全符合“有界且同读”的判断。
商品评论无边界,用户随时新增,且产品页只需显示最近20条评论,需要按时间倒序分页。若把评论嵌入商品文档,每次发评论都重写整个商品文档,高并发下产生严重的写放大和文档大小逼近16MB风险。改为独立reviews集合,文档存productId和createdAt,查询带{productId, createdAt}复合索引,用limit取最近评论,成本低且可控。
拆分阈值的可操作判断
没有固定条数阈值,但有两个经验线:一是数组元素预计会长期超过1000个,二是嵌入后父文档预计超过4MB(留出写放大余地)或更新频率很高。只要满足其中一条,就应拆为子集合,否则单文档16MB限制迟早成为硬顶。
拆分后若产品首页必须一次返回商品和评论摘要,可以接受$lookup的延迟;若延迟敏感,可按Extended Reference模式在商品文档冗余最近几条评论,但需在写评论时同步更新冗余字段。另一条避开方式是Bucket Pattern,把评论按时间分桶存储,但评论天然独立查询,引用是最直接方案。
容易答错的地方
- 认为不超过16MB就能嵌入
- 16MB是文档硬限制,但数组增长到几MB时每次更新都重写整个文档,写放大和锁等待已经让吞吐下降,远早于16MB就该拆分。判断应以更新代价和写放大为准,而非只看硬限制。
- 默认引用更灵活就处处用
- 引用会失去单文档原子性和读取局部性,需要额外查询并可能引入事务。对于订单项这类有界且总随父读取的数据,嵌入避免
$lookup,性能更好。设计应从访问模式出发,而非一味追求通用。
面试官还会怎么问?
数组元素数量达到多少必须拆?
没有绝对数值,但可按是否超过1000或文档预计超过4MB来判断;更关键是看更新频率。如果每写一个子项就要更新父文档且写入频繁,那么即使数组只有几百个,也可能需要提前拆分,因为写放大与数组大小成正比。
嵌入和引用在一致性上有什么差别?
嵌入数组依赖单文档原子性,对子项和父项的更新要么全成功要么全失败,天然一致;引用则父和子是分离文档,跨文档更新需要事务兜底。若无事务环境,需接受最终一致或减少引用场景。
嵌入数组能否通过Bucket Pattern解决无界增长?
Bucket Pattern是把多个小文档合并进一个桶文档,适合时间序列迁移或高频写入,但一对多的子项若天然独立查询,桶反而增加复杂度。无界时直接拆为引用,按业务维度分页更简单。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。