MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

MongoDB $lookup阶段实现数据联合查询

2023-04-066.1k 阅读

一、MongoDB 数据联合查询概述

在关系型数据库中,我们常常通过 JOIN 操作来关联多个表的数据,以获取更丰富、更完整的信息。然而,MongoDB 作为非关系型数据库,并没有传统意义上的 JOIN 操作。这是因为 MongoDB 的设计理念基于文档存储,数据结构更加灵活,各个集合(相当于关系型数据库中的表)之间的关系不像关系型数据库那样紧密。

但在实际应用场景中,我们经常会遇到需要从多个集合中获取相关联数据的需求。例如,在一个电商系统中,我们可能有一个 products 集合存储商品信息,另一个 reviews 集合存储商品的评论信息。为了展示某个商品及其所有评论,就需要将这两个集合的数据关联起来。

MongoDB 提供了 $lookup 阶段来实现类似 JOIN 的功能,通过在聚合管道中使用 $lookup,我们可以将来自不同集合的数据进行联合查询。它允许我们基于特定的条件,将一个集合(被称为 “本地集合”)中的文档与另一个集合(被称为 “外部集合”)中的文档进行匹配,并将匹配结果添加到本地集合的文档中。

二、$lookup 语法详解

$lookup 的基本语法如下:

{
    $lookup:
    {
        from: <collection to join>,
        localField: <field from the input documents>,
        foreignField: <field from the documents of the "from" collection>,
        as: <output array field>
    }
}
  • from:指定要关联的外部集合名称。
  • localField:本地集合中用于匹配的字段。
  • foreignField:外部集合中用于匹配的字段。
  • as:指定一个新的数组字段名,用于存储关联匹配的结果。

(一)简单匹配示例

假设我们有两个集合 orderscustomersorders 集合存储订单信息,其中包含一个 customerId 字段,用来关联客户信息。customers 集合存储客户信息,包含 _id 字段作为客户的唯一标识。

orders 集合示例文档:

[
    {
        "_id": ObjectId("64d265b36d7c491c9d0a7527"),
        "orderNumber": "1001",
        "customerId": ObjectId("64d2658e6d7c491c9d0a7522"),
        "orderDate": ISODate("2023-11-01T00:00:00Z")
    },
    {
        "_id": ObjectId("64d265b36d7c491c9d0a7528"),
        "orderNumber": "1002",
        "customerId": ObjectId("64d2658e6d7c491c9d0a7523"),
        "orderDate": ISODate("2023-11-02T00:00:00Z")
    }
]

customers 集合示例文档:

[
    {
        "_id": ObjectId("64d2658e6d7c491c9d0a7522"),
        "customerName": "Alice",
        "email": "alice@example.com"
    },
    {
        "_id": ObjectId("64d2658e6d7c491c9d0a7523"),
        "customerName": "Bob",
        "email": "bob@example.com"
    }
]

要将订单信息与对应的客户信息关联起来,我们可以使用以下聚合管道:

db.orders.aggregate([
    {
        $lookup:
        {
            from: "customers",
            localField: "customerId",
            foreignField: "_id",
            as: "customerInfo"
        }
    }
])

执行上述聚合操作后,结果如下:

[
    {
        "_id": ObjectId("64d265b36d7c491c9d0a7527"),
        "orderNumber": "1001",
        "customerId": ObjectId("64d2658e6d7c491c9d0a7522"),
        "orderDate": ISODate("2023-11-01T00:00:00Z"),
        "customerInfo": [
            {
                "_id": ObjectId("64d2658e6d7c491c9d0a7522"),
                "customerName": "Alice",
                "email": "alice@example.com"
            }
        ]
    },
    {
        "_id": ObjectId("64d265b36d7c491c9d0a7528"),
        "orderNumber": "1002",
        "customerId": ObjectId("64d2658e6d7c491c9d0a7523"),
        "orderDate": ISODate("2023-11-02T00:00:00Z"),
        "customerInfo": [
            {
                "_id": ObjectId("64d2658e6d7c491c9d0a7523"),
                "customerName": "Bob",
                "email": "bob@example.com"
            }
        ]
    }
]

