大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
聚合管道
这一课你将能做到
- 用
$match→$group→$project→$sort→$limit写出课时热度 Top 5 统计 - 使用
$unwind展开数组、$lookup关联集合,并说明$lookup与 SQLLEFT JOIN的对应与代价 - 判断什么需求该用
aggregate、什么需求留在find,并能说出至少两条管道优化原则
为什么要学
列表查询用 find 就够;一旦需求变成「按课统计浏览量、去重用户数、算平均分、排 Top N」,把原始数据拖回应用层拼装既慢又费带宽。聚合管道(Aggregation Pipeline)让这些计算在数据库里一次完成。
这是 MongoDB 面试的重头戏:管道 stage 顺序、$group 语义、$lookup 性能、大数据量优化(allowDiskUse、预聚合),都是高频题。本站场景现成——做题日志的热度榜、每门课的完成率统计。
人话:aggregate = 在数据库里把「过滤、分组、整形、排序」按流水线一次做完,应用层只接收算好的结果。
先建立图像
lesson_logs 文档流
→ $match (WHERE:尽早过滤,减少后续数据量)
→ $group (GROUP BY + 聚合函数:$sum/$avg/$addToSet…)
→ $project (SELECT:改字段名、算派生值)
→ $sort (ORDER BY)
→ $limit (LIMIT)
→ 结果文档数组
| Stage | 作用 | SQL 近似 |
|---|---|---|
$match | 过滤 | WHERE |
$group | 分组聚合 | GROUP BY |
$project | 字段整形/派生 | SELECT 表达式 |
$sort / $limit / $skip | 排序截取 | ORDER BY / LIMIT |
$unwind | 数组展开成多文档 | 行转多行 |
$lookup | 关联另一集合 | LEFT JOIN |
跟着做
步骤 1:准备日志数据
use coderoadmap_lab
db.lesson_logs.deleteMany({})
db.lesson_logs.insertMany([
{ lessonSlug: "mongo-crud", userId: "u_1", action: "view", at: ISODate("2026-02-01T10:00:00Z") },
{ lessonSlug: "mongo-crud", userId: "u_1", action: "view", at: ISODate("2026-02-01T11:00:00Z") },
{ lessonSlug: "mongo-crud", userId: "u_2", action: "complete", at: ISODate("2026-02-02T09:00:00Z") },
{ lessonSlug: "mongo-intro", userId: "u_1", action: "view", at: ISODate("2026-02-01T12:00:00Z") },
{ lessonSlug: "mongo-intro", userId: "u_3", action: "view", at: ISODate("2026-02-03T08:00:00Z") },
{ lessonSlug: "mongo-indexes", userId: "u_2", action: "view", at: ISODate("2026-02-04T08:00:00Z") }
])
步骤 2:跑通热度榜管道
db.lesson_logs.aggregate([
{ $match: { at: { $gte: ISODate("2026-01-01") } } }, // ① 尽早过滤
{
$group: {
_id: "$lessonSlug", // 分组键
views: { $sum: 1 }, // 计数
users: { $addToSet: "$userId" } // 组内去重集合
}
},
{
$project: {
lessonSlug: "$_id", // 把分组键改回业务名
views: 1,
uniqueUsers: { $size: "$users" },
_id: 0
}
},
{ $sort: { views: -1 } },
{ $limit: 10 }
])
应看到:mongo-crud views 最高;uniqueUsers 是去重人数。注意 $group 之后原文档字段只剩 _id 与你声明的累加器——所以需要 $project 改名。
步骤 3:对照 SQL,写进笔记
SELECT lesson_slug,
COUNT(*) AS views,
COUNT(DISTINCT user_id) AS unique_users
FROM lesson_logs
WHERE at >= '2026-01-01'
GROUP BY lesson_slug
ORDER BY views DESC
LIMIT 10;
关键对应:$match=WHERE、$group+$sum=GROUP BY+COUNT、$addToSet+$size=COUNT(DISTINCT)。stage 顺序对应 SQL 语义执行顺序。
步骤 4:$unwind 展开数组
// 每个标签变成一行,才能按标签统计课程数
db.tracks.aggregate([
{ $unwind: "$tags" },
{ $group: { _id: "$tags", courseCount: { $sum: 1 } } },
{ $sort: { courseCount: -1 } }
])
应看到:java、mongodb 等各占一行。$unwind 是「数组统计」的必经之路。
步骤 5:$lookup 关联(理解 + 克制)
db.lesson_logs.aggregate([
{ $match: { action: "view" } },
{ $limit: 2 },
{
$lookup: {
from: "tracks", // 被关联集合
localField: "lessonSlug", // 本集合字段
foreignField: "slug", // 对面集合字段
as: "trackDocs" // 结果数组字段
}
}
])
应看到:每条日志多了 trackDocs 数组(LEFT JOIN 语义:对不上就是空数组)。笔记写下工程态度:能 JOIN 不等于该常 JOIN——文档模型的优势在减少连接,建模上优先内嵌或冗余只读字段,$lookup 留给低频报表。
步骤 6:加时间维度 + allowDiskUse
db.lesson_logs.aggregate([
{
$group: {
_id: {
day: { $dateToString: { format: "%Y-%m-%d", date: "$at" } },
lesson: "$lessonSlug"
},
views: { $sum: 1 }
}
},
{ $sort: { "_id.day": 1, views: -1 } }
], { allowDiskUse: true }) // 数据量大、内存超限时允许落盘
应看到:按「日期 + 课时」二维分组的日活统计。allowDiskUse 是大数据量管道的保险丝。
讲清楚要点
- 管道按数组顺序执行:
$match/$limit尽量靠前,让最少的文档流进昂贵的$group/$sort——这是管道优化第一原则。 $group的_id是分组键:可以是字段("$lessonSlug")、表达式或多字段对象;分组后原文档「消失」,只剩_id与累加器字段。- 常用累加器:
$sum计数求和、$avg平均、$max/$min、$addToSet去重集合、$push保留列表——对应 SQL 的聚合函数家族。 $project负责整形与派生:改名、算比值、$size取数组长度;让返回结构直接可给前端。$lookup是 LEFT JOIN:对不上的文档得到空数组;大集合对大集合的$lookup成本高,能内嵌/冗余就别连。- 索引在聚合里依然有效:
$match、$sort能吃索引;聚合前先做索引课的功课。 - 超限与优化:
$group/$sort默认受 100 MB 内存限制,超了要么allowDiskUse、要么拆步预聚合;高频报表考虑定时任务写结果集合($merge/$out)。
你可能会懵的地方
-
$group之后原来的字段去哪了? 没了。分组把 N 篇文档折叠成 1 篇,只保留_id(分组键)与显式声明的累加器字段。要保留的维度放进_id对象,或先想清楚要不要分组。 -
$match放在$group后面行不行? 语法上行,性能上通常错——那等于先聚合全量再过滤。与 SQL「WHERE 在 GROUP BY 前」同理;确实要过滤聚合结果的(如 HAVING 语义)才放后面。 -
聚合慢怎么办? 顺序:①
$match提前;②$match/$sort字段有索引;③ 加$limit截断;④ 还不行用explain看聚合计划,或改为定时预聚合。 -
和 MapReduce 什么关系? MapReduce 是旧 API,性能差,现代 MongoDB 一律推荐聚合管道。面试提一句「已不推荐 MapReduce」即可。
-
什么时候不用聚合? 单篇详情(
findOne)、简单列表(find+ sort + limit)都不必上管道;管道解决的是「分组统计、多步变换、服务端整形」。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
先 $group 再 $match 过滤明细 | 白白聚合无关文档 | $match 放最前(除非 HAVING 语义) |
$group 后直接取 lessonSlug 字段 | undefined——字段已折叠 | $project 从 _id 取出改名 |
一切关联都 $lookup | 回到大表 JOIN 痛点 | 内嵌 / 冗余只读字段优先 |
| 应用层 for 循环 + 多次 find 拼统计 | 慢、错、费带宽 | 能下推的统计交给 aggregate |
无 $limit 的大结果集 | 内存与网络双爆 | 加 $limit / 分页 / 预聚合 |
忘了 allowDiskUse 报 100MB 超限 | 管道中断 | 优化 stage 或显式允许落盘 |
卡点急救
- 现象:
$group结果里拿不到某字段 它没进分组键也没进累加器。把维度放进_id: { a: "$a", b: "$b" },或对明细值用$push/$first保留。 - 现象:统计数字和预期对不上
两步核对:①
$match条件是否漏了(如忘了过滤action);②$addToSet去重的是不是你要的键。用countDocuments抽查对账。 - 现象:
$sort阶段超内存报错$sort放到$group之前先缩小量级,或给排序字段建索引,或allowDiskUse: true;三者按代价从低到高尝试。
面试常问
-
介绍聚合管道的执行模型与优化原则。 文档依次流过 stage 数组,每个 stage 输出作为下一个输入。优化原则:
$match/$limit提前减少数据量;$match与$sort利用索引;避免无界$group/$sort(内存 100MB 限制,超限需allowDiskUse);高频报表用$merge/$out预聚合落集合。 -
$lookup和 SQL JOIN 的关系?性能上要注意什么? 语义近似 LEFT JOIN(as字段是数组,对不上为空数组)。性能注意:大集合 × 大集合代价高,逐文档到对面集合取数;应优先靠建模(内嵌、冗余快照字段)减少 lookup,仅低频报表使用;对面字段要建索引。 -
统计每门课的独立用户数,怎么写?
$match过滤 action →$groupby lessonSlug 配$addToSet: "$userId"→$project里$size取长度 →$sort/$limit。本质是COUNT(DISTINCT)的管道表达;海量去重可提$addToSet内存隐患,进阶方案$group+ 分片或预聚合。
自测三问
- 为什么
$match应尽量放在$group之前? $addToSet+$size组合计算的是什么?对应 SQL 的什么?- 什么情况下该避免依赖
$lookup?替代方案是什么?
小结一句话
aggregate 用 stage 管道把过滤、分组、整形、排序在库内一次做完:$match/$group/$project 是入门三件套,$match 提前、$lookup 克制、超限靠 allowDiskUse。
延伸
- Aggregation 官方文档
- 聚合管道 Stage 参考
- 下一课:在 Spring Boot 里用 Spring Data MongoDB 落地这些操作。