做社区项目那段时间,我花了整整一个下午帮同事排查一个问题:用户发帖列表接口越查越慢,最后一看,代码里用 $lookup 关联了五个集合,每一层都忘了建索引,一个列表接口跑了三秒多。那个项目的 MongoDB 用得很别扭,所有的关系都按 MySQL 的外键思路拆成集合,然后到处 JOIN(准确的说是 $lookup),完全没发挥出文档模型的优势。
这让我想认真聊聊 MongoDB 里的"关系"。标题就两个字:关系。但这两个字背后其实是文档建模最核心的决策——什么时候把数据塞进同一个文档,什么时候拆开用引用关联,什么时候干脆冗余一份。如果你正准备用 MongoDB 做业务,或者已经在数据建模上踩过坑,这篇内容值得你花十分钟看完。
我尽量用实际项目里摸爬滚打的视角来讲,把一对一、一对多、多对多这三类关系在 MongoDB 中的处理方式、$lookup 的正确用法、以及我亲身踩过的几个坑都摊开来说,希望对你有用。
1. 关系建模的第一步:先认清 Mongo 的文档模型和关系型数据库的本质差异
1.1 为什么"外键 + JOIN"思维在 Mongo 里会水土不服
先从核心概念说起。MongoDB 是文档型数据库,数据不是存在二维表里,而是存在集合(collection)里,每个集合由文档(document)构成,每个文档是 JSON-like 的结构(BSON)。数据库(database)下面是集合,集合下面是文档。这个层级本身不复杂,但很多人恰恰栽在"文档到底该怎么组织"这件事上。
刚从 MySQL 转过来的人,很容易把集合当成表,把文档当成行,然后下意识地给文档加一个 userId 字段当作外键,查询的时候用 $lookup 去关联。这样做的结果是什么?单表查询好好的,一旦关联三层以上,聚合管道复杂到没法维护,性能也开始恶化。
问题不在 $lookup 本身,而在于"关系"这个东西在 MongoDB 里的表达方式和关系型数据库完全不同。关系型数据库靠拆表和 JOIN 来消除数据冗余,这是它模型的核心;而 MongoDB 的文档模型允许而且鼓励把相关数据放在一起,文档本身就是"一条完整的数据"。你不需要为了省一点存储空间把用户和他的基本资料拆成两张表,因为一个用户文档里直接嵌一个 profile 对象,读取时一次就能拿到全部信息。
1.2 文档模型里的"内嵌"和"引用"到底指什么
这是 MongoDB 关系建模最核心的两个概念:内嵌(embedding)和引用(referencing)。
内嵌的意思是,把关联的数据作为子文档或数组,直接放在父文档内部。举个例子,一个用户文档:
javascript复制{
_id: ObjectId("64a1b2c3d4e5f6a7b8c9d0e1"),
username: "zhangsan",
profile: {
nickname: "张三",
avatar: "http://cdn.example.com/avatar/zhangsan.jpg",
city: "北京"
}
}
这里的 profile 就是内嵌文档。引用则是反过来,父文档里只存关联文档的 _id,真正的内容在另一个集合里:
javascript复制// user 集合中的文档
{
_id: ObjectId("64a1b2c3d4e5f6a7b8c9d0e1"),
username: "zhangsan",
posts: [
ObjectId("64b1b2c3d4e5f6a7b8c9d0e2"),
ObjectId("64b1b2c3d4e5f6a7b8c9d0e3")
]
}
// post 集合中的文档
{
_id: ObjectId("64b1b2c3d4e5f6a7b8c9d0e2"),
authorId: ObjectId("64a1b2c3d4e5f6a7b8c9d0e1"),
title: "MongoDB 关系建模实战",
content: "..."
}
我用一个生活化的类比:内嵌就像把身份证复印件和银行卡直接放进同一个档案袋,你要办事时打开档案袋全都有;引用就像档案袋里只写一行字"去隔壁档案柜找编号 xxx 的文件",你要用的时候还需要去隔壁取一趟。
内嵌的好处是读得快、写入可以保证单文档原子性;坏处是如果数据一直膨胀,文档会越来越大,而且一份数据如果被很多地方内嵌,更新需要多处同步。引用的好处是数据只有一份,更新一处就好,结构灵活;坏处是你需要额外查询才能拿到关联数据,或者用 $lookup 来模拟 JOIN。
1.3 用实际场景对比两种方式的读写差异
假设你做一个博客系统,文章(post)和评论(comment)。
如果采用"外键思维",你会建两个集合,评论文档里存 postId,查一篇文章的评论时先查文章,再建索引去查评论列表。这没问题,但如果你需要文章标题、作者名、最近三条评论摘要一起展示,就得多次查询或一次大 $lookup。
如果采用"内嵌思维",把评论数组直接塞进文章文档里:
javascript复制{
_id: ObjectId("..."),
title: "MongoDB 关系建模实战",
authorId: ObjectId("..."),
comments: [
{ user: ObjectId("..."), content: "很有帮助", createdAt: new Date() },
{ user: ObjectId("..."), content: "收藏了", createdAt: new Date() }
]
}
这样一次查询直接把文章和评论都读出来。但是问题来了:如果一篇文章有一万条评论,这个文档瞬间就变得巨大,每次读取文章都会连带把一万条评论全部读出来,内存和带宽都吃不消,而且整个文档还会逼近 16MB 的硬限制。
所以,内嵌不是万能药,引用也不是。关键看关系的量级和访问模式。下面对不同关系逐一拆解。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一对一关系:什么时候该内嵌,什么时候该引用
2.1 用户与用户资料:最典型的一对一内嵌场景
一对一关系在 MongoDB 里是最轻松的一类,SQL 里要建两张表加外键,MongoDB 里绝大多数情况直接内嵌就行。
比如用户(users)和用户的个人资料(profile)。资料里的姓名、头像、简介、地区等信息,和用户本身是同时被读取的,而且修改时也基本都发生在用户编辑资料这个动作上。我更推荐直接内嵌:
javascript复制{
_id: ObjectId("..."),
username: "zhangsan",
email: "zhangsan@example.com",
profile: {
nickname: "张三",
bio: "写代码的老张",
avatar: "http://cdn.example.com/avatar/zhangsan.jpg",
city: "北京",
website: "https://example.com"
}
}
这么做的最大理由是一次读取搞定。用户详情页要展示用户信息、资料、头像,一条查询就够了,不需要两次往返,也不用 $lookup。而且更新资料时只需要一次 updateOne,整个文档在同一个事务边界内,不会出现"用户基本信息更新成功、资料更新失败"这种不一致。
2.2 订单与订单详情:数据量增长时要考虑边界
一对一也不是绝对内嵌,订单和订单详情就是个值得权衡的例子。小订单场景下,订单项可能就三五个,直接把 items 数组内嵌进订单文档非常舒服:
javascript复制{
_id: ObjectId("..."),
orderNo: "202405180001",
userId: ObjectId("..."),
status: "paid",
items: [
{ skuId: ObjectId("..."), name: "咖啡豆", price: 89, qty: 2 },
{ skuId: ObjectId("..."), name: "手冲壶", price: 299, qty: 1 }
],
totalAmount: 477,
createdAt: new Date()
}
但是如果一个订单可能有几百个商品条目,或者你经常需要单独统计商品维度的销量,那把所有 items 都内嵌在订单里就有点笨重了。每次更新一个 item 的状态都要操作整个订单文档,并发一高就容易撞。
我的建议是:判断"这份关联数据是否总是随主体一起读取、一起写"。如果答案一直是,内嵌;如果有时候你只想查详情而不想碰订单主数据,或者数据量可能大到不可控,就拆出来单独建一个 order_items 集合,用 orderId 引用。
2.3 一对一引用的实现和 DBRef 的取舍
如果你决定用引用,MongoDB 本身没有专门的"外键字段类型",通常就是直接存对方的 _id:
javascript复制{
_id: ObjectId("..."),
userId: ObjectId("64a..."),
address: ObjectId("64b...")
}
有些人会想用 DBRef,它是一个更标准的引用格式,长这样:
javascript复制{
$ref: "users",
$id: ObjectId("64a..."),
$db: "shop"
}
DBRef 确实提供了一些标准化的语义,但实际项目中我基本不推荐。原因是:第一,绝大多数官方驱动不会自动解析 DBRef,你依然要自己写逻辑去查对应的集合;第二,它比普通 _id 引用占用更多空间,且语义上没有额外的约束或好处。普通字段存 _id 完全够用,代码也更直观。别为了"标准"二字去引入不必要的复杂性。
注意:在 MongoDB 的文档模型里,所谓"引用"只是开发者约定的一种模式,数据库本身不强制你外键。正因为如此,关系维护更依赖应用层逻辑和良好的命名规范。
3. 一对多关系:三种建模方案,按量级和热度来选
3.1 方案一:子文档中存父 ID(经典的外键式)
这是最接近 SQL 思维的方式。子文档里存一个 parentId 或 postId 字段,表示它从属于哪个父文档。
拿文章和评论举例,comments 集合中的每条评论:
javascript复制{
_id: ObjectId("..."),
postId: ObjectId("64b..."),
userId: ObjectId("64a..."),
content: "写得太好了,转走了",
createdAt: new Date()
}
查询某篇文章的评论:
javascript复制db.comments.find({ postId: ObjectId("64b...") }).sort({ createdAt: -1 }).limit(20)
这种方式的好处是扩展性极好,评论数量可以无限增长,不会撑爆文章文档。坏处是查文章列表时,如果想带上评论数或最新评论,你得另外统计,通常要配合聚合查询或者反规范化字段(后面说)。
它最适合的是一对多中"多"的这一侧数据量大、独立性强、访问频率高的场景,比如评论、日志、明细,或者物联网网关下面挂的传感器上报记录这种。数据量大的时候,记得给 postId 建索引:
javascript复制db.comments.createIndex({ postId: 1, createdAt: -1 })
3.2 方案二:父文档里存子 ID 数组
反过来,把子文档的 _id 放进父文档的数组字段里:
javascript复制{
_id: ObjectId("..."),
title: "MongoDB 关系建模实战",
tagIds: [
ObjectId("64t1..."),
ObjectId("64t2...")
]
}
这种方式适合"一对多"中"多"的一侧数量有限且基本稳定的情况。比如文章和标签,一篇文章最多五六个标签,直接在文章里存 tagIds 数组再自然不过。查询文章时直接读数组,然后用 $in 查出标签名:
javascript复制db.tags.find({ _id: { $in: tagIds } })
优点是一次文章查询就拿到了关联的所有 id,不需要先按外键查子表,逻辑上很清爽。缺点是数组不能无限大,整个文档有 16MB 限制,虽然普通场景根本碰不到,但如果你往数组里塞几千上万个 id,无论空间还是序列化代价都不小。另外,如果子对象本身也经常需要独立更新,比如标签要改名字,你就得遍历所有文章更新冗余的 tagIds 数组,这时候就要考虑反规范化的一致性维护成本了。
3.3 方案三:反规范化冗余,用空间换性能
"反规范化"是 MongoDB 建模里经常听到的词,说白了就是在父文档里冗余一些子数据,避免每次都去子集合里查。
最常见的场景是帖子的"评论数"。如果用子文档存父 ID 的方式,每次展示帖子列表时想显示"xx 条评论",你就得先查帖子,再对每个帖子去 comments 集合 count 一下,这在列表场景里非常糟糕。替代方案是在帖子文档里冗余一个 commentCount 字段:
javascript复制{
_id: ObjectId("..."),
title: "MongoDB 关系建模实战",
content: "...",
commentCount: 12
}
新增评论时,除了向 comments 集合插入一条文档,同时执行:
javascript复制db.posts.updateOne({ _id: postId }, { $inc: { commentCount: 1 } })
删除评论时用 $inc: -1。这看起来多了一步操作,但换来的列表查询性能是质的提升——你完全不需要在列表接口里做统计了。同理,你还可以冗余"最新三条评论摘要"到文章文档里,让文章列表页直接展示评论预览,减少一次 $lookup。
反规范化的代价是数据一致性要自己保证。如果新增评论成功但 $inc 失败,评论数就错了。所以这类字段我建议使用 $inc 这种原子操作,而不是读出来改回去,并且在代码里做好异常补偿,比如定时巡检修复计数。
3.4 三种一对多方案怎么选,给个可抄的规则
我用一个表格把适用场景说清楚:
| 方案 | 适合的场景 | 优点 | 代价 |
|---|---|---|---|
| 子文档存父ID | 评论、日志、明细等量大且独立访问的子数据 | 扩展性好,子数据独立查询方便 | 查看父数据时需要二次查询或反规范化辅助 |
| 父文档存子ID数组 | 标签、成员等数量有限且随父文档一起读的关联 | 一次读父文档就能拿到所有关联ID | 数组不能无限大,关联对象更新时要同步数组 |
| 反规范化冗余字段 | 列表页高频展示的统计信息(评论数、点赞数) | 查询性能好,避免列表 N+1 | 一致性需要应用层维护,更新逻辑复杂 |
判断逻辑其实很简单:先问"这个关系的子女侧是否可能成千上万?"——是,就子存父ID;否,再问"我是否总是随着父文档一起读它们?"——是,就内嵌或存数组;如果我只是展示摘要或统计,就冗余一个字段。
4. 多对多关系:MongoDB 里不只有数组引用一种套路
4.1 最简场景:用户与角色,直接数组引用
多对多关系在 SQL 里需要中间表,MongoDB 则可以用数组引用直接表达,而且很多时候比中间表更自然。
拿用户(users)和角色(roles)来说,一个用户可以有多个角色,一个角色也可能属于多个用户。如果关系本身没有额外属性,直接在用户文档里存角色 _id 数组就完了:
javascript复制{
_id: ObjectId("..."),
username: "zhangsan",
roleIds: [
ObjectId("64r1..."),
ObjectId("64r2...")
]
}
要查用户的所有角色:
javascript复制db.roles.find({ _id: { $in: user.roleIds } })
反过来,如果你想查某个角色下的所有用户,就需要在 roles 文档里也冗余一个 userIds 数组,或者通过聚合 $lookup 反查。这就需要判断哪一侧是更常见的查询入口。如果只是后台管理端需要"按角色找用户",频率不高,完全可以用 $lookup;如果这个查询特别高频,就在角色文档里也存一份 userIds,两个方向都冗余,更新时两边一起维护。
4.2 带额外属性的多对多关系:需要中间集合
当多对多关系本身带有附加信息时,数组引用就不好使了。典型场景:学生和课程的关系,一个学生选多门课,每门课有多个学生,而且选课这个动作还带着"选课时间"、"成绩"、"状态"等属性。
这时候你需要一个专门的中间集合,比如 enrollments:
javascript复制// enrollments 集合中的文档
{
_id: ObjectId("..."),
studentId: ObjectId("64s..."),
courseId: ObjectId("64c..."),
enrolledAt: new Date("2024-03-01T10:00:00Z"),
status: "active",
score: 95
}
查询某个学生选了哪些课:
javascript复制db.enrollments.find({ studentId: ObjectId("64s...") })
db.courses.find({ _id: { $in: [ /* 从上面查到的 courseId 列表 */ ] } })
或者直接用 $lookup 一步到位。中间集合本质上就是 SQL 里的中间表,但在 MongoDB 里它比中间表更轻量,因为你可以直存放业务字段,不需要再 join 第三张属性表。
使用中间集合时一定要想清楚查询方向,给两个方向的字段都建索引,比如 { studentId: 1 } 和 { courseId: 1, studentId: 1 },避免全集合扫描。
4.3 实际案例:文章标签与作者关系的建模
我用一个稍微综合的例子演示。短视频平台或博客会有点赞、收藏、关注这类关系,它们本质都是"用户"和"内容/用户"之间的多对多关系。
拿"用户收藏文章"来说。简单方案是在用户文档里冗余一个 favoriteArticleIds 数组,但收藏数量可能几千,而且用户主页要分页展示收藏,数组分页很难受。更好的是建一个 favorites 中间集合:
javascript复制{
_id: ObjectId("..."),
userId: ObjectId("..."),
articleId: ObjectId("..."),
createdAt: new Date()
}
按用户分页查收藏时:
javascript复制db.favorites.find({ userId: ObjectId("...") }).sort({ createdAt: -1 }).skip(20).limit(20)
同时你需要知道"这篇文章被多少人收藏了",最常见做法是在文章文档里冗余 favoriteCount: 123,每当用户收藏或取消收藏时 $inc 更新。注意,在 MongoDB 4.0 之前,这种"往 favorites 插入记录 + 更新文章的 favoriteCount"两步操作是没办法保证原子性的。4.0 以后副本集支持多文档事务了,4.2 以后分片集群也能用,所以我个人建议:关系写操作涉及多处数据时,优先考虑用事务保护,或者确保自己清楚可能出现中间状态的后果,并做好补偿。
提示:MongoDB 的多文档事务在副本集和分片集群上都已可用,但很多老项目还没开启。如果你用的版本低于 4.0,中间集合加计数器这种操作,必须接受"临时不一致"的风险,靠定时任务去对账。
5. 关系查询的实战演练:$lookup 与聚合管道,别再用错了
5.1 什么是 $lookup,它和 LEFT JOIN 的关系
当引用关系已经建立,查询时需要把关联数据拼回来,这就要用到 $lookup 聚合阶段。
先看最基本语法:
javascript复制db.orders.aggregate([
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user"
}
}
])
它的含义是:从 orders 集合的文档中取 userId,去 users 集合中匹配 _id 相等的文档,把匹配到的用户文档放进一个名为 user 的数组。如果没匹配到,user 就是一个空数组。这个行为非常像 SQL 里的 LEFT JOIN,差别在于结果是一个数组而不是平铺的行。
需要记住:localField 和 foreignField 两边必须有索引,否则 $lookup 会慢到让你怀疑人生。按上面的例子,需要在 users._id 上建索引(_id 默认有),并且注意 orders.userId 的类型必须和 users._id 一致,经常遇到的坑是 userId 存的是字符串,而 _id 是 ObjectId,导致永远匹配不上。
5.2 多级联查:三层关系在一段聚合里搞定
实际业务中经常需要跨两三层。比如订单列表要显示用户昵称和订单商品名称,商品又是单独的集合。可以连续使用两次 $lookup:
javascript复制db.orders.aggregate([
{ $match: { status: "paid" } },
{ $sort: { createdAt: -1 } },
{ $limit: 50 },
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "userInfo"
}
},
{
$unwind: { path: "$userInfo", preserveNullAndEmptyArrays: true }
},
{
$lookup: {
from: "products",
localField: "items.productId",
foreignField: "_id",
as: "products"
}
},
{
$project: {
orderNo: 1,
totalAmount: 1,
"userInfo.nickname": 1,
"userInfo.email": 1,
"products.name": 1,
"products.price": 1
}
}
])
这里我用了 $match、$sort、$limit 先把订单收窄,再做 $lookup。为什么顺序这么重要?因为如果一开始就 $lookup,相当于对全量订单做关联,数据集越大越亏。先筛选再关联,能让 $lookup 只处理 50 条订单,性能天差地别。
$unwind 是为了把 userInfo 数组打平,方便后续字段引用。如果需求里用户不存在也要保留订单,就加 preserveNullAndEmptyArrays: true,相当于 LEFT JOIN 里保留左表记录。
多级联查看起来简单,但要注意区分 $lookup 的结果始终是数组这个特性,很多人第一次用 $unwind 忘记处理空数组,导致订单被过滤掉。
5.3 关系查询中"查询"和"删除"的正确姿势
先说查询。我的经验是:尽量把聚合管道拆解得直白一点,能用一次 $lookup 就不用两次。 能不查的信息就不查,用 $project 尽早去掉大字段(比如文章正文)。如果只是校验某个关联是否存在,$lookup 就不必了,直接 findOne({ _id: xxx }, { _id: 1 }) 更省。
再提一下文档数据的删除,从关系一致性角度考虑,MongoDB 没有外键级联,当你删除一个父文档时,子集合里的引用不会自动清理。例如删除一篇文章:
javascript复制const postId = ObjectId("...");
// 先删除评论
db.comments.deleteMany({ postId: postId });
// 再删除文章本身
db.posts.deleteOne({ _id: postId });
如果你用了"父文档存子 ID 数组"的方案,还要用 $pull 把父文档数组中的引用清掉:
javascript复制db.users.updateOne(
{ _id: userId },
{ $pull: { favoriteArticleIds: postId } }
);
删数据之前,先列一张清单:哪些集合的文档引用了要删的标签或 id?如果漏了,就成了传说中的"脏数据"。我在项目里吃过这个亏,删了一个课程,但学生的选课记录还在,第二天统计时数据全乱了。所以关系建模的另一面,就是关系清理。
6. 我在真实项目里踩过的关系建模坑,和一套可复用的决策清单
6.1 坑一:无脑内嵌,文档膨胀到拖垮整个接口
之前有个同事设计"项目-任务"模型,把任务全部内嵌到项目文档里。一开始项目只有十几个任务,一切正常。后来客户一个项目里导入了两千多个任务,每次打开项目详情页,MongoDB 要序列化整个项目文档,任务数据全量传到前端,接口响应从几十毫秒变成好几秒,而且文档大小也逼近 16MB。
解决办法:任务拆成独立集合,任务文档里存 projectId,项目文档里只保留任务统计。实测改造后列表接口从三秒降到两百毫秒。这个案例让我记住一条线:如果子数据可能超过几百条,或者未来可能上千,别内嵌。
6.2 坑二:引用字段没建索引,慢查询直接拖垮生产
这个坑在前面也提过,但值得单独说。有一次排查线上慢请求,db.comments.find({ postId: ... }) 跑了三秒。集合里只有几十万条评论,按说不多,但没索引就是全集合扫描。建了 createIndex({ postId: 1, createdAt: -1 }) 之后,瞬间变成个位数毫秒。
MongoDB 里所有用于 localField、foreignField、find 条件、sort 的字段,都值得认真考虑索引。尤其关系字段,它天然就是查询的高频入口。索引设计这步不能偷懒,否则关系模型越复杂,死得越难看。
6.3 坑三:多对多修改时没注意事务边界,造成数据不一致
做电商项目时,把"商品挂到分类下"设计成商品文档里存 categoryIds。操作时先给商品文档 push 一个分类 id,然后给分类文档的 productIds 也 push 一个商品 id。代码里两条 update 语句,如果第二条执行失败,就会出现"商品里有这个分类,分类里却没有这个商品"的脏状态。
后来升级到 MongoDB 4.2,我果断给这类操作套上了事务:
javascript复制const session = client.startSession();
session.startTransaction();
try {
await db.products.updateOne(
{ _id: productId },
{ $push: { categoryIds: categoryId } },
{ session }
);
await db.categories.updateOne(
{ _id: categoryId },
{ $push: { productIds: productId } },
{ session }
);
await session.commitTransaction();
} catch (err) {
await session.abortTransaction();
throw err;
} finally {
session.endSession();
}
如果你的 MongoDB 版本不支持事务,至少要设计成"幂等操作 + 对账任务",例如每天扫一遍反向引用,发现不一致就修正。别用"读-改-写"这种非原子流程去维护关系字段,并发下一撞就出错。
6.4 关系建模决策清单(简单版)
最后留一份我在做技术评审时常用的决策清单,基本可以覆盖多数业务:
- 这个关系是否"总是"随着主体一起被读取?如果是,优先考虑内嵌。
- 子数据量会不会无上限增长(评论、日志、操作记录)?会,就拆独立集合,子文档存父 ID。
- 关系本身是否带属性(成绩、时间、状态)?带,就建中间集合,别硬塞数组。
- 列表页/详情页是否高频展示聚合信息?高频,就反规范化冗余一个统计字段,用
$inc维护。 - 这些关系修改时会不会涉及多个集合写入?会,优先用多文档事务。
- 你的关系字段上有索引吗?没有,第一批就补上。
- 删除主文档时,关联的子数据有清理计划吗?没有,现在就写。
我自己做项目时还会多问一句:五年后这个数据量会变成多少?如果一个关系字段在未来可能从"一对多"直接膨胀成"一对海量",我宁可一开始就用引用,也不会因为贪图内嵌的方便而埋坑。
回到开头那个慢查询的经历,后来我把 $lookup 从五层降到两层,把高频读取的用户昵称和头像反规范化到订单集合里,列表接口终于恢复到了秒回。让人感慨的是,MongoDB 的"关系"从来不是一个数据库功能上的硬约束,它是一道建模题、一道取舍题。把文档模型想透了,关系就不是阻力,而是顺手的事。
