刚从 MySQL 迁到 MongoDB 的人,最容易把关系建模那一套照搬过来:每个实体建一个集合,用 userIdorderId 这种字段做关联,查询时再 $lookup 拼。这种写法能跑,但它把 MongoDB 用成了「没有外键约束的关系库」,丢掉了文档模型最大的优势——访问局部性。

文档模型真正的价值,不是「字段随便加」,而是把一个业务实体的相关信息内嵌成一个文档,应用读一次就能拿到全部信息。但内嵌也不是免费午餐:它换来访问效率的同时,要承担冗余、一致性维护和文档膨胀的成本。这一篇讲清楚内嵌和引用各自的边界,以及怎么在两者之间做取舍。

先把机制边界说清楚

MongoDB 的建模选择本质上只有两种基本姿势:

  • 内嵌(Embedded):把关联的对象作为子文档/数组,存进同一个文档。
  • 引用(Referenced):关联对象存在别的集合,主文档只存它的 _id,用时再查。

这不是「谁好谁坏」的选择,而是两种访问模式的取舍。核心判断点是:关联对象和主对象是不是「一起读、一起写」

内嵌 vs 引用

文档模型设计:内嵌 vs 引用

把两种建模画在一起对比,差别立刻清晰:内嵌是「一次访问拿全」,引用是「跨集合靠 ID 关联」。它们对应的是完全不同的访问形状。

什么时候该内嵌

内嵌的收益是访问局部性。判断要不要内嵌,看三个条件是不是同时成立:

  1. 子对象和主对象生命周期一致:主对象删了,子对象也没意义。
  2. 访问模式是整体读整体写:业务几乎不会「只改子对象的某个字段,不碰主对象」。
  3. 子对象数量有上限:不会无限增长。

典型场景:订单的收货地址快照、商品的多规格、文章的评论(小规模)、用户的标签。这些子对象只属于它的主对象,业务读订单时永远需要同时拿到商品明细和地址,内嵌让一次 find 就返回完整订单,比拆三个集合 $lookup 三次快得多,也更省连接和往返。

订单系统里有个经典设计:地址用内嵌存「快照」而不是存 addrId。因为订单生成时的收货地址是历史事实,用户后来改地址不应该影响已下订单。这是内嵌天然擅长表达的「时间快照」语义,关系模型要用冗余字段才能模拟。

什么时候该引用

引用的收益是独立演进和复用。判断要不要引用,看这几个条件:

  1. 子对象被多处引用:一个商品出现在很多订单里,一个用户有多个角色。
  2. 子对象需要独立查询和更新:商品要单独管理库存,用户要单独改资料。
  3. 子对象数量可能很大或持续增长:主文档装不下。

典型场景:商品、用户、分类、组织架构。商品被千万订单引用,内嵌会导致商品信息在每个订单里冗余千万份,改个价格要更新千万个文档——这是灾难。这种实体必须独立成集合,主文档只存 itemId

判断引用是否合适的另一个信号:这个子对象有自己的主键和独立生命周期吗? 如果有,它就是独立实体,引用它;如果没有,它只是主对象的组成部分,内嵌它。

两种姿势各自的代价

内嵌的代价是冗余和一致性。 同样的商品名称出现在每个订单的 items 里,商品改名后历史订单的名字不会自动更新。这通常不是问题(历史快照本该如此),但如果你的业务要求「全局改名同步所有引用」,内嵌就会变成负债。

内嵌的第二个代价是文档膨胀。 MongoDB 单文档上限是 16MB。一个订单有几十件商品没问题,但如果一个文档要内嵌几千上万条评论、几百万条日志,就会撞上这个上限,查询性能也会随文档变大而下降。

引用的代价是多次访问。 拿一个完整订单要查 ordersaddressesitems 三个集合,要么 $lookup(本质是类 JOIN),要么应用侧多次查询。每次访问都是一次往返和一次索引查找,延迟会叠加。引用过多,MongoDB 就退化成「需要应用手动 JOIN 的关系库」。

引用的第二个代价是一致性靠应用保证。 没有 SQL 的外键约束,删除一个商品时,引用它的订单不会自动处理,要靠应用或监听 change stream 维护。弱一致性有时是优势(允许暂时的悬空引用),但需要业务能容忍。

头号反模式:无限增长的数组

文档模型最常见的线上事故,是把持续增长的数据塞进一个文档的数组。几个典型形态:

  • 用户动态内嵌成一个长数组,活跃用户文档膨胀到几十 MB。
  • 聊天消息全部内嵌到一个会话文档,会话越长文档越大。
  • 评论内嵌到文章文档,热门文章评论上万条。

这些写法起初都能跑,随着数据积累会集中爆发:文档超过 16MB 报错、单文档读写变慢、更新时整文档重写(MongoDB 更新是文档级,改一个数组元素可能触发整文档搬移)。

处理这类持续增长数据,正确姿势是拆集合 + 引用:消息/评论/动态独立成集合,主文档只存最近 N 条或一个引用。这就是著名的「桶模式(bucket pattern)」——按时间或数量分桶,每个桶是一个文档,避免单文档无限膨胀。时间序列数据、IoT 传感器读数、日志,都适合用桶模式。

一对多、多对多怎么落地

把上面收敛成几个可复用的判断:

  • 一对少且一起访问:内嵌。订单地址快照、文章标签。
  • 一对多且子对象独立:引用。用户与订单、商品与评价。
  • 一对海量且持续增长:拆集合 + 引用,必要时桶模式。用户动态、聊天消息。
  • 多对多:双向存引用数组,或用中间集合。用户与群组、商品与活动。注意引用数组本身也会增长,超大量时也要拆。

一个实用经验:先按「读路径」建模,再校验「写路径」。先把业务最频繁的查询画出来,看每次查询需要哪些字段一起出现,把这些字段聚合成文档;然后检查写路径,看哪些字段会被独立更新,把它们拆出来。MongoDB 的建模是读写形状共同决定的,不是只看结构。

判断框架

  • 业务实体「整体读、整体写」、子对象只属于父对象 → 内嵌。
  • 子对象独立演进、被多处引用、需独立查询 → 引用。
  • 持续增长的列表 → 拆集合 + 引用,必要时桶模式,绝不让单文档数组无限长。
  • 需要历史快照语义 → 内嵌(快照不被后续修改影响)。
  • 需要全局一致性同步 → 引用(单一数据源)。
  • 任何「这个数组会一直变长吗」的疑问,如果答案是会,就立刻拆。

文档模型的灵活性,价值在于「能按访问形状建模」,而不是「能随便存」。把内嵌和引用用对地方,MongoDB 的访问效率优势才真正发挥;用错地方,它就只是一个缺少约束、还要手动维护一致性的关系库。


关于十三Tech

我是十三,All in AI Agent 方向的架构师,专注 AI 工程实践。

我相信 AI 是程序员的最佳搭档,也希望帮助每一位开发者更好地驾驭 AI。

如果你想继续跟完这套「图解 MongoDB」,欢迎关注公众号 「十三Tech」。后续会按文档模型、索引优化、存储引擎、高可用和分片集群这条线更新。

十三Tech公众号二维码