4. 内嵌与引用决策

在 MongoDB 中,文档之间的关系表达只有两条基本路径:把关联数据放进同一个文档(内嵌,Embedding),或者把关联数据存到另一个集合、只在本文档中保存对方的标识(引用,Referencing)。MongoDB 没有 JOIN 那样的强制外键约束,也没有关系型数据库的范式约束,因此“关系怎么放”完全由建模者决定。这个决定一旦落到生产数据上,后期迁移成本很高,所以它不是一个风格问题,而是一个需要按访问模式、数据增长和一致性要求来推导的工程决策。

两种方式在存储与读写上的本质差异

内嵌把子数据作为父文档的一个字段(对象或数组)写入同一份 BSON。读取父文档时,子数据随父文档一次性返回,一次磁盘读取即可拿到完整视图;写入时,父文档和子数据在同一个文档级原子操作中完成,天然满足“要么全成功、要么全失败”。代价是文档体积会随子数据增长,而 BSON 文档有 16MB 上限,且数组无限增长会导致文档不断被重写、移动,影响写性能。

引用则把子数据放在独立集合,父文档只保存子文档的 _id(或业务键)。父文档体积稳定,子数据可以独立增长、独立索引、独立分片。代价是读取完整视图需要两次或多次查询,应用层要自己做“拼接”;跨文档写入不再有原子性保障,需要靠事务或设计上的幂等来兜底。

内嵌 vs 引用:同一业务关系的两种落盘形态 内嵌 Embedding orders 集合 · 一个订单文档 _id: ObjectId("...a1") orderNo: "SO20240612-001" buyer: { userId, nick } items: [ {sku, qty, price}, ... ] ✓ 一次查询返回完整订单 ✓ 单文档原子写,无跨表一致性问题 ✓ 子数据与父数据同生命周期 ✗ 文档随 items 增长,逼近 16MB ✗ 数组频繁追加导致文档重写/移动 ✗ 子数据无法独立分片与独立索引 引用 Referencing orders 集合 · 订单文档 _id: ObjectId("...a1") orderNo: "SO20240612-001" itemIds: [ ObjectId("...b1"), ... ] 应用层二次查询 order_items 集合 · 明细文档 _id: ObjectId("...b1") orderId, sku, qty, price ✓ 父文档体积稳定,可长期增长 ✓ 子集合可独立索引、分片、归档 ✗ 读完整视图需 2 次以上查询 ✗ 跨文档写需事务或幂等补偿

三条决策准则:访问频率、数据增长、一致性

准则一:访问频率与访问模式。如果子数据几乎总是和父数据一起被读取,且很少单独查询,内嵌能显著减少查询次数。例如订单详情页要展示订单头加全部明细,内嵌一次读取即可。反之,如果子数据会被独立访问、独立分页、独立统计,引用更合适。例如“商品”被大量订单引用,商品信息需要单独维护和检索,就应放在独立集合,订单里只存 sku 或 productId。

准则二:数据增长边界。这是最容易被忽视、也最容易出事故的一条。判断标准是:子数据的数量是否有明确上界。一个订单的明细行数通常在几十行以内,有界,适合内嵌;一个用户的登录日志、一个商品的评论,数量无上界,会持续增长,必须引用。经验阈值可以这样把握:子数组预期长期稳定在几百条以内、且单条体积小,内嵌风险可控;若可能达到数千条以上,或单条子文档本身较大,应改为引用。

准则三:一致性要求。如果父与子必须在同一次写入中保持一致,内嵌的单文档原子性最省心。例如“账户余额 + 流水”若放在同一文档,扣款与记账天然原子。若跨文档,则需要多文档事务(MongoDB 4.0+ 副本集支持),事务有性能开销和超时限制,且分片集群中跨分片事务成本更高。因此,强一致且高频写入的关系,优先内嵌;弱一致、可最终一致的关系,可用引用加补偿。

一个可执行的决策流程

把三条准则串成一个判断顺序,可以避免凭感觉选型。下面给出一个可直接套用的决策表,字段和判断条件都对应真实建模时需要回答的问题。

判断问题 若答案为“是” 若答案为“否”
子数据是否总与父数据一起读取? 倾向内嵌 倾向引用
子数据数量是否有明确上界(如 ≤ 几百)? 可内嵌 必须引用
父与子是否要求同一次写入原子生效? 优先内嵌 可用引用 + 事务/补偿
子数据是否需要独立索引、分页或统计? 引用 可内嵌
子数据是否被多个父文档共享? 引用(避免多份副本不一致) 可内嵌

实际建模中,这三条准则常常互相冲突。例如“订单明细”访问频率高、要求原子写,但明细行数有界,因此内嵌胜出;“商品评论”访问频率中等、数量无界、可最终一致,因此引用胜出。冲突时的优先级建议是:数据增长边界 > 一致性要求 > 访问频率。因为增长失控会导致文档超限和写放大,是硬性故障;一致性可以通过事务弥补;访问频率只影响性能,可以通过索引和缓存优化。

混合模式:不是二选一

真实系统里最常见的做法是混合。以订单为例:订单头内嵌买家昵称、收货地址快照(这些是下单时刻的固定值,不需要跟随用户资料变化),明细内嵌在订单文档中(有界、原子、总是一起读);而商品主数据、用户账户放在独立集合,订单中只引用 productId、userId。这样既保证了订单读取的一次性,又避免了商品和用户数据被复制到海量订单中造成更新风暴。

快照 vs 引用:需要“下单时的价格、地址”这类历史事实,应内嵌快照;需要“当前的商品库存、用户等级”这类实时状态,应引用。判断依据是:这个值在业务上是否允许随时间变化。允许变化且需要最新值 → 引用;不允许变化、必须冻结 → 内嵌快照。
常见误区:把“用户的所有订单 ID”内嵌到用户文档。订单数量无上界,用户文档会持续膨胀并频繁重写,最终触及 16MB 上限。正确做法是订单文档引用 userId,需要查某用户订单时对 orders.userId 建索引后查询。

落地时的验证动作

选定方案后,不要只停留在纸面。可以用一段脚本估算文档体积与增长趋势:取一条典型父文档,用 Object.bsonSize() 或 db.collection.stats() 观察平均文档大小,再按业务增长率推算一年后的体积。若内嵌数组按每月新增 50 条、单条 200 字节计算,一年约增加 120KB,仍在安全区;若每月新增 5000 条,一年将超过 12MB,就必须改为引用。同时用 explain("executionStats") 对比两种方案在典型查询下的 totalDocsExamined 与响应时间,用数据确认访问频率准则的判断是否成立。

归根结底,内嵌与引用的选择不是审美偏好,而是对“数据怎么被读、怎么增长、怎么保持一致”这三个问题的回答。把这三问写进建模评审清单,每次建模都逐条回答,就能把关系表达方式从经验判断变成可复现的工程决策。