聚合管道
基本概念
文档流经一系列 stage,每个 stage 对文档做变换,输出作为下一个 stage 的输入。
db.orders.aggregate([
{$match: {status: "paid", createdAt: {$gte: ISODate("2026-01-01")}}},
{$group: {_id: "$userId", total: {$sum: "$amount"}, cnt: {$sum: 1}}},
{$match: {total: {$gt: 1000}}},
{$sort: {total: -1}},
{$limit: 10}
])
对应的 SQL 是 SELECT userId, SUM(amount) ... WHERE ... GROUP BY userId HAVING ... ORDER BY ... LIMIT 10。
注意 $match 出现了两次 —— 第一次相当于 WHERE(在分组前过滤),第二次相当于 HAVING(在分组后过滤)。这个对应关系是常考点。
常用 stage
| Stage | 作用 | SQL 类比 |
|---|---|---|
$match | 过滤 | WHERE / HAVING |
$project | 选择、重命名、计算字段 | SELECT |
$group | 分组聚合 | GROUP BY |
$sort | 排序 | ORDER BY |
$limit / $skip | 分页 | LIMIT / OFFSET |
$lookup | 关联另一个集合 | LEFT OUTER JOIN |
$unwind | 把数组字段拆成多个文档 | 无直接对应 |
$addFields / $set | 新增字段(保留原有) | — |
$count | 计数 | COUNT(*) |
$facet | 同一批数据跑多个子管道 | 多个查询 UNION |
$bucket / $bucketAuto | 分桶统计(直方图) | CASE WHEN 分组 |
$out / $merge | 结果写入集合 | INSERT INTO SELECT |
$graphLookup | 递归查询(树 / 图) | 递归 CTE |
$sample | 随机抽样 | ORDER BY RAND() |
$group 的累加器:$sum $avg $min $max $first $last $push(收集成数组)$addToSet(去重收集)$count。
$unwind 是 MongoDB 特有的概念,必须理解:一个文档的数组有 3 个元素,$unwind 后变成 3 个文档,每个文档的该字段是原数组的一个元素。典型用途是「按数组元素分组统计」—— 比如统计所有订单里每个商品的销量,订单的商品是嵌入数组,就要先 $unwind 再 $group。
注意 $unwind 会放大文档数量,对大数组是性能杀手,且空数组或字段不存在的文档默认会被丢弃(要加 preserveNullAndEmptyArrays: true)。
$lookup:和 JOIN 的差距
db.orders.aggregate([
{$lookup: {
from: "users", // 关联的集合
localField: "userId", // 本集合字段
foreignField: "_id", // 目标集合字段
as: "user" // 结果放进哪个字段(数组!)
}},
{$unwind: "$user"} // 通常要跟一个 unwind 把数组摊平
])
必须知道的限制(高频追问「MongoDB 能 JOIN 吗」):
- 结果是数组 —— 即使只匹配到一条,也要
$unwind或$arrayElemAt取出 - 语义等价于 LEFT OUTER JOIN —— 匹配不到就是空数组,没有 INNER / RIGHT JOIN
- 性能远不如关系库的 JOIN —— 实现上接近「对左表每个文档去右表查一次」的嵌套循环,目标集合的
foreignField必须有索引,否则是灾难 - 分片集群的限制 —— 4.4 之前被关联的集合不能是分片集合;之后支持了但性能更差
- 不能跨数据库
结论:$lookup 是补救手段,不是设计目标。需要频繁 JOIN 说明建模方式不适合 MongoDB —— 要么改成嵌入,要么冗余字段,要么这个场景本来就该用关系库。
$lookup 的子管道写法(3.6+)更灵活,可以在关联时就过滤,减少数据量:
{$lookup: {
from: "orders",
let: {uid: "$_id"},
pipeline: [
{$match: {$expr: {$eq: ["$userId", "$$uid"]}, status: "paid"}},
{$limit: 5}
],
as: "recentOrders"
}}
内存限制与优化
:::warning 每个 stage 的内存上限是 100MB 超限直接报错。两种应对:
allowDiskUse: true—— 允许落盘到临时文件(6.0 起$group和$sort默认就允许溢写磁盘)- 真正的解法是别让数据进到那个 stage —— 把
$match和$limit尽量提前
$group 和 $sort 是最容易超限的两个 stage。
:::
优化原则:
$match必须放第一个 —— 这样才能用上索引(只有管道开头的$match和$sort能用索引),且立刻减少后续数据量$project尽早做 —— 砍掉不需要的字段,减少流经管道的数据体积$sort+$limit要相邻 —— 优化器会合并成 top-k 排序,只保留 k 个元素而不是全量排序,内存从 O(n) 降到 O(k)$lookup尽量靠后 —— 先过滤再关联,别关联完再过滤- 目标集合的关联字段必须有索引
管道优化器会自动做一些重排,比如把 $match 下推到 $lookup 之前、把相邻的 $sort+$limit 合并。用 explain() 看最终的执行计划,不要假设写的顺序就是执行的顺序。
MapReduce 已废弃
MongoDB 5.0 起 MapReduce 被标记为 deprecated,官方明确建议改用聚合管道。
原因:聚合管道是 C++ 原生实现,MapReduce 要走 JavaScript 引擎(SpiderMonkey),性能差一个数量级;且聚合管道的表达能力现在已经完全覆盖 MapReduce。
被问「MapReduce 和聚合管道怎么选」—— 答「选聚合管道,MapReduce 已废弃」就是正确答案。能补充上面的原因更好。
其他实用 stage
$facet —— 一次查询出多组统计。典型场景是列表页同时要「数据列表 + 总数 + 筛选项聚合」:
db.products.aggregate([
{$match: {category: "ring"}},
{$facet: {
data: [{$sort: {price: 1}}, {$skip: 0}, {$limit: 20}],
total: [{$count: "count"}],
byBrand: [{$group: {_id: "$brand", n: {$sum: 1}}}]
}}
])
好处是只扫一遍数据。注意 $facet 的每个子管道不能用索引(输入已经是上游的结果流),所以前面的 $match 要尽可能收窄。
$graphLookup —— 递归查询,用于组织架构、评论树、好友关系这类自引用结构。有 maxDepth 控制深度。这是 MongoDB 相对 MySQL 的一个优势(MySQL 要用递归 CTE,8.0 才支持)。
$merge / $out —— 物化视图。定时跑聚合把结果写进另一个集合,前台直接查结果集合。$out 是整体替换,$merge(4.2+)支持增量更新,可以做增量物化视图。