MongoDB 关联查询
上一篇模式设计讲过"何时嵌入、何时引用"。如果选择了引用,接下来就要面对一个实际问题——怎么把引用的文档"连"起来一起取?关系数据库用 JOIN,MongoDB 也有几条路:嵌入式文档直接用点表示法查、聚合管道的 $lookup、应用层的多次查询、或借助 Mongoose 的 populate。这一篇把这些都讲清楚。
1. 嵌入式文档:点表示法
如果相关数据已经嵌在同一个文档里,关联查询根本不是问题——直接用点表示法访问内层字段,无需任何 JOIN。这正是嵌入的最大优势。
// 嵌入式文档:直接用点表示法查询,无需 JOIN
// 文档结构:
// {
// title: "...",
// author: { name: "小明", verified: true },
// comments: [
// { user: "小红", visible: true, text: "赞" },
// { user: "小刚", visible: false, text: "..." }
// ]
// }
// 查嵌套对象的字段
db.posts.find({ "author.name": "小明" })
db.posts.find({ "author.verified": true })
// 查数组里"某个元素"满足条件
db.posts.find({ "comments.user": "小红" }) // 任一元素满足即可
// 数组里"同一元素"同时满足多条件 -> $elemMatch
db.posts.find({
comments: {
$elemMatch: { user: "小红", visible: true }
}
})
// 用点表示法 + 位置操作符更新某个数组元素
db.posts.updateOne(
{ "comments.user": "小红" },
{ $set: { "comments.$.visible": false } } // $ = 刚匹配到的那个元素
)关键技巧:数组里"同一元素"同时满足多个条件时必须用 $elemMatch。否则 MongoDB 会理解成"数组里有个元素 user 是小红,另有个元素 visible 是 true",即使它们不是同一条评论也算匹配,导致结果错误。位置操作符 $ 则用于精准更新"查询条件匹配到的那一个"数组元素。
2. $lookup:聚合里的左连接
$lookup 是 MongoDB 在聚合管道里实现关联的官方方式,概念上等价于 SQL 的 LEFT JOIN。它把另一个集合里匹配的文档"塞"进当前文档的一个数组字段。
// $lookup:在聚合管道里做"左连接"(LEFT JOIN)
// 把另一个集合里匹配的文档塞进当前文档的一个数组字段
// 场景:订单引用用户,显示订单时要把用户信息一起带出来
db.orders.aggregate([
{
$lookup: {
from: "users", // 要关联的目标集合
localField: "userId", // 当前文档里的字段
foreignField: "_id", // 目标集合里的匹配字段
as: "userInfo" // 结果放进这个数组字段
}
}
])
// 结果示例:
// {
// _id: "o-1001",
// userId: "u-001",
// total: 199,
// userInfo: [ { _id: "u-001", name: "小明", email: "..." } ]
// }
//
// 注意 userInfo 始终是数组,即使只匹配一条
// 配合 $unwind 把数组拆成单对象
db.orders.aggregate([
{ $lookup: { from: "users", localField: "userId",
foreignField: "_id", as: "userInfo" } },
{ $unwind: "$userInfo" },
{ $project: { orderId: "$_id", total: 1, userName: "$userInfo.name" } }
])注意 $lookup 的结果始终是数组(即使只匹配到一条)。如果期望"一对一"关系,配 $unwind 把数组拆成单个对象。这是 $lookup + $unwind 这对组合的高频用法。
3. $lookup 的 pipeline 形态:复杂关联
基础 $lookup 只能做"字段相等"的简单匹配。如果关联时还要在目标集合加额外过滤、排序、限制数量、投影,就要用 pipeline 形态——配 let 把当前文档字段传给子管道。
// $lookup 的 pipeline 形态:做更复杂的关联
// 比如关联时还要在目标集合加额外过滤、做投影
db.orders.aggregate([
{
$lookup: {
from: "comments",
let: { // 把当前文档字段传给子管道
orderId: "$_id"
},
pipeline: [
// $expr 把 let 传入的变量用 $expr 表达
{ $match: { $expr: { $eq: ["$orderId", "$$orderId"] } } },
{ $match: { visible: true } }, // 额外过滤:只要可见的
{ $sort: { createdAt: -1 } },
{ $limit: 5 }, // 每个订单只要最新 5 条评论
{ $project: { user: 1, text: 1, _id: 0 } }
],
as: "recentComments"
}
}
])
// 注意: $expr 性能不如直接 localField/foreignField 匹配
// 数据量大时务必给目标集合的关联字段建索引一个高频场景:"每个订单带它最新的 5 条可见评论"。基础 $lookup 做不到(它会带回所有评论),用 pipeline 形态能在子管道里 $match + $sort + $limit 精准控制。性能提示:子管道里用 $expr 匹配比直接 localField/foreignField 慢,务必给目标集合的关联字段建索引。
4. $graphLookup:递归图查询
如果关联是"递归"的——比如查某员工的所有上级链、某分类的所有子分类树、某用户的二度好友——就要用 $graphLookup。它会顺着指定的字段关系不停往下找,直到找不到为止。
// $graphLookup:递归图查询(类似树/图的遍历)
// 经典场景:组织架构(员工 -> 上级 -> 上级的上级)、好友关系、分类树
// 例:员工集合里每个文档有 managerId 指向上级
// 查"小张"的所有上级链(他 -> 他经理 -> 经理的经理 -> ...)
db.employees.aggregate([
{ $match: { name: "小张" } },
{
$graphLookup: {
from: "employees",
startWith: "$managerId", // 起点:小张的 managerId
connectFromField: "managerId", // 顺着这个字段往上找
connectToField: "_id", // 每跳匹配 _id
as: "managerChain"
}
}
])
// 反过来:查"老王"管理的所有下属(直接 + 间接)
db.employees.aggregate([
{ $match: { name: "老王" } },
{
$graphLookup: {
from: "employees",
startWith: "$_id",
connectFromField: "_id",
connectToField: "managerId", // 顺着 managerId 往下找
as: "allReports",
depthField: "level" // 给每条结果加个深度字段
}
}
])$graphLookup 在组织架构、好友关系、知识图谱、评论楼层(评论回复评论)等场景非常有用。注意它可能很慢(每跳都是一次查询),数据量大时务必建好索引并限制 maxDepth。
5. Mongoose populate:让关联变优雅
Node.js 项目里很少直接写 $lookup,更常见的是用 Mongoose(ODM 库)的 populate。它在 schema 里定义引用关系,查询时一句 .populate("author") 就能把引用的 ObjectId 替换成完整文档。
// Mongoose(Node.js ODM)的 populate:让关联查询变得优雅
// 先定义 schema,字段标 ref 指向另一个 model
const Post = mongoose.model("Post", new Schema({
title: String,
author: { type: Schema.Types.ObjectId, ref: "User" }, // 引用
comments: [{
user: { type: Schema.Types.ObjectId, ref: "User" },
text: String
}]
}))
const User = mongoose.model("User", new Schema({
name: String,
email: String
}))
// populate:自动把引用的 ObjectId 替换成完整文档
const posts = await Post.find().populate("author")
// 结果: posts[0].author 是完整 user 对象,不再是 id 字符串
// 嵌套 populate:把 comments 里的 user 也带出来
const posts = await Post.find().populate({
path: "comments.user",
select: "name -_id" // 只取 name,不要 _id
})
// 多级 populate
await Post.find().populate("author").populate("comments.user")
// populate 的实现:其实是发多条 query,然后在 Node 层合并
// 数据量大时仍要注意 N+1 问题,可以用 $lookup 一次拿populate 的底层实现其实是发多条 query 然后在 Node 层合并(而不是 MongoDB 服务端的 JOIN)。所以它本质上是"应用层 JOIN",写起来漂亮但性能不如 $lookup。数据量大、关联多时仍要警惕 N+1 问题——可以用 .populate 配 lean 或直接改写 $lookup。
6. 关于 DBRef(已过时,仅了解)
早期 MongoDB 用 DBRef 这种特殊结构表达跨集合引用,文档里写 { $ref, $id }。但官方现在明确推荐直接用 ObjectId 或字符串存引用字段(如 userId: ObjectId("...")),不再用 DBRef。新项目别用,看老代码心里有数即可。
// 旧式 DBRef(已不推荐,提一下避免踩坑)
// 早期 MongoDB 用 DBRef 表达"跨集合引用"
// 文档长这样: { $ref: "users", $id: "u-001" }
// 现在官方明确推荐:直接用 ObjectId/字符串存引用字段
// 比如就用: userId: ObjectId("...")
// 而不是: user: { $ref: "users", $id: ObjectId("...") }
// 大多数驱动对 DBRef 的支持已经淡化,Mongoose 也不用它
// 看到老项目里的 DBRef 心里有数即可,新项目别用7. 几种方案对比与选择
- 嵌入 + 点表示法:最快,但只适合嵌入结构。优先考虑。
- $lookup:服务端 JOIN,适合聚合报表、复杂关联。配索引用。
- $graphLookup:递归关系(树/图)专用。
- 应用层多次查询:最灵活,代码简单,但网络往返多。
- Mongoose populate:Node 项目首选,代码优雅,注意 N+1。
- 反规范化:常用字段冗余存一份,直接读,免 JOIN。
实战中往往是组合拳:核心展示字段反规范化(免 JOIN),完整数据用 $lookup 或 populate 按需带出。
8. 性能要点
- 给被关联字段建索引:
$lookup的foreignField没索引会全表扫描,数据量大直接卡死。 - 尽早 $match:在
$lookup前先过滤主集合,减少要关联的文档数。 - 限制 $lookup 结果:用 pipeline 形态里的
$limit避免一次带回过多数据。 - 避免深度 $lookup:嵌套多层
$lookup性能会指数级恶化。 - $graphLookup 加 maxDepth:防止无限递归扫穿整个集合。
小结
这一篇讲了 MongoDB 关联查询的几种方式:嵌入用点表示法、引用用 $lookup(基础与 pipeline 形态)、递归关系用 $graphLookup、Node 项目用 Mongoose populate。没有银弹,根据数据模型和访问模式选最合适的。最后一篇我们聊生产部署——副本集(高可用)、写关注/读偏好、多文档事务,把 MongoDB 推向真正可用。
← 上一篇 MongoDB 模式设计
下一篇 MongoDB 副本集与事务 →