MongoDB 模式设计:嵌入 vs 引用

关系数据库的表结构设计有成熟的"三大范式"可循,MongoDB 因为文档可以嵌套,模式设计自由度大得多——但也意味着你要主动决定:相关数据是"嵌进同一个文档"还是"拆出来互相引用"?这一决定直接决定查询性能、写入复杂度和数据一致性。这一篇讲清这条 MongoDB 设计的核心权衡。

1. 嵌入(Embedding):打包进一个文档

把相关数据直接放进同一个文档。读的时候一次查询拿全,无需 JOIN,性能极佳。适合"一起读出来、不共享、数量有限"的数据。

// 嵌入(embedding):把相关数据直接放进同一个文档
// 适合"一起读出来、不共享、数量有限"的数据

// 例:博客文章 + 它的评论 + 标签
db.posts.insertOne({
  title: "MongoDB 入门",
  content: "...",
  tags: ["MongoDB", "NoSQL"],        // 数组嵌入
  author: {                            // 对象嵌入
    name: "小明",
    avatar: "ming.jpg"
  },
  comments: [                          // 数组嵌入对象
    { user: "小红", text: "赞!", at: ISODate("...") },
    { user: "小刚", text: "学到了", at: ISODate("...") }
  ]
})

// 优势:一次查询拿到全部,无需 JOIN,性能极佳
// 劣势:评论无限增长会让文档膨胀(MongoDB 单文档上限 16MB)

2. 引用(Referencing):只存对方的 _id

只存对方的 _id,需要时再查或用 $lookup 关联。适合"独立存在、被多处引用、数量巨大"的数据。这正是关系数据库的常规做法。

// 引用(referencing):只存对方的 _id,需要时再查
// 适合"独立存在、被多处引用、数量巨大"的数据

// 用户集合
db.users.insertOne({ _id: "u-001", name: "小明", email: "..." })

// 订单集合:只引用用户 id
db.orders.insertOne({
  _id: "o-1001",
  userId: "u-001",                     // 引用用户
  items: [
    { productId: "p-001", qty: 2 },    // 引用商品
    { productId: "p-002", qty: 1 }
  ],
  total: 199.00,
  createdAt: new Date()
})

// 查询时需要两次查询或用 $lookup 关联
const order = db.orders.findOne({ _id: "o-1001" })
const user  = db.users.findOne({ _id: order.userId })

// 优势:数据不冗余,被引用方独立更新方便
// 劣势:多次查询,需要 JOIN 时较麻烦

3. 经验法则:何时嵌入,何时引用

这是 MongoDB 模式设计的核心决策。下面是经过实战检验的经验法则:

4. 按关系类型建模

借鉴关系数据库的思路,但要根据 MongoDB 特点调整:

一对一 / 一对少

用户和他的偏好设置、文章和它的标签——这类数量有限且总是一起读的关系,嵌入是最佳选择。

一对多

数量中等的(如一篇博客的评论,几十到几百),嵌入;数量巨大的(一个作者写了几千篇文章),引用,在"多"方存"一"方的 id。超大的一对多(帖子下百万评论),用子引用(在评论里存 postId)而不是父存子 id 列表(会撑爆父文档)。

// 一对少(few):嵌入最合适
// 例:一篇文章有 5 个标签、3 个分类
db.posts.insertOne({
  title: "...",
  tags: ["MongoDB", "NoSQL", "数据库"]   // 数量有限,嵌入
})

// 一对多(many,数量大):引用更合适
// 例:一个作者写了 1000+ 篇文章 -> 文章里引用作者 id
db.posts.insertOne({
  title: "...",
  authorId: "u-001"                       // 引用,而非把作者对象嵌入每篇文章
})

// 一对多(超大):父引用(把子文档 id 存父)或子引用(把父 id 存子)
// 例:一篇帖子下百万评论,父存不下 -> 评论里存 postId
db.comments.insertOne({
  postId: "p-001",
  text: "..."
})

多对多

文章和标签是经典多对多。MongoDB 通常用双方互相引用 id 数组,或者只在访问更频繁的一方存 id 数组,反向查询靠 find{ ids: "x" }(数组字段会自动建多键索引)。

// 多对多:双方互相引用(用 id 数组)
// 例:文章 <-> 标签(一篇文章有多个标签,一个标签下有多篇文章)