在这个例子中,$lookup 根据 orders 集合中的 customerId 字段和 customers 集合中的 _id 字段进行匹配,并将匹配到的客户信息以数组的形式存储在 customerInfo 字段中。

(二)多字段匹配

有时候,我们可能需要基于多个字段进行匹配。例如,在一个员工考勤系统中,有 attendance 集合记录员工的考勤信息,包含 employeeIddate 字段,employees 集合记录员工基本信息,包含 _id 字段。我们希望在查询考勤信息时,同时关联上员工的基本信息,并且确保日期也匹配(假设员工在不同日期的考勤记录对应不同的基本信息状态,比如员工在某个日期可能处于休假状态,其基本信息会有相应标注)。

attendance 集合示例文档:

[
    {
        "_id": ObjectId("64d2669d6d7c491c9d0a752b"),
        "employeeId": ObjectId("64d2667f6d7c491c9d0a7529"),
        "date": ISODate("2023-11-05T00:00:00Z"),
        "status": "present"
    },
    {
        "_id": ObjectId("64d2669d6d7c491c9d0a752c"),
        "employeeId": ObjectId("64d2667f6d7c491c9d0a752a"),
        "date": ISODate("2023-11-06T00:00:00Z"),
        "status": "absent"
    }
]

employees 集合示例文档:

[
    {
        "_id": ObjectId("64d2667f6d7c491c9d0a7529"),
        "employeeName": "Charlie",
        "department": "HR",
        "date": ISODate("2023-11-05T00:00:00Z")
    },
    {
        "_id": ObjectId("64d2667f6d7c491c9d0a752a"),
        "employeeName": "David",
        "department": "IT",
        "date": ISODate("2023-11-06T00:00:00Z")
    }
]

我们可以通过以下方式进行多字段匹配:

db.attendance.aggregate([
    {
        $lookup:
        {
            from: "employees",
            let: { localEmployeeId: "$employeeId", localDate: "$date" },
            pipeline: [
                {
                    $match:
                    {
                        $expr:
                        {
                            $and: [
                                { $eq: ["$_id", "$$localEmployeeId"] },
                                { $eq: ["$date", "$$localDate"] }
                            ]
                        }
                    }
                }
            ],
            as: "employeeDetails"
        }
    }
])

这里使用了 let 来定义局部变量 localEmployeeIdlocalDate,并在 pipeline 中使用 $match$expr 进行多字段匹配。$expr 操作符允许在 $match 中使用表达式,$$localEmployeeId$$localDate 是对 let 定义的局部变量的引用。

执行结果如下:

[
    {
        "_id": ObjectId("64d2669d6d7c491c9d0a752b"),
        "employeeId": ObjectId("64d2667f6d7c491c9d0a7529"),
        "date": ISODate("2023-11-05T00:00:00Z"),
        "status": "present",
        "employeeDetails": [
            {
                "_id": ObjectId("64d2667f6d7c491c9d0a7529"),
                "employeeName": "Charlie",
                "department": "HR",
                "date": ISODate("2023-11-05T00:00:00Z")
            }
        ]
    },
    {
        "_id": ObjectId("64d2669d6d7c491c9d0a752c"),
        "employeeId": ObjectId("64d2667f6d7c491c9d0a752a"),
        "date": ISODate("2023-11-06T00:00:00Z"),
        "status": "absent",
        "employeeDetails": [
            {
                "_id": ObjectId("64d2667f6d7c491c9d0a752a"),
                "employeeName": "David",
                "department": "IT",
                "date": ISODate("2023-11-06T00:00:00Z")
            }
        ]
    }
]

三、$lookup 高级应用

(一)左外连接模拟

在关系型数据库中,左外连接会返回左表中的所有记录,即使在右表中没有匹配的记录。在 MongoDB 的 $lookup 中,默认行为类似左外连接,即本地集合中的所有文档都会出现在结果集中,即使在外部集合中没有匹配的文档。当没有匹配时,as 指定的数组字段将为空数组。

