CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
聚合管道
这一课你将能做到
用 $match → $group → $project → $sort → $limit 写出课时热度 Top 5 统计
使用 $unwind 展开数组、$lookup 关联集合,并说明 $lookup 与 SQL LEFT 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 → $group by 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。
延伸