深蓝黑背景封面。左侧一条自上而下的管道流程链,四张卡片以向下箭头串联:第一张「行为日志集合,1200 条文档进入管道」;第二张「$match:过滤近 7 天且 status = ok」,注「剩 96 条 —— 越早过滤越省」;第三张「$group:按品类分组并 sum 金额」,注「96 条折叠成 8 组」;第四张青色高亮「$sort 金额降序 + $limit 5」,注「输出 Top 5 品类」。底部一行注「管道顺序即性能:$match 前置,数据越早变小」。右侧大标题「MongoDB 聚合管道,各阶段是怎么工作的」,副标题「文档流过 $match → $group → $sort,一段一变换」。左上标注「面试经典 · 数据库」,右上胶囊标注「Day 03 · 11-20」。 图 1:文档流过管道,每过一段数据量就缩一截。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料整理,与任何公司的当前招聘无关。「什么是聚合」「聚合管道怎么工作」是 MongoDB 面试的常客。AI 应用的行为日志分析、标注数据统计常常落在 MongoDB 上,聚合管道就是主要的分析入口,值得讲透。

先厘清「聚合」到底指什么。

题目原话常常只有「什么是聚合」四个字,回答时先给定义再谈机制。聚合是 MongoDB 里对一批文档做分组统计、变换、整形的统称,对应关系型数据库里 GROUP BY、COUNT、SUM、AVG 那一族能力。它有两代实现:早年是 map-reduce,灵活但写起来重、性能差,从 5.0 版本起已被官方弃用;现在的标准答案就是聚合管道——声明式地列出阶段,由引擎负责执行和优化。面试里把「map-reduce 已退场、管道是主流」点出来,能体现信息是新近核对过的。

把管道想成传送带:文档流逐段加工。

聚合管道由一个或多个阶段(stage)组成,文档从集合流入第一个阶段,每个阶段对输入文档做一次变换,输出再交给下一阶段。关键点:阶段不是一进一出,$match 会扔掉文档,$group 会把一百条折叠成一组,$unwind 会把一条拆成多条。所以管道里流动的「数据量」是逐段变化的,这也是性能话题的入口。

拿一个典型分析需求串起来:从行为日志里找近 7 天成功请求的 Top 5 品类。管道写成四段:

  • $match:过滤出近 7 天且 status 为 ok 的文档,对应 SQL 的 WHERE;
  • $group:按品类分组、对金额求和,对应 GROUP BY 加 SUM 聚合;
  • $sort:按总金额降序,对应 ORDER BY;
  • $limit:只取前 5,对应 LIMIT。

模拟一遍,看数据量怎么收缩。

下面用 Node.js 22.15.0 模拟这条管道(node pipeline.js),造 1200 条合成日志,逐段打印剩余条数:

const DAY = 86400000, now = Date.UTC(2026, 10, 20);
const cats = ['搜索', '推荐', '支付', '登录', '上传', '下载', '评论', '分享'];
const docs = [];
for (let i = 0; i < 96; i++)            // 近 7 天的 ok 日志
  docs.push({ ts: now - (i % 7) * DAY, category: cats[i % 8],
              status: 'ok', amount: (i % 8 + 1) * 10 + Math.floor(i / 8) });
for (let i = 0; i < 1104; i++)          // 更早的日志,部分 fail
  docs.push({ ts: now - (8 + (i % 42)) * DAY, category: cats[i % 8],
              status: i % 5 === 0 ? 'fail' : 'ok', amount: (i % 13) + 1 });

const s1 = docs.filter(d => d.ts >= now - 7 * DAY && d.status === 'ok');  // $match
const acc = new Map();
for (const d of s1) acc.set(d.category, (acc.get(d.category) || 0) + d.amount);
const s2 = [...acc.entries()].map(([category, total]) => ({ category, total })); // $group
const s3 = s2.sort((a, b) => b.total - a.total).slice(0, 5);             // $sort+$limit
console.log(docs.length, s1.length, s2.length, s3.length);
console.log(s3.map(g => `${g.category}=${g.total}`).join(' '));

输出:

1200 96 8 5
分享=1026 评论=906 下载=786 上传=666 登录=546

1200 条进管道,$match 砍到 96,$group 折叠成 8 组,$sort 加 $limit 留下 5 条。真实 MongoDB 里这条管道的写法是 [{ $match: {...} }, { $group: { _id: '$category', total: { $sum: '$amount' } } }, { $sort: { total: -1 } }, { $limit: 5 }],语义与模拟一一对应。

管道顺序为什么就是性能。

每段处理的数据量取决于上一段吐多少,所以总代价大致由「最胖的那段」决定。原则一句话:让数据尽早变小。$match、$limit 尽量靠前——把 $match 放在 $group 后面,等于让分组去折叠一堆注定被扔掉的文档,白做工。

好消息是 MongoDB 自己会帮一把:优化器会把不依赖计算字段的 $match 自动前移、把紧跟 $sort 的 $limit 合并进排序(排序时只维护前 N 条,内存里不留全量)、把相邻的同种阶段合并。管道开头的 $match 和 $sort 还能走索引。但自动优化有边界——它不知道你的数据分布,也不知道哪些过滤条件其实可以合并,所以写法上仍要主动按「先过滤、后加工、最后整形」的顺序组织,再用 explain 看优化器实际改写成了什么样。

还有两个容易踩的点:一是 $group 的 _id 决定分组粒度,写错字段(比如拿时间戳原文分组)会把组数打爆;二是管道中间结果有内存限制,大结果集排序或分组要评估 allowDiskUse 或干脆前置 $match 瘦身。

深蓝黑背景概念图。上半部是一条横向五段管道:输入集合 → $match → $group → $sort → 输出结果,各段之间以箭头连接;每段下方有一根与数据量成正比的竖条,从 1200 条逐级收缩到 96 条、8 组、8 组、5 条,直观展示数据越早变小。中部横轴标注「数据量」。下半部三张语义对应卡片:$match 对应 SQL 的 WHERE,先过滤再加工;$group 对应 GROUP BY + SUM 等聚合;$sort 与 $limit 对应 ORDER BY + LIMIT。底部总结条:优化规则 —— $match、$limit 尽量靠前,把数据量尽早压小,MongoDB 会自动做部分阶段重排。 图 2:数据量竖条逐级收缩,下方是三张与 SQL 语义的对照卡。

回到面试,推荐的答题骨架:先一句话定义「文档流过多阶段、每段一变换」,再用一条具体管道把 $match/$group/$sort/$limit 与 SQL 语义对上,最后讲顺序即性能和优化器的自动重排。如果能补一句「 explain 验证优化结果」,就有工程味了。你在生产里写过最复杂的管道有几段?有没有被 $group 的分组键坑过?欢迎分享。