例如,我们有 students 集合和 scores 集合。students 集合存储学生基本信息,scores 集合存储学生的考试成绩信息。有些学生可能还没有参加考试,即 scores 集合中没有对应的记录。

students 集合示例文档:

[
    {
        "_id": ObjectId("64d2676a6d7c491c9d0a752f"),
        "studentName": "Eve",
        "grade": "10th"
    },
    {
        "_id": ObjectId("64d2676a6d7c491c9d0a7530"),
        "studentName": "Frank",
        "grade": "11th"
    }
]

scores 集合示例文档:

[
    {
        "_id": ObjectId("64d2674e6d7c491c9d0a752d"),
        "studentId": ObjectId("64d2676a6d7c491c9d0a752f"),
        "subject": "Math",
        "score": 85
    }
]

使用 $lookup 进行关联:

db.students.aggregate([
    {
        $lookup:
        {
            from: "scores",
            localField: "_id",
            foreignField: "studentId",
            as: "studentScores"
        }
    }
])

执行结果:

[
    {
        "_id": ObjectId("64d2676a6d7c491c9d0a752f"),
        "studentName": "Eve",
        "grade": "10th",
        "studentScores": [
            {
                "_id": ObjectId("64d2674e6d7c491c9d0a752d"),
                "studentId": ObjectId("64d2676a6d7c491c9d0a752f"),
                "subject": "Math",
                "score": 85
            }
        ]
    },
    {
        "_id": ObjectId("64d2676a6d7c491c9d0a7530"),
        "studentName": "Frank",
        "grade": "11th",
        "studentScores": []
    }
]

这里可以看到,即使 Frank 没有考试成绩(在 scores 集合中没有匹配记录),他的信息仍然出现在结果集中,并且 studentScores 字段为空数组。

(二)自连接

自连接是指在同一个集合上进行关联操作。假设我们有一个 employees 集合,其中每个员工文档包含一个 managerId 字段,用于指定该员工的经理。我们可以使用 $lookup 来实现自连接,以获取每个员工及其经理的信息。

employees 集合示例文档:

[
    {
        "_id": ObjectId("64d2682d6d7c491c9d0a7533"),
        "employeeName": "Grace",
        "managerId": ObjectId("64d268166d7c491c9d0a7531")
    },
    {
        "_id": ObjectId("64d268166d7c491c9d0a7531"),
        "employeeName": "Hank",
        "managerId": null
    }
]

聚合操作如下:

db.employees.aggregate([
    {
        $lookup:
        {
            from: "employees",
            localField: "managerId",
            foreignField: "_id",
            as: "managerInfo"
        }
    }
])

执行结果:

[
    {
        "_id": ObjectId("64d2682d6d7c491c9d0a7533"),
        "employeeName": "Grace",
        "managerId": ObjectId("64d268166d7c491c9d0a7531"),
        "managerInfo": [
            {
                "_id": ObjectId("64d268166d7c491c9d0a7531"),
                "employeeName": "Hank",
                "managerId": null
            }
        ]
    },
    {
        "_id": ObjectId("64d268166d7c491c9d0a7531"),
        "employeeName": "Hank",
        "managerId": null,
        "managerInfo": []
    }
]

在这个例子中,Grace 的 managerInfo 数组中包含了她的经理 Hank 的信息,而 Hank 没有经理,所以他的 managerInfo 数组为空。

(三)多层嵌套 $lookup

在复杂的业务场景中,可能需要进行多层嵌套的 $lookup。例如,在一个项目管理系统中,有 projects 集合、tasks 集合和 employees 集合。projects 集合包含项目信息,tasks 集合包含项目中的任务信息,并且通过 projectIdprojects 集合关联。employees 集合包含员工信息,tasks 集合通过 assignedTo 字段与 employees 集合关联,以指定任务的负责人。

projects 集合示例文档:

[
    {
        "_id": ObjectId("64d268f06d7c491c9d0a7536"),
        "projectName": "Project A"
    }
]

