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 模式设计的核心决策。下面是经过实战检验的经验法则:
- 一起读出来的:嵌入。一篇文章的标签几乎总是一起显示,嵌入。
- 不共享的:嵌入。某用户的偏好只属于他自己,嵌入。
- 数量有限:嵌入。评论数 < 100 还行,但百万评论会撑爆文档(单文档 16MB 上限)。
- 独立存在、被多处引用的:引用。用户信息被订单、评论、收藏多处用到,引用。
- 数量巨大的:引用。日志、订单明细,引用。
- 频繁单独访问的:引用。某条评论要直接 URL 访问,就该有自己的 _id。
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 篇文章,再每篇查一次作者)。反规范化后,文章里冗余 authorName 和 authorAvatar,一次查询搞定。代价是作者改名时要同步更新所有相关文章——所以反规范化适合读远多于写、被引用方不常变更的数据。
同理,经常用 $inc 维护一个计数器(如 postCount、likeCount)也是一种反规范化——避免每次显示都要 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. 模式设计的几条原则
- 按查询设计,不是按数据:先想"应用要怎么查",再倒推文档结构。MongoDB 是"读优化"思维。
- 富文档,贫集合:宁可一个文档装更多相关数据,也不要急着拆集合。
- 16MB 上限:单文档不能无限增长,评论/日志这类要引用。
- 写多读少慎反规范化:冗余字段每次写都要同步,写多场景反而成累赘。
- 别强求关系范式:MongoDB 不是 MySQL,把 MySQL 表结构硬搬过来通常效果差。
- 渐进式演进:schema 灵活的好处就是可以边走边改,先把核心跑起来再优化。
8. 决策流程图(简版)
遇到一段相关数据,问自己这几个问题:
- "我总是一起读它吗?"→ 是 → 嵌入候选。
- "它的数量会很大吗?"→ 是 → 必须引用。
- "它会被多处引用吗?"→ 是 → 引用。
- "我需要单独 URL 访问某条吗?"→ 是 → 引用。
- "它和主文档必须同时变更吗?"→ 是 → 嵌入(享原子性)。
小结
这一篇讲了 MongoDB 模式设计的核心权衡:嵌入 vs 引用,以及何时反规范化。原则不复杂——"一起读、不共享、量有限 → 嵌入;独立存在、被引用、量大 → 引用;读多写少且不变 → 反规范化"。下一篇专门讲关联查询:用 $lookup 把引用的文档"连"起来,以及 Mongoose 的 populate 怎么让这件事变得优雅。
← 上一篇 MongoDB 索引
下一篇 MongoDB 关联查询 →