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减量
关联逻辑不复杂的情况下,用起来还是很顺手的。