tasks 集合示例文档:

[
    {
        "_id": ObjectId("64d268d76d7c491c9d0a7534"),
        "taskName": "Task 1",
        "projectId": ObjectId("64d268f06d7c491c9d0a7536"),
        "assignedTo": ObjectId("64d268c46d7c491c9d0a7532")
    }
]

employees 集合示例文档:

[
    {
        "_id": ObjectId("64d268c46d7c491c9d0a7532"),
        "employeeName": "Ivy"
    }
]

我们可以通过以下多层嵌套 $lookup 来获取项目及其包含的任务,以及任务负责人的信息:

db.projects.aggregate([
    {
        $lookup:
        {
            from: "tasks",
            localField: "_id",
            foreignField: "projectId",
            as: "tasksList"
        }
    },
    {
        $unwind: "$tasksList"
    },
    {
        $lookup:
        {
            from: "employees",
            localField: "tasksList.assignedTo",
            foreignField: "_id",
            as: "tasksList.assignedEmployee"
        }
    },
    {
        $group:
        {
            _id: "$_id",
            projectName: { $first: "$projectName" },
            tasksList: { $push: "$tasksList" }
        }
    }
])

在这个聚合管道中,首先通过第一个 $lookuptasks 集合关联到 projects 集合,然后使用 $unwind 展开 tasksList 数组,以便进行下一层的 $lookup。第二个 $lookupemployees 集合关联到 tasksList 中的每个任务文档,并将结果存储在 tasksList.assignedEmployee 字段中。最后,使用 $group 操作将结果重新组合成合适的格式。

执行结果:

[
    {
        "_id": ObjectId("64d268f06d7c491c9d0a7536"),
        "projectName": "Project A",
        "tasksList": [
            {
                "_id": ObjectId("64d268d76d7c491c9d0a7534"),
                "taskName": "Task 1",
                "projectId": ObjectId("64d268f06d7c491c9d0a7536"),
                "assignedTo": ObjectId("64d268c46d7c491c9d0a7532"),
                "assignedEmployee": [
                    {
                        "_id": ObjectId("64d268c46d7c491c9d0a7532"),
                        "employeeName": "Ivy"
                    }
                ]
            }
        ]
    }
]

四、$lookup 的性能考虑

(一)索引的重要性

在使用 $lookup 时,为 localFieldforeignField 建立索引可以显著提高查询性能。如果没有索引,MongoDB 需要对整个集合进行全表扫描来查找匹配的文档,这在大数据量的情况下会非常耗时。

例如,在前面的 orderscustomers 关联示例中,为 orders 集合的 customerId 字段和 customers 集合的 _id 字段建立索引:

db.orders.createIndex({ customerId: 1 });
db.customers.createIndex({ _id: 1 });

这样在执行 $lookup 操作时,MongoDB 可以利用索引快速定位匹配的文档,从而提高查询效率。

(二)数据量与性能

随着数据量的增加,$lookup 的性能会受到影响。特别是在多层嵌套 $lookup 或关联的集合数据量非常大时,性能问题会更加明显。在这种情况下,可以考虑以下几种优化方法:

  1. 数据分区:将大集合按照某种规则(如时间、地理位置等)进行分区,减少单次查询的数据量。
  2. 减少不必要的字段:在 $lookup 操作中,只选择需要的字段进行关联和返回,避免返回过多冗余数据。例如,在 $lookuppipeline 中使用 $project 操作符来选择特定字段。
  3. 优化查询逻辑:尽量简化查询逻辑,避免复杂的多层嵌套和不必要的关联。

(三)内存使用

$lookup 操作可能会消耗较多的内存,尤其是在处理大数据量和复杂关联时。MongoDB 在执行聚合管道时,会在内存中处理数据。如果数据量超过了可用内存,可能会导致性能下降甚至查询失败。

为了避免内存问题,可以考虑以下几点:

  1. 分批处理:将大数据集分成多个小批次进行处理,减少单次操作的数据量。
  2. 调整内存参数:根据服务器的硬件配置,合理调整 MongoDB 的内存相关参数,如 wiredTigerCacheSizeGB,以确保有足够的内存用于聚合操作。

