跳到主要内容

聚合管道

基本概念​

文档流经一系列 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 吗」):

  1. 结果是数组 —— 即使只匹配到一条,也要 $unwind 或 $arrayElemAt 取出
  2. 语义等价于 LEFT OUTER JOIN —— 匹配不到就是空数组,没有 INNER / RIGHT JOIN
  3. 性能远不如关系库的 JOIN —— 实现上接近「对左表每个文档去右表查一次」的嵌套循环,目标集合的 foreignField 必须有索引,否则是灾难
  4. 分片集群的限制 —— 4.4 之前被关联的集合不能是分片集合;之后支持了但性能更差
  5. 不能跨数据库

结论:$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 超限直接报错。两种应对:

  1. allowDiskUse: true —— 允许落盘到临时文件(6.0 起 $group 和 $sort 默认就允许溢写磁盘)
  2. 真正的解法是别让数据进到那个 stage —— 把 $match 和 $limit 尽量提前

$group 和 $sort 是最容易超限的两个 stage。 :::

优化原则:

  1. $match 必须放第一个 —— 这样才能用上索引(只有管道开头的 $match 和 $sort 能用索引),且立刻减少后续数据量
  2. $project 尽早做 —— 砍掉不需要的字段,减少流经管道的数据体积
  3. $sort + $limit 要相邻 —— 优化器会合并成 top-k 排序,只保留 k 个元素而不是全量排序,内存从 O(n) 降到 O(k)
  4. $lookup 尽量靠后 —— 先过滤再关联,别关联完再过滤
  5. 目标集合的关联字段必须有索引

管道优化器会自动做一些重排,比如把 $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+)支持增量更新,可以做增量物化视图。