一、聚合管道核心概念
1. 什么是聚合管道?
聚合管道是 MongoDB 中执行数据聚合与转换的核心机制。它通过一系列有序的阶段(Stage)对文档进行逐步处理:每个阶段接收上一阶段输出的文档流,执行特定转换后,将结果传递给下一阶段。
基本语法如下:
javascript
db.collection.aggregate([
{ $stage1: { ... } },
{ $stage2: { ... } },
// ... 更多阶段
])
每个阶段都是一个 JSON 对象,键名为阶段名称(如 $match),值为具体的配置参数。阶段之间的顺序至关重要------上游的输出决定了下游的输入。
2. 聚合管道 vs. MapReduce
在 MongoDB 的演进历程中,聚合管道是 MapReduce 的现代替代方案。与需要编写 JavaScript 函数的 MapReduce 相比,聚合管道采用声明式语法,性能更优,可用性更强。MongoDB 官方文档明确指出,聚合管道提供了比 MapReduce 操作更好的性能和可用性。
二、$match------数据过滤的守门人
1. 功能定义
$match 阶段的作用是对文档流进行过滤,只允许匹配条件的文档通过并进入下一阶段。它的查询语法与 find() 方法完全一致。
语法:
javascript
{ $match: { <query> } }
对于每个输入文档,$match 输出一个文档(匹配)或零个文档(不匹配)。如果查询谓词返回 0、null 或 false,或者查询使用了文档中不存在的字段,该文档将被过滤掉。
2. 基础用法示例
假设有一个订单集合 orders:
javascript
db.orders.insertMany([
{ "_id": 1, "userId": "u001", "amount": 100, "status": "paid" },
{ "_id": 2, "userId": "u002", "amount": 200, "status": "pending" },
{ "_id": 3, "userId": "u001", "amount": 150, "status": "paid" }
])
筛选所有状态为 "paid" 且金额大于等于 120 的订单:
javascript
db.orders.aggregate([
{ $match: { status: "paid", amount: { $gte: 120 } } }
])
输出:
javascript
[ { "_id": 3, "userId": "u001", "amount": 150, "status": "paid" } ]
3. 与 find() 的对比
$match 的查询语法与 find() 完全相同,但二者在聚合管道中的定位有本质区别:
| 特性 | find() |
$match(聚合中) |
|---|---|---|
| 用途 | 直接查询并返回结果 | 作为管道中的一个过滤阶段 |
| 后续处理 | 无 | 结果传递给下一阶段继续处理 |
| 索引利用 | 支持 | 同样支持 |
4. 性能优化:黄金法则
尽早放置 $match 是聚合管道性能优化的第一原则。由于 $match 限制了聚合管道中的文档总数,越早执行 $match,后续阶段需要处理的数据量就越小。
以一个包含 1000 万条日志记录的集合为例:如果先执行 $group 再执行 $match,数据库需要先对全部 1000 万条记录进行分组计算;而如果先执行 $match 将数据过滤到 10 万条再分组,性能提升可达百倍。
5. 索引策略
$match 阶段可以充分利用索引来加速过滤:
- 单字段索引:对高频过滤字段创建单字段索引
- 复合索引:遵循最左前缀原则,将高选择性、等值过滤的字段放在前面
- 覆盖索引 :如果
$match只涉及查询条件字段,可考虑创建覆盖索引,直接从索引返回结果,避免回表
javascript
// 为高频过滤字段创建复合索引
db.orders.createIndex({ status: 1, userId: 1 })
三、$project------文档重塑的手术刀
1. 功能定义
$project 阶段将带有请求字段的文档传递到管道中的下一个阶段。指定的字段可以是输入文档中的已有字段,也可以是新计算的字段。
语法:
javascript
{ $project: { <specification(s)> } }
$project 的规范有以下几种形式:
| 形式 | 说明 |
|---|---|
<field>: 1 或 true |
包含该字段(非零整数也视为 true) |
_id: 0 或 false |
排除 _id 字段 |
<field>: <expression> |
添加新字段或重置现有字段的值 |
<field>: 0 或 false |
排除该字段(除 _id 外不能与其他包含操作混用) |
重要限制 :如果指定排除 _id 以外的字段,则不能在同一 $project 中使用任何其他规范形式。
2. 包含与排除字段
默认情况下,_id 字段会包含在输出文档中。
示例:选择特定字段,排除 _id:
javascript
db.books.aggregate([
{ $match: { year: { $gt: 2010 } } },
{ $project: { title: 1, year: 1, pages: 1, _id: 0 } }
])
对应的 SQL 语句:
sql
SELECT title, year, pages FROM books WHERE year > 2010;
3. 计算新字段
$project 最强大的能力之一是使用表达式计算新字段。
示例:计算作者数量:
javascript
db.books.aggregate([
{ $project: {
title: 1,
authors: 1,
authorCount: { $size: "$authors" }
} }
])
示例:字段重命名与计算:
javascript
db.orders.aggregate([
{ $project: {
orderNumber: 1,
total: 1,
customerName: "$customer.name", // 重命名字段
year: { $year: "$orderDate" }, // 提取年份
_id: 0
} }
])
示例:复杂计算(税费 + 总计):
javascript
db.orders.aggregate([
{ $project: {
total: 1,
tax: { $multiply: ["$total", 0.1] },
grandTotal: { $add: ["$total", { $multiply: ["$total", 0.1] }] }
} }
])
4. 数组字段操作
$project 阶段支持使用方括号 [] 直接创建新的数组字段。
5. 性能考量
关于 $project 的放置位置,有一个重要的性能认知:
$project 通常应该是管道的最后一个阶段,用于指定要返回给客户端的字段。
在管道的开头或中间使用 $project 来减少传递给后续阶段的字段数量,不太可能提高性能,因为 MongoDB 的优化器会自动执行此优化。
6. 与 SQL SELECT 的类比
对于从 SQL 转向 MongoDB 的开发者来说,$project 在聚合管道中的角色,类似于 SQL 中的 SELECT 语句------它控制输出中包含哪些字段以及它们的格式。
四、match 与 project 的协同作战
1. 典型管道模式
在实际业务中,$match 和 $project 几乎总是搭配使用:
javascript
db.orders.aggregate([
{ $match: { status: "completed", total: { $gte: 100 } } }, // 先过滤
{ $project: { orderId: 1, total: 1, customerName: 1, _id: 0 } } // 后投影
])
2. 管道序列优化
MongoDB 的优化器会自动对管道进行重塑以提高性能。其中一项关键优化是:
如果聚合管道包含投影阶段($project、$addFields、$set 或 $unset),且其后跟随 $match 阶段,MongoDB 会将 $match 阶段中无需使用投影阶段计算的值 的所有过滤器,移动到投影之前的新的 $match 阶段。
这意味着 :即使你在代码中将 $project 写在 $match 之前,优化器也可能自动将不依赖投影结果的过滤条件前置,从而提升性能。
如果管道包含多个投影或 $match 阶段,MongoDB 会对每个 $match 阶段执行此优化,将每个过滤器移到它不依赖的所有投影阶段之前。
3. 完整执行流程图

