使用 $match、$group 和 $lookup 构建 MongoDB 聚合管道
了解 MongoDB 聚合阶段如何对文档进行过滤、分组、重塑、连接和排序,以及如何将它们串联成能够回答实际报表需求的处理流程。
使用普通的 find() 查询可以获取符合特定条件的文档,但它无法计算每个客户的总收入、将用户信息附加到订单中或生成排序后的报告。为此,MongoDB 提供了聚合框架。本指南将介绍您最常使用的五个阶段、每个阶段中可能存在的陷阱,以及如何将它们组合成一个能够生成整洁且可直接使用结果的管道。
聚合管道的工作原理
聚合是一个按顺序排列的阶段列表。文档首先进入第一个阶段,每个阶段以特定方式处理数据流,该阶段输出的内容会成为下一个阶段的输入。将其视为数据流水线有助于理解各阶段的顺序。
下面的简单管道仅保留已完成的订单,计算每个客户的消费总额,并按支出金额从高到低排序。
db.orders.aggregate([
{ $match: { status: "completed" } },
{ $group: { _id: "$customerId", total: { $sum: "$amount" } } },
{ $sort: { total: -1 } }
]);
以下部分将分别探讨每个阶段的内容。
使用 $match 过滤文档
$match 接受的过滤条件与传递给 find() 的相同。在此示例中,只有那些 status 值为 "completed" 的文档才能进入下一阶段。
{
$match: {
status: "completed"
}
}
应尽可能早地放置 $match。在初始阶段被过滤掉的文档越少,后续阶段需要处理的文档就越多;而且将 $match 放在处理流程的起始处可以利用集合中的索引,而后续阶段则需在索引无法提供帮助的中间结果上进行处理。
使用 $group 进行分组与求和
$group用于收集具有相同键值的文档,并为每个分组计算对应的值。该示例会为每位客户生成一份包含其订单总金额的文档。
{
$group: {
_id: "$customerId",
totalSales: {
$sum: "$amount"
}
}
}
该功能由两部分完成:
_id用于定义分组键;此处即为每份文档的customerId字段值($前缀表示“读取该字段”)。$sum是一个累加器,用于将分组中每份文档的amount值相加。
其他累加器也遵循相同的模式,包括$avg、$min、$max和$count。需注意,$group的输出仅包含_id以及你计算出的字段;原始文档中的其他所有字段都会被移除。
使用$project选择和重新构造字段
$project 决定输出中包含哪些字段,并可创建新字段。将某个字段设置为 1 即会将其包含在输出中,而 _id: 0 则会明确隐藏该标识符,否则该标识符默认会被包含。
{
$project: {
customerId: 1,
totalSales: 1,
_id: 0
}
}
它还可以计算数值。此处 $multiply 通过对 totalSales 应用18%的税率,生成一个 totalWithTax 字段。
{
$project: {
customerId: 1,
totalWithTax: {
$multiply: ["$totalSales", 1.18]
}
}
}
当这些代码片段位于 $group 阶段之后时,有一个细节很重要:客户标识符会存储在 _id 中,而非 customerId 中,因此设置 customerId: 1 是不会输出任何结果的。最终的组合流程通过写入 customerId: "$_id" 来解决此问题,从而重新命名该字段。可将 $project 视为决定 API 返回的最终响应结构的阶段。
使用 $lookup 连接另一个集合
文档通常会引用存储在其他地方的数据。以 orders 集合和 users 集合为例,$lookup 会为每笔订单获取对应的用户信息。
{
$lookup: {
from: "users",
localField: "customerId",
foreignField: "_id",
as: "customer"
}
}
对于每一笔订单,MongoDB 会将该订单的 customerId 与 users 集合中的 _id 进行比对,然后将所有匹配的结果写入名为 customer 的新数组字段中。这便是最接近 SQL 连接的操作方式。即便只有一名用户匹配,结果也始终是一个数组,因此通常会在查询后使用 $unwind 或直接读取第一个元素。同时要确保这两个字段的类型一致:字符串类型的 customerId 无法与 _id 中的 ObjectId 匹配。
使用 $sort 对结果进行排序
$sort 可根据一个或多个字段对文档进行排序。值为 1 时为升序排列,-1 时为降序排列,因此示例中的客户是按照 totalSales 的值从高到低排序的。
{
$sort: {
totalSales: -1
}
}
将多个阶段整合为一份报告
当各个阶段串联起来时,其真正价值才会显现。下面的处理流程可将原始订单转换为按客户及其总销售额排序的列表:
db.orders.aggregate([
{
$match: {
status: "completed"
}
},
{
$group: {
_id: "$customerId",
totalSales: {
$sum: "$amount"
}
}
},
{
$sort: {
totalSales: -1
}
},
{
$project: {
customerId: "$_id",
totalSales: 1,
_id: 0
}
}
]);
具体步骤如下:
- 使用
$match仅保留已完成的订单。 - 使用
$group按客户对订单进行分组。 - 将每位客户的订单金额汇总为
totalSales。 - 按照该总销售额从高到低对客户进行排序。
- 使用
$project将_id重命名为customerId,并从输出结果中删除_id。
若要包含姓名或电子邮件,可对users使用$lookup进行关联。将其放在$group之后以及任何$limit之后,意味着每次会针对每位客户执行一次关联操作,而非每笔订单执行一次。
核心要点
记住这些核心步骤的简明方法:
$match用于过滤。$group用于分组并计算。$project用于选择数据并重新构造结构。$lookup用于关联不同集合的数据。$sort用于对结果进行排序。
一旦超越了基本的CRUD操作,这些方法共同涵盖了报告、仪表板、分析接口以及业务规则所需的大部分功能。执行顺序很重要:尽早过滤数据,一次性进行分组,在数据允许的情况下尽量晚地进行连接操作,最后再确定输出格式。在面试和设计评审中,能够解释每个步骤为何安排在当前位置,比单纯列举各步骤的功能更有说服力。
相关阅读
- 防止Node.js和MongoDB在并发写入时丢失更新 — 了解原子条件更新、基于版本的乐观锁、409响应以及事务如何防止MongoDB的并发写入悄悄丢弃数据。