五、$lookup 与其他聚合操作的结合使用

(一)$lookup 与 $match

$match 操作符用于过滤文档,在使用 $lookup 之前或之后使用 $match 可以有效地减少参与关联的数据量,从而提高性能。

例如,在 productsreviews 关联的场景中,如果我们只想获取某个特定分类的产品及其评论,可以先使用 $match 过滤出该分类的产品,然后再进行 $lookup 操作。

假设 products 集合有一个 category 字段表示产品分类。

db.products.aggregate([
    {
        $match: { category: "Electronics" }
    },
    {
        $lookup:
        {
            from: "reviews",
            localField: "_id",
            foreignField: "productId",
            as: "productReviews"
        }
    }
])

这样,只有分类为 “Electronics” 的产品会参与 $lookup 操作,减少了不必要的数据处理。

(二)$lookup 与 $project

$project 操作符用于选择和重命名文档中的字段。在 $lookup 之后使用 $project,可以对关联后的结果进行进一步的处理,只返回需要的字段,减少数据传输和存储的开销。

例如,在获取订单及其客户信息后,我们可能只需要订单编号、客户名称和订单日期,而不需要其他详细信息。

db.orders.aggregate([
    {
        $lookup:
        {
            from: "customers",
            localField: "customerId",
            foreignField: "_id",
            as: "customerInfo"
        }
    },
    {
        $project:
        {
            orderNumber: 1,
            customerName: "$customerInfo.customerName",
            orderDate: 1,
            _id: 0
        }
    }
])

这里使用 $project 选择了 orderNumbercustomerNameorderDate 字段,并通过 _id: 0 排除了 _id 字段。同时,从 customerInfo 数组中提取了 customerName 字段。

(三)$lookup 与 $group

$group 操作符用于按照指定的字段对文档进行分组,并可以对分组后的数据进行聚合计算。在 $lookup 之后使用 $group,可以对关联后的结果进行分组统计。

例如,在获取产品及其评论后,我们想统计每个产品的评论数量。

db.products.aggregate([
    {
        $lookup:
        {
            from: "reviews",
            localField: "_id",
            foreignField: "productId",
            as: "productReviews"
        }
    },
    {
        $group:
        {
            _id: "$_id",
            productName: { $first: "$productName" },
            reviewCount: { $size: "$productReviews" }
        }
    }
])

这里通过 $group 操作,使用 $size 操作符统计了每个产品的评论数量,并将结果存储在 reviewCount 字段中。

六、实际应用场景分析

(一)电商系统中的应用

在电商系统中,$lookup 有广泛的应用。比如,我们有 products 集合存储商品信息,orders 集合存储订单信息,order_items 集合存储订单中的商品明细信息。

假设我们要查询某个订单及其包含的商品信息,可以通过以下聚合操作实现:

db.orders.aggregate([
    {
        $match: { orderNumber: "12345" }
    },
    {
        $lookup:
        {
            from: "order_items",
            localField: "_id",
            foreignField: "orderId",
            as: "orderItems"
        }
    },
    {
        $unwind: "$orderItems"
    },
    {
        $lookup:
        {
            from: "products",
            localField: "orderItems.productId",
            foreignField: "_id",
            as: "orderItems.productInfo"
        }
    },
    {
        $group:
        {
            _id: "$_id",
            orderNumber: { $first: "$orderNumber" },
            orderDate: { $first: "$orderDate" },
            orderItems: { $push: "$orderItems" }
        }
    }
])

这个聚合管道首先通过 $match 过滤出指定订单编号的订单,然后通过两次 $lookup 操作分别关联 order_items 集合和 products 集合,获取订单中的商品明细和商品详细信息。

(二)社交网络系统中的应用

在社交网络系统中,我们有 users 集合存储用户信息,friends 集合存储用户之间的好友关系,posts 集合存储用户发布的帖子信息。

