大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
聚合管道
这一课你将能做到
- 用
$match / $group / $sort / $project / $limit写出多阶段聚合管道,完成「分组统计」类报表需求 - 用
$unwind展开数组、用$lookup做集合关联,并说明各自的使用边界 - 用 explain 观察聚合执行,知道管道顺序对性能的影响(先过滤后分组)
为什么要学
find 只能回答「哪些文档符合条件」,回答不了「每个用户的通过率是多少」「每节课的平均尝试次数」「本月活跃用户 Top10」——这类问题需要分组、统计、关联,这就是聚合管道(Aggregation Pipeline)的地盘。
人话:find 是「筛」,聚合是「流水线」——文档像流水线上的产品,经过一道道工序(stage),每道工序改变形状,最后一道出来的就是报表。会写 SQL 的 GROUP BY,就能类比学会聚合管道。
先建立图像
SQL: SELECT lessonSlug, AVG(score) FROM submissions
WHERE status='accepted' GROUP BY lessonSlug ORDER BY avg DESC LIMIT 5
聚合管道:
[ 全量文档 ]
│ $match { status: "accepted" } ← WHERE
▼
[ 过滤后文档 ]
│ $group { _id: "$lessonSlug", avg: {$avg: "$score"} } ← GROUP BY
▼
[ 分组结果 ]
│ $sort { avg: -1 } ← ORDER BY
▼
│ $limit 5 ← LIMIT
▼
[ 最终报表 ]
核心心法:管道越靠前放 $match 和 $limit,后面 stage 处理的数据越少。
跟着做
准备数据(本站提交记录 + 课时两个集合):
use codeforge
db.submissions.drop()
db.submissions.insertMany([
{ userId: 1024, lessonSlug: "mdbrm-intro", status: "accepted", score: 100, attempts: 1, submittedAt: new Date("2026-10-01") },
{ userId: 1024, lessonSlug: "mdbrm-types", status: "failed", score: 40, attempts: 2, submittedAt: new Date("2026-10-02") },
{ userId: 1024, lessonSlug: "mdbrm-types", status: "accepted", score: 88, attempts: 1, submittedAt: new Date("2026-10-03") },
{ userId: 2048, lessonSlug: "mdbrm-intro", status: "accepted", score: 60, attempts: 3, submittedAt: new Date("2026-10-04") },
{ userId: 2048, lessonSlug: "mdbrm-types", status: "accepted", score: 92, attempts: 2, submittedAt: new Date("2026-10-05") },
{ userId: 4096, lessonSlug: "mdbrm-intro", status: "failed", score: 30, attempts: 5, submittedAt: new Date("2026-10-06") }
])
db.lessons.drop()
db.lessons.insertMany([
{ slug: "mdbrm-intro", title: "MongoDB 概览", difficulty: 1 },
{ slug: "mdbrm-types", title: "BSON 与类型", difficulty: 2 }
])
步骤 1:$match + $group —— 每个用户的通过数
db.submissions.aggregate([
{ $match: { status: "accepted" } },
{ $group: {
_id: "$userId", // 分组键
passCount: { $sum: 1 }, // 每组计数
avgScore: { $avg: "$score" }, // 平均分
bestScore: { $max: "$score" }
}},
{ $sort: { passCount: -1 } }
])
结果形如 { _id: 1024, passCount: 2, avgScore: 94, bestScore: 100 }。
步骤 2:$group 分组键的进阶 —— 按天统计提交量
db.submissions.aggregate([
{ $group: {
_id: { $dateToString: { format: "%Y-%m-%d", date: "$submittedAt" } },
count: { $sum: 1 }
}},
{ $sort: { _id: 1 } }
])
分组键可以是任意表达式:$year、$month、$dayOfWeek、$cond 条件表达式都行。
步骤 3:$project —— 重塑输出形状
上一步的 _id 对用户不友好,重命名并派生字段:
db.submissions.aggregate([
{ $match: { status: "accepted" } },
{ $group: { _id: "$userId", passCount: { $sum: 1 }, avgScore: { $avg: "$score" } } },
{ $project: {
_id: 0,
userId: "$_id",
passCount: 1,
avgScore: { $round: ["$avgScore", 1] }, // 表达式:四舍五入
level: { $switch: { // 条件表达式
branches: [
{ case: { $gte: ["$avgScore", 90] }, then: "学霸" },
{ case: { $gte: ["$avgScore", 60] }, then: "稳健" }
],
default: "加油"
}}
}}
])
步骤 4:$unwind —— 展开数组
db.judgeResults.drop()
db.judgeResults.insertMany([
{ submissionId: 1, cases: [{name:"c1",passed:true,costMs:12},{name:"c2",passed:false,costMs:250}] },
{ submissionId: 2, cases: [{name:"c1",passed:true,costMs:8}] }
])
// 统计「失败用例」的平均耗时
db.judgeResults.aggregate([
{ $unwind: "$cases" }, // 1 条文档变 N 条,每条 cases 是单个对象
{ $match: { "cases.passed": false } },
{ $group: { _id: null, avgCost: { $avg: "$cases.costMs" } } }
])
$unwind 后 cases 从数组变成对象,后续 stage 按单对象处理。preserveNullAndEmptyArrays: true 选项可以保住空数组文档(默认会被丢弃)。
步骤 5:$lookup —— 集合关联
给提交记录挂上课时标题:
db.submissions.aggregate([
{ $match: { status: "accepted" } },
{ $lookup: {
from: "lessons", // 目标集合
localField: "lessonSlug", // 本集合字段
foreignField: "slug", // 目标集合字段
as: "lesson" // 结果写入的数组字段
}},
{ $unwind: "$lesson" }, // lesson 是数组,展开成对象
{ $project: { _id: 0, userId: 1, score: 1, "lesson.title": 1, "lesson.difficulty": 1 } }
])
步骤 6:$facet —— 一次扫描出多张报表
用户列表页常要「数据 + 总数」:
db.submissions.aggregate([
{ $match: { status: "accepted" } },
{ $facet: {
list: [ { $sort: { score: -1 } }, { $limit: 10 } ],
total: [ { $count: "n" } ]
}}
])
返回 { list: [...], total: [{n: 4}] },一次聚合完成分页列表和总数。
步骤 7:看执行计划
db.submissions.explain("executionStats").aggregate([
{ $match: { status: "accepted" } },
{ $group: { _id: "$userId" } }
])
关注 totalDocsExamined:如果 $match 有索引支撑,这个数字会远小于全集合大小。
讲清楚要点
- 管道顺序就是性能:
$match和$limit尽量靠前;$match在首位时能用索引(等价于 find)。优化器会做一些自动重排,但别指望它兜底。 $group的_id是分组键,不是主键:_id: null表示全集合一组(全局统计);_id可以是表达式或对象(多字段分组_id: {u: "$userId", d: "$day"})。- 累加器:
$sum(传 1 即计数)、$avg、$min、$max、$first、$last、$push(收集成数组)、$addToSet(收集去重数组)。 $unwind会让文档数膨胀:100 条文档、每条 50 元素的数组,unwind 后是 5000 条。先$match再 unwind,别把顺序搞反。$lookup不是万能 JOIN:适合「主表 + 字典表」(如本例挂课时名)。大表对大表的关联是反模式,该内嵌就内嵌,该冗余就冗余。7.x 支持 pipeline 形式的$lookup(相关子查询)。$facet的子管道互相独立,共用$facet之前的扫描结果,是「一次查询多个维度汇总」的利器。- 内存限制:每个 stage 默认 100MB 内存上限,超了报错。大排序/分组加
{ allowDiskUse: true }:
db.submissions.aggregate([...], { allowDiskUse: true })
你可能会懵的地方
$match里的字段要不要加$前缀? 不加。$match: {score: {$gte: 60}}是普通查询语法;只有在表达式上下文($expr、$project的派生字段、$group累加器参数)里,字段引用才要加$("$score")。这是初学者最常混的点。$group之后原字段还在吗? 不在。$group的输出只有_id和你显式定义的累加器字段。想保留原字段要用$push: "$$ROOT"(整文档收集)或先$project。$$ROOT、$$NOW是什么? 系统变量。$$ROOT指当前整条文档,$$NOW是当前时间。常用于$group收集整文档或时间戳。- 聚合能写数据吗?
能。
$merge(写入/合并到目标集合)和$out(覆盖写目标集合)是管道的最后阶段,用于物化报表。 - 聚合和 map-reduce 选哪个? 永远选聚合管道。map-reduce 慢、难写,官方已基本弃用,聚合覆盖它 99% 的场景。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
$match: {$score: ...} 或 $group: {_id: "userId"} 漏写/多写 $ | 表达式引用字段才用 $,路径里多写变成字面量 | 记住:取字段值才加 $ |
先 $unwind 再 $match 过滤数组元素 | 处理量放大 N 倍 | 先 $match 收窄文档,再 unwind |
$group 后想直接用原字段 | 字段已消失 | 累加器里收集($first/$push/$$ROOT) |
| 大集合分组排序不加 allowDiskUse | 100MB 内存超限报错 | aggregate(pipeline, {allowDiskUse: true}) |
用 $lookup 做大表关联报表 | 性能雪崩 | 反范式设计:写入时冗余标题等只读字段 |
卡点急救
- 聚合结果为空:从第一个 stage 开始逐个注释排查——先只跑
$match看有没有数据,再逐级加。多半是字段名写错或$前缀用错。 - 报
Exceeded memory limit for $group:加allowDiskUse: true;若仍慢,考虑把大分组键降基数(如按天而不是按毫秒分组)。 $lookup后$unwind把文档弄丢了:关联没命中时$unwind默认丢弃该文档,用$unwind: {path: "$lesson", preserveNullAndEmptyArrays: true}。
面试常问
- 聚合管道和 SQL 的对应关系?
$match≈WHERE,$group≈GROUP BY(累加器≈聚合函数),$sort≈ORDER BY,$project≈SELECT 列+表达式,$lookup≈LEFT JOIN,$limit/$skip≈LIMIT/OFFSET,$unwind≈展开一行多行。 - 聚合性能怎么优化?
三板斧:管道首位的
$match/$sort有索引支撑;尽早$match/$limit收窄数据;大 stage 开 allowDiskUse。再往上是预聚合(定时把报表算好 $merge 到结果集合,查询时直接读)。 $lookup和 MySQL JOIN 的区别? 语义类似 LEFT JOIN,但 MongoDB 的强项不是关联;$lookup适合主+字典、小数据侧。高频复杂关联说明建模有问题——该内嵌的没内嵌。
自测三问
$group: {_id: "$userId"}里$的作用是什么?写成_id: "userId"会怎样?- 「统计每节课的通过率」需要哪些 stage?按什么顺序?
- 为什么
$lookup后要接$unwind?什么时候要加preserveNullAndEmptyArrays?
小结一句话
聚合管道是数据流水线:$match 收窄、$group 统计、$unwind 展开、$lookup 关联,顺序决定性能,表达式里取字段记得加 $。
延伸
- 官方文档:Aggregation Pipeline
- 管道运算符速查
- 下一课:索引优化——让
$match和$sort有索引可用,查询从秒级到毫秒级。