MongoDB用$lookup实现多表连接查询

MongoDB 是文档型数据库,没有 SQL 里天然的 JOIN,但聚合管道里的 $lookup 做到了类似的事。这篇把 $lookup 的基础用法、嵌套关联、条件关联都过一遍,顺带说说它的限制和实际踩过的坑。

MongoDB 没有 SQL 的 JOIN,但这不代表它做不了多表关联查询。

聚合管道里有个 $lookup 操作符,专门干这件事。用法不复杂,但细节挺多,这篇我们把它拆开讲透。

$lookup 是什么

$lookup 是 MongoDB 聚合管道(Aggregation Pipeline)的一个阶段操作符。

它的作用是:从另一个集合里查出匹配的文档,拼到当前文档上,类似 SQL 里的左连接(LEFT JOIN)。

基本语法长这样:

{
  $lookup: {
    from: "要关联的集合名",
    localField: "当前集合的关联字段",
    foreignField: "目标集合的关联字段",
    as: "关联结果放到哪个字段里"
  }
}
​

结果会是一个数组,就算只匹配到一条,也是 [{...}] 的形式,空匹配是 []。

这和 SQL JOIN 不一样,SQL 是平铺展开,MongoDB 是嵌套数组——用的时候要记住这个区别。

基础用法

我们拿订单和用户两个集合举例。

orders 集合:

{ "_id": 1, "item": "键盘", "userId": 101 }
{ "_id": 2, "item": "鼠标", "userId": 102 }
{ "_id": 3, "item": "显示器", "userId": 101 }
​

users 集合:

{ "_id": 101, "name": "老王" }
{ "_id": 102, "name": "老张" }
​

现在想查每个订单附带对应的用户信息:

db.orders.aggregate([
  {
    $lookup: {
      from: "users",
      localField: "userId",   // orders 里的关联字段
      foreignField: "_id",    // users 里的关联字段
      as: "userInfo"          // 关联结果放进 userInfo 数组
    }
  }
])
​

结果:

{
  "_id": 1,
  "item": "键盘",
  "userId": 101,
  "userInfo": [ { "_id": 101, "name": "老王" } ]
}
​

userInfo 是数组,取第一个用 $arrayElemAt 或者后面接 $unwind。

配合 $unwind 展开数组

关联结果是数组,如果确定只会匹配一条,可以用 $unwind 把数组展开成单条文档,更方便后续处理:

db.orders.aggregate([
  {
    $lookup: {
      from: "users",
      localField: "userId",
      foreignField: "_id",
      as: "userInfo"
    }
  },
  {
    // preserveNullAndEmptyArrays: true 保留没有匹配的文档
    $unwind: { path: "$userInfo", preserveNullAndEmptyArrays: true }
  }
])
​

展开后 userInfo 就变成一个对象而不是数组了,直接用 userInfo.name 取字段。

preserveNullAndEmptyArrays: true 很重要——没有这个的话,关联不上的文档会被直接丢掉,相当于变成 INNER JOIN 了。

带条件的关联(pipeline 写法)

MongoDB 3.6 之后,$lookup 支持用 pipeline 替代简单的字段匹配,可以在关联时做更复杂的过滤。

比如只想关联状态为 active 的用户:

db.orders.aggregate([
  {
    $lookup: {
      from: "users",
      let: { ordUserId: "$userId" },  // 把当前文档的字段暴露出来
      pipeline: [
        {
          $match: {
            $expr: {
              $and: [
                { $eq: ["$$ordUserId", "$_id"] },  // 关联条件
                { $eq: ["$status", "active"] }       // 额外过滤条件
              ]
            }
          }
        }
      ],
      as: "userInfo"
    }
  }
])
​

let 把当前文档的字段传进 pipeline,在 pipeline 里用 $$变量名 引用(两个 $),目标集合的字段还是一个 $。

这个写法刚开始确实有点绕,记住:$$ 是传进来的变量,$ 是目标集合的字段。

嵌套关联

一个 $lookup 可以嵌套在另一个 $lookup 的 pipeline 里,实现三层甚至更深的关联。

不过说实话,嵌套超过两层之后管道会变得非常难读,性能也会下降明显。

我个人的做法是:嵌套超过两层,就在应用层做多次查询然后自己拼装,比在数据库里搞三层嵌套 $lookup 好维护得多。

几个常见的坑

坑一:关联字段类型不匹配

MongoDB 是严格类型的,userId: 101(Number)和 userId: "101"(String)不会匹配。

存进去的时候什么类型,关联的时候就得是同一类型,不然结果是空数组,而且不报错,很难排查。

坑二:$lookup 在分片集合上的限制

from 的目标集合如果是分片集合,MongoDB 会有一些限制。具体来说,$lookup 要求 from 集合要么没有分片,要么分片键就是 _id。不满足条件会直接报错。

坑三:大集合关联性能差

$lookup 本质上是嵌套循环(Nested Loop Join),没有 SQL 那种 Hash Join 或 Merge Join 优化。

关联字段上一定要建索引,尤其是 foreignField 对应的字段。

可以用 db.collection.aggregate([...]).explain() 看执行计划,确认关联有没有走索引。

坑四:结果集膨胀

一对多关联如果不加过滤,结果集可能会很大,吃内存。聚合管道的内存限制默认是 100MB,超了会报错。

可以在 $lookup 之前先用 $match 过滤掉不需要的文档,减少参与关联的数据量。

和 SQL JOIN 的差异

用惯了 SQL 再看 $lookup,有几点要调整认知:

  • SQL JOIN 结果是平铺的,$lookup 结果是嵌套数组,需要 $unwind 展开
  • SQL 有 INNER/LEFT/RIGHT/FULL JOIN,$lookup 本质是左连接,没有匹配的右侧是空数组
  • SQL 的 JOIN 经过了几十年优化,$lookup 性能在大数据量下差距明显
  • SQL 可以 JOIN 多张表一起写,$lookup 要分多个阶段串联

不是说 $lookup 不好,只是用场景不太一样。MongoDB 的设计初衷是把关联数据直接嵌进文档里(内嵌文档),$lookup 更像是一个补丁,处理那些没法完全内嵌的情况。

小结

$lookup 用法本身不复杂,难点在于:

  • 记住结果是数组,配合 $unwind 处理
  • 复杂条件用 pipeline 写法,注意 $$ 和 $ 的区别
  • 关联字段类型要对齐,foreignField 上要建索引
  • 大数据量关联之前先 $match 减量

关联逻辑不复杂的情况下,用起来还是很顺手的。

更多推荐

章节目录