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 问题——可以用 .populatelean 或直接改写 $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. 几种方案对比与选择

实战中往往是组合拳:核心展示字段反规范化(免 JOIN),完整数据用 $lookuppopulate 按需带出。

8. 性能要点

小结

这一篇讲了 MongoDB 关联查询的几种方式:嵌入用点表示法、引用用 $lookup(基础与 pipeline 形态)、递归关系用 $graphLookup、Node 项目用 Mongoose populate。没有银弹,根据数据模型和访问模式选最合适的。最后一篇我们聊生产部署——副本集(高可用)、写关注/读偏好、多文档事务,把 MongoDB 推向真正可用。

← 上一篇 MongoDB 模式设计

下一篇 MongoDB 副本集与事务

✈️💬