4. 内嵌与引用决策
在 MongoDB 中,文档之间的关系表达只有两条基本路径:把关联数据放进同一个文档(内嵌,Embedding),或者把关联数据存到另一个集合、只在本文档中保存对方的标识(引用,Referencing)。MongoDB 没有 JOIN 那样的强制外键约束,也没有关系型数据库的范式约束,因此“关系怎么放”完全由建模者决定。这个决定一旦落到生产数据上,后期迁移成本很高,所以它不是一个风格问题,而是一个需要按访问模式、数据增长和一致性要求来推导的工程决策。
两种方式在存储与读写上的本质差异
内嵌把子数据作为父文档的一个字段(对象或数组)写入同一份 BSON。读取父文档时,子数据随父文档一次性返回,一次磁盘读取即可拿到完整视图;写入时,父文档和子数据在同一个文档级原子操作中完成,天然满足“要么全成功、要么全失败”。代价是文档体积会随子数据增长,而 BSON 文档有 16MB 上限,且数组无限增长会导致文档不断被重写、移动,影响写性能。
引用则把子数据放在独立集合,父文档只保存子文档的 _id(或业务键)。父文档体积稳定,子数据可以独立增长、独立索引、独立分片。代价是读取完整视图需要两次或多次查询,应用层要自己做“拼接”;跨文档写入不再有原子性保障,需要靠事务或设计上的幂等来兜底。
三条决策准则:访问频率、数据增长、一致性
准则一:访问频率与访问模式。如果子数据几乎总是和父数据一起被读取,且很少单独查询,内嵌能显著减少查询次数。例如订单详情页要展示订单头加全部明细,内嵌一次读取即可。反之,如果子数据会被独立访问、独立分页、独立统计,引用更合适。例如“商品”被大量订单引用,商品信息需要单独维护和检索,就应放在独立集合,订单里只存 sku 或 productId。
准则二:数据增长边界。这是最容易被忽视、也最容易出事故的一条。判断标准是:子数据的数量是否有明确上界。一个订单的明细行数通常在几十行以内,有界,适合内嵌;一个用户的登录日志、一个商品的评论,数量无上界,会持续增长,必须引用。经验阈值可以这样把握:子数组预期长期稳定在几百条以内、且单条体积小,内嵌风险可控;若可能达到数千条以上,或单条子文档本身较大,应改为引用。
准则三:一致性要求。如果父与子必须在同一次写入中保持一致,内嵌的单文档原子性最省心。例如“账户余额 + 流水”若放在同一文档,扣款与记账天然原子。若跨文档,则需要多文档事务(MongoDB 4.0+ 副本集支持),事务有性能开销和超时限制,且分片集群中跨分片事务成本更高。因此,强一致且高频写入的关系,优先内嵌;弱一致、可最终一致的关系,可用引用加补偿。
一个可执行的决策流程
把三条准则串成一个判断顺序,可以避免凭感觉选型。下面给出一个可直接套用的决策表,字段和判断条件都对应真实建模时需要回答的问题。
| 判断问题 | 若答案为“是” | 若答案为“否” |
|---|---|---|
| 子数据是否总与父数据一起读取? | 倾向内嵌 | 倾向引用 |
| 子数据数量是否有明确上界(如 ≤ 几百)? | 可内嵌 | 必须引用 |
| 父与子是否要求同一次写入原子生效? | 优先内嵌 | 可用引用 + 事务/补偿 |
| 子数据是否需要独立索引、分页或统计? | 引用 | 可内嵌 |
| 子数据是否被多个父文档共享? | 引用(避免多份副本不一致) | 可内嵌 |
实际建模中,这三条准则常常互相冲突。例如“订单明细”访问频率高、要求原子写,但明细行数有界,因此内嵌胜出;“商品评论”访问频率中等、数量无界、可最终一致,因此引用胜出。冲突时的优先级建议是:数据增长边界 > 一致性要求 > 访问频率。因为增长失控会导致文档超限和写放大,是硬性故障;一致性可以通过事务弥补;访问频率只影响性能,可以通过索引和缓存优化。
混合模式:不是二选一
真实系统里最常见的做法是混合。以订单为例:订单头内嵌买家昵称、收货地址快照(这些是下单时刻的固定值,不需要跟随用户资料变化),明细内嵌在订单文档中(有界、原子、总是一起读);而商品主数据、用户账户放在独立集合,订单中只引用 productId、userId。这样既保证了订单读取的一次性,又避免了商品和用户数据被复制到海量订单中造成更新风暴。
userId,需要查某用户订单时对 orders.userId 建索引后查询。
落地时的验证动作
选定方案后,不要只停留在纸面。可以用一段脚本估算文档体积与增长趋势:取一条典型父文档,用 Object.bsonSize() 或 db.collection.stats() 观察平均文档大小,再按业务增长率推算一年后的体积。若内嵌数组按每月新增 50 条、单条 200 字节计算,一年约增加 120KB,仍在安全区;若每月新增 5000 条,一年将超过 12MB,就必须改为引用。同时用 explain("executionStats") 对比两种方案在典型查询下的 totalDocsExamined 与响应时间,用数据确认访问频率准则的判断是否成立。
归根结底,内嵌与引用的选择不是审美偏好,而是对“数据怎么被读、怎么增长、怎么保持一致”这三个问题的回答。把这三问写进建模评审清单,每次建模都逐条回答,就能把关系表达方式从经验判断变成可复现的工程决策。