db.posts.insertOne({
  title: "...",
  tagIds: ["t-001", "t-003"]              // 文章引用它的标签
})

db.tags.insertOne({
  _id: "t-001",
  name: "MongoDB",
  postCount: 256                            // 反规范化的计数器(可选)
})

// 查"这篇文章的标签"
const post = db.posts.findOne({ _id: "p-001" })
db.tags.find({ _id: { $in: post.tagIds } })

// 查"这个标签下有哪些文章"(反向)
db.posts.find({ tagIds: "t-001" })

5. 反规范化:用冗余换性能

关系数据库追求"不冗余"(三大范式),MongoDB 则相反——故意冗余一点常用字段,避免每次查询都 JOIN。这是 NoSQL 的精髓,叫"反规范化"。

// 反规范化(denormalization):故意冗余一点数据,换取少查询
// 经典做法:在引用的同时,冗余几个最常一起读的字段

// 不冗余(纯引用):查文章列表时要 N+1 次查询拿作者
const posts = db.posts.find().toArray()
posts.forEach(p => {
  const author = db.users.findOne({ _id: p.authorId })   // 每篇文章都要查一次!
})

// 反规范化:文章里冗余 author 的 name + avatar
db.posts.insertOne({
  title: "...",
  authorId: "u-001",                          // 完整引用(更新时用)
  authorName: "小明",                          // 冗余(展示时用)
  authorAvatar: "ming.jpg"
})

// 现在文章列表一次查询就够了
db.posts.find({}, { title: 1, authorName: 1, authorAvatar: 1 })

// 代价:作者改名时要同步更新所有文章(用 $inc 计数器同理)
// 适用:读远多于写、被引用方不常变更的数据

典型场景:文章列表总要显示作者名字和头像。如果纯引用,列表查询会变成 N+1 次(先查 N 篇文章,再每篇查一次作者)。反规范化后,文章里冗余 authorNameauthorAvatar,一次查询搞定。代价是作者改名时要同步更新所有相关文章——所以反规范化适合读远多于写、被引用方不常变更的数据。

同理,经常用 $inc 维护一个计数器(如 postCountlikeCount)也是一种反规范化——避免每次显示都要 count 一遍。

6. 原子性考虑

MongoDB 对单个文档的所有操作是原子的——意思是你一次 updateOne 改了多个字段,要么全成功要么全失败,中间状态不会出现。把相关的"必须同时变更"的数据放进同一个文档,等于免费拿到原子性

// 单文档原子性:MongodB 对单文档的所有操作都是原子的
// 把相关的"必须同时更新"的数据放进同一个文档,自然得到原子性

// 反例(分两个文档,可能不一致):
//   account_a: { balance: 100 }
//   account_b: { balance: 50 }
// 转账要"扣 A 加 B",跨文档,需要事务(4.0+)

// 正例:用嵌入把"订单 + 库存扣减记录"放一个文档
db.products.insertOne({
  _id: "p-001",
  name: "手机",
  stock: 100,
  reservations: [                            // 嵌入预订记录
    { orderId: "o-1", qty: 1 },
    { orderId: "o-2", qty: 2 }
  ]
})

// 扣库存 + 加预订记录,单文档操作,天然原子
db.products.updateOne(
  { _id: "p-001", stock: { $gte: 1 } },
  {
    $inc: { stock: -1 },
    $push: { reservations: { orderId: "o-3", qty: 1 } }
  }
)

这种"嵌入 + 单文档原子更新"的思路,在库存扣减、订单创建、计数器等场景非常优雅,完全不需要事务。如果实在要跨文档原子操作,才用 4.0+ 的多文档事务(性能开销大,能免则免)。

7. 模式设计的几条原则

8. 决策流程图(简版)

遇到一段相关数据,问自己这几个问题:

小结

这一篇讲了 MongoDB 模式设计的核心权衡:嵌入 vs 引用,以及何时反规范化。原则不复杂——"一起读、不共享、量有限 → 嵌入;独立存在、被引用、量大 → 引用;读多写少且不变 → 反规范化"。下一篇专门讲关联查询:用 $lookup 把引用的文档"连"起来,以及 Mongoose 的 populate 怎么让这件事变得优雅。

← 上一篇 MongoDB 索引

下一篇 MongoDB 关联查询

✈️💬