在 MongoDB 里,单文档的增删改查很简单,但真正体现它价值的往往是”分析型查询”——按用户统计订单数、按天汇总销售额、把订单表和用户表关联起来算客单价。这些需求靠 find() 做不到,要靠聚合管道(Aggregation Pipeline)。本文用一组可复现的示例,带你吃透 $match、$group、$lookup 等核心阶段,并给出与 SQL 的对照和性能避坑清单。
一、什么是聚合管道
聚合管道由多个”阶段(stage)”组成,文档像水一样从第一个阶段流向下一个阶段,每个阶段对输入的文档做转换后,把结果交给下一阶段。阶段的书写顺序就是执行顺序,而 MongoDB 在执行前还会做”管道优化”(例如把 $match 尽量下推到最前面、合并相邻的 $sort 与 $limit),所以养成”先过滤、后计算”的书写习惯,既符合直觉也能让优化器更好发挥作用。
那么什么时候该用聚合管道,而不是把数据拉回应用层用代码循环处理?经验法则是:只要聚合能在数据库侧完成”过滤 + 归约”,就尽量留在数据库。原因有二——其一,网络上只传输归约后的小结果,而不是全量文档,带宽和延迟都更优;其二,MongoDB 的聚合引擎是 C++ 原生实现且能利用索引,通常比你在 Python/Java 里手写循环快一个数量级。只有当逻辑极其复杂、必须调外部服务时,才考虑应用层处理。
1.1 常用阶段速查
| 阶段 | 作用 | 类比 SQL |
|---|---|---|
| $match | 过滤文档 | WHERE |
| $group | 分组并聚合 | GROUP BY + 聚合函数 |
| $project | 选择/重命名字段 | SELECT 列 |
| $sort | 排序 | ORDER BY |
| $limit / $skip | 截断 / 跳过 | LIMIT / OFFSET |
| $lookup | 关联另一集合 | LEFT JOIN |
| $unwind | 展开数组字段 | 一对多行展开 |
| $facet | 单阶段内多分支 | 子查询/窗口函数 |
1.2 累加器运算符一览
| 运算符 | 含义 | 示例 |
|---|---|---|
| $sum | 求和 / 计数 | { $sum: 1 } 计数,{ $sum: “$amount” } 求和 |
| $avg | 平均值 | { $avg: “$amount” } |
| $min / $max | 最小 / 最大 | { $min: “$created_at” } |
| $push | 收集为数组 | { $push: “$user_id” } |
| $addToSet | 去重收集 | { $addToSet: “$city” } |
| $first / $last | 组内首/尾 | 配合 $sort 取极值 |
这些累加器只能出现在 $group 内部,它们对分组后的文档逐条累积。掌握这张表,绝大多数”统计类”需求都能一行搞定;其中 $push 和 $addToSet 特别适合把一对多关系”收拢”成数组,省去应用层再组装的麻烦。
二、第一个管道:$match → $group → $sort
假设有一个 orders 集合,文档结构为 { _id, user_id, status, amount, created_at }。我们要统计”已支付”订单的数量与总金额,并按金额降序排列。最自然的写法就是先过滤、再分组、最后排序:
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $group: {
_id: "$status",
count: { $sum: 1 },
total: { $sum: "$amount" },
avg: { $avg: "$amount" }
}},
{ $sort: { total: -1 } }
])
2.1 按天统计订单金额
把 created_at 用 $dateToString 归约到”天”,就能得到每天的订单数与营收,这对看板类报表非常实用:
db.orders.aggregate([
{ $match: { status: "paid", created_at: { $gte: ISODate("2026-10-01") } } },
{ $group: {
_id: { $dateToString: { format: "%Y-%m-%d", date: "$created_at" } },
orders: { $sum: 1 },
revenue: { $sum: "$amount" }
}},
{ $sort: { _id: 1 } }
])
三、重塑文档:$project 与 $addFields
$project 用来挑选或重命名字段,相当于 SQL 的 SELECT;$addFields 则在不丢弃原有字段的前提下追加新字段。两者常配合表达式运算符使用:
// 仅保留年份与金额,丢弃其它字段
{ $project: { year: { $year: "$created_at" }, amount: 1, _id: 0 } }
// 在原有文档基础上追加 6% 税额
{ $addFields: { tax: { $multiply: ["$amount", 0.06] } } }
当重塑逻辑带分支时,用 $cond 表达式就能在管道里写”如果…否则…”。比如给大额订单打标:
{ $project: {
amount: 1,
tier: {
$cond: {
if: { $gte: ["$amount", 1000] },
then: "VIP",
else: "normal"
}
}
} }
四、多表关联:$lookup 替代 JOIN
文档型数据库没有”外键约束”,但分析时照样要关联。MongoDB 用 $lookup 把另一集合的文档”塞”进当前文档的数组字段,再用 $unwind 展开,从而计算客单价、用户等级等跨集合指标:
db.orders.aggregate([
{ $lookup: {
from: "users",
localField: "user_id",
foreignField: "_id",
as: "user"
}},
{ $unwind: "$user" },
{ $project: {
user_name: "$user.name",
amount: 1,
city: "$user.city",
_id: 0
}},
{ $sort: { amount: -1 } }
])
4.1 $unwind 的两个易错点
第一,$unwind 默认会丢弃数组中为空或不存在的文档,如果你希望”没有关联到的订单也保留”,要加 preserveNullAndEmptyArrays: true。第二,$lookup 的结果永远是数组(即使只命中一条),所以几乎所有 $lookup 之后都要跟一个 $unwind,否则后面取 user.name 会取到 undefined。
性能上要提醒一点:$lookup 默认对右表是”逐文档嵌套循环”,若右表巨大且没有合适索引,会非常慢。MongoDB 5.1 之后支持在 $lookup 的 pipeline 形式里先 $match 再关联,相当于在关联前就缩小右表范围;更早版本则建议给 foreignField 建索引。另外,聚合结果本身也可以物化——把常用的”日报表”写回一张新的集合,用定时任务刷新,查询时直接读这张表,这是高并发场景下的常见优化。
五、分页与性能:$facet 一次出总数 + 分页
传统做法要先查总数再查分页,要发两次聚合。用 $facet 可以在一个管道里同时产出”数据”和”总数”,前端拿到就能直接渲染分页器:
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $facet: {
data: [
{ $sort: { created_at: -1 } },
{ $skip: 0 },
{ $limit: 10 }
],
total: [
{ $count: "count" }
]
}}
])
5.1 内存限制与索引优化
聚合管道默认有 100MB 的内存上限,一旦某个阶段(通常是 $group 或 $sort)超出,就会报 Exceeded memory limit。两种解法:一是在驱动里加 allowDiskUse: true 允许落盘;二是给 $match/$sort 用到的字段建索引,让 MongoDB 直接走索引而不必在内存里大排序。用 .explain("executionStats") 检查 totalKeysExamined 与 usedDisk 字段,就能确认是否真正用到了索引。
在驱动层面,聚合返回的是一个游标(cursor)而非一次性数组,所以即使结果很大,也可以逐批 batchSize 流式消费,避免客户端内存被打爆。这也是为什么聚合既能在数据库侧”算完再给”,也能”边算边给”——按需选择即可。配合上面提到的 allowDiskUse,超大结果的报表任务也能稳妥跑完。
六、和 SQL 怎么对应
| MongoDB 聚合 | 等价 SQL |
|---|---|
| $match | WHERE |
| $group + $sum | GROUP BY + SUM() |
| $sort | ORDER BY |
| $limit / $skip | LIMIT / OFFSET |
| $lookup | LEFT JOIN |
| $facet | 窗口函数 / 子查询 |
如果你更熟悉关系型数据库的窗口函数,可以参考MySQL 8.0 窗口函数与 CTE 实战,两者解决的是同一类”在分组/排序基础上做分析”的问题,只是语法范式不同:SQL 是声明式二维表,MongoDB 是声明式文档流。
七、常见坑与排障清单
| 坑 | 解法 |
|---|---|
| $group 前没有 $match,全表扫描 | 先 $match 缩小数据集 |
| 字符串比较区分大小写 | 用 $toLower 或 collation |
| $lookup 后忘了 $unwind 拿到的是数组 | 紧跟 $unwind 展开 |
| 大聚合报内存超限 | allowDiskUse + 建索引 |
| $project 误删 _id 导致后续关联失败 | 显式保留关联关键字段 |
| 日期分组时区错乱 | $dateToString 指定 timezone |
八、落地建议
想快速搭一套可练习的环境,用Docker Compose 一键编排起一个 MongoDB 容器即可,几行配置就能本地复现上面的所有示例。从架构选型看,文档型数据库的聚合适合”半结构化、嵌套多”的数据(如订单、日志、埋点),而关系型数据库在强一致事务与复杂多表关联上仍更稳妥——两者不是替代关系,而是按业务形状分工。聚合结果若被高频读取,记得用Redis 缓存设计把”天级报表”这类稳定结果缓存起来,避免每次请求都跑全表聚合。
聚合管道本质上是一套”声明式数据处理”语言:你把转换步骤拆成一个个阶段,MongoDB 负责调度执行。掌握 $match、$group、$lookup、$facet 这四块,就足以应对八成以上的分析型查询;再补上索引与内存优化的意识,生产环境也能稳稳跑起来。