五、实战案例
案例一:电商订单报表
需求:从订单集合中筛选出 2024 年已完成且金额大于 500 的订单,输出订单号、客户名、金额和下单年份。
javascript
db.orders.aggregate([
{
$match: {
status: "completed",
amount: { $gt: 500 },
orderDate: {
$gte: ISODate("2024-01-01"),
$lt: ISODate("2025-01-01")
}
}
},
{
$project: {
orderId: 1,
customerName: 1,
amount: 1,
orderYear: { $year: "$orderDate" },
_id: 0
}
}
])
案例二:用户行为分析
需求:从用户日志中筛选出特定用户的行为记录,只保留时间、操作类型和 IP 地址,并计算操作次数的排名标识。
javascript
db.userLogs.aggregate([
{
$match: {
userId: "user123",
createdAt: { $gte: ISODate("2024-01-01") }
}
},
{
$project: {
action: 1,
timestamp: "$createdAt",
ip: "$clientIP",
_id: 0
}
}
])
六、常见错误与避坑指南
错误一:$match 放置过晚
问题 :在 $group 或 $lookup 之后才执行 $match,导致大量无关数据进入昂贵的计算阶段。
解决 :将 $match 尽可能前置,作为管道的第一或第二个阶段。
错误二:混淆 $project 的包含与排除规则
问题 :在同一 $project 中同时使用字段包含(field: 1)和字段排除(field: 0,除 _id 外)。
解决 :除 _id 外,包含和排除不能混用。要么只列需要包含的字段(_id 可单独排除),要么只列需要排除的字段。
错误三:在管道中间滥用 $project
问题 :过早使用 $project 试图"减少数据量"以提高性能。
解决 :MongoDB 优化器会自动处理字段裁剪。$project 通常应放在管道的最后阶段。
错误四:$match 条件未建索引
问题 :$match 的过滤字段没有建立索引,导致全表扫描。
解决 :使用 explain() 分析执行计划,为高频过滤字段创建合适的索引。
错误五:在 project 中计算后再用 match 过滤计算结果
问题 :先 $project 计算新字段,再用 $match 过滤该字段,而该过滤条件本可以直接应用于原始字段。
解决:优化器会自动将不依赖计算结果的过滤条件前置。但如果过滤条件确实依赖计算结果,则无法优化,需评估是否有更优的写法。
记住两个核心原则:$match 尽早放,$project 最后放 。在此基础上,善用索引、善用 explain()、善用 MongoDB 优化器的自动优化能力,你的聚合查询将既优雅又高效。
七、高频面试题
Q1:聚合管道与 MapReduce 的主要区别是什么?
A:聚合管道使用声明式语法,性能更好,可用性更强;MapReduce 需要编写 JavaScript 函数,灵活性更高但性能较差。MongoDB 官方推荐优先使用聚合管道。
Q2:$match 在聚合管道中应放在什么位置?为什么?
A :应尽可能早地放在聚合管道中。因为 $match 限制了聚合管道中的文档总数,越早执行就越能最小化后续阶段需要处理的数据量。
Q3:$project 阶段应该放在管道的什么位置?
A :$project 通常应该是管道的最后一个阶段,用于指定要返回给客户端的字段。在管道开头或中间使用 $project 来减少字段数量不太可能提高性能,因为 MongoDB 优化器会自动执行此优化。
Q4:如何调试和优化聚合管道的性能?
A :使用 explain("executionStats") 查看详细的执行计划。重点关注:
$match是否使用了索引- 各阶段处理的文档数量
- 是否存在内存溢出风险(超过 100MB 时需启用
allowDiskUse: true)
Q5:$project 中字段包含(1)和排除(0)能否混用?
A :除 _id 字段外,包含和排除不能在同一 $project 中混用。要么只指定需要包含的字段(并单独排除 _id),要么只指定需要排除的字段。