假设我们要查询某个用户及其好友发布的帖子,可以这样操作:

db.users.aggregate([
    {
        $match: { username: "john_doe" }
    },
    {
        $lookup:
        {
            from: "friends",
            localField: "_id",
            foreignField: "userId",
            as: "friendsList"
        }
    },
    {
        $unwind: "$friendsList"
    },
    {
        $lookup:
        {
            from: "posts",
            let: { friendId: "$friendsList.friendId" },
            pipeline: [
                {
                    $match:
                    {
                        $expr: { $eq: ["$userId", "$$friendId"] }
                    }
                }
            ],
            as: "friendsPosts"
        }
    },
    {
        $group:
        {
            _id: "$_id",
            username: { $first: "$username" },
            friendsPosts: { $push: "$friendsPosts" }
        }
    }
])

这里先通过 $match 找到指定用户,然后通过 $lookup 获取该用户的好友列表,再通过另一个 $lookup 结合 pipeline 找到好友发布的帖子。

(三)日志分析系统中的应用

在日志分析系统中,我们有 logs 集合存储日志记录,services 集合存储服务信息。日志记录中包含一个 serviceId 字段,用于关联对应的服务。

假设我们要统计每个服务的日志数量,可以这样做:

db.services.aggregate([
    {
        $lookup:
        {
            from: "logs",
            localField: "_id",
            foreignField: "serviceId",
            as: "serviceLogs"
        }
    },
    {
        $group:
        {
            _id: "$_id",
            serviceName: { $first: "$serviceName" },
            logCount: { $size: "$serviceLogs" }
        }
    }
])

通过 $lookuplogs 集合关联到 services 集合,然后使用 $group$size 统计每个服务的日志数量。

七、$lookup 相关常见问题及解决方法

(一)匹配不到预期结果

  1. 原因分析
    • 字段类型不匹配:例如,本地集合中的 localField 是字符串类型,而外部集合中的 foreignField 是 ObjectId 类型,这会导致匹配失败。
    • 数据不一致:可能存在数据录入错误,导致本该匹配的字段值不一致,如拼写错误等。
  2. 解决方法
    • 检查字段类型:使用 db.collection.find().pretty() 查看文档结构,确保 localFieldforeignField 的数据类型一致。如果不一致,可以考虑在聚合管道中使用 $convert 操作符进行类型转换。
    • 数据清洗:对数据进行清洗,检查并修正不一致的数据值,确保匹配字段的准确性。

(二)性能问题导致查询缓慢

  1. 原因分析
    • 缺少索引:如前文所述,没有为 localFieldforeignField 建立索引,会导致全表扫描,性能低下。
    • 大数据量和复杂关联:多层嵌套 $lookup 或关联的集合数据量巨大,增加了计算和内存开销。
  2. 解决方法
    • 建立索引:为相关字段建立索引,提高查询效率。
    • 优化查询逻辑:尽量简化查询,减少不必要的嵌套和关联。同时,可以考虑数据分区、分批处理等方法,降低大数据量对性能的影响。

(三)内存不足导致查询失败

  1. 原因分析
    • 数据量过大:聚合操作需要在内存中处理数据,如果数据量超过了 MongoDB 分配的内存,就会导致内存不足。
    • 不合理的查询:复杂的 $lookup 操作,尤其是多层嵌套和大量数据关联,可能会消耗过多内存。
  2. 解决方法
    • 调整内存参数:根据服务器硬件配置,合理调整 MongoDB 的内存参数,如 wiredTigerCacheSizeGB,确保有足够的内存用于聚合操作。
    • 分批处理数据:将大数据集分成多个小批次进行处理,减少单次操作的数据量,从而降低内存消耗。

通过深入理解 $lookup 的语法、应用场景、性能考虑以及常见问题的解决方法,我们可以在 MongoDB 中高效地实现数据联合查询,满足各种复杂业务场景的需求。在实际应用中,需要根据具体的数据结构和业务逻辑,灵活运用 $lookup 及其与其他聚合操作的组合,以达到最佳的查询效果。