聚合管道入门
🎯 引言
前面几篇我们学会了用 find 查询数据、用条件筛选数据。但实际开发中经常会遇到另一类需求:「每个城市各有多少用户?」「各城市用户的平均年龄是多少?」这类分组统计的问题,光靠 find 是答不上来的。
学完这篇文章,你能理解 MongoDB **聚合管道(Aggregation Pipeline)**的工作方式,学会用 $match、$group、$sort 三个常用阶段组合出分组统计查询,并知道什么时候用 find、什么时候该请出聚合管道。这也是本课程的收官篇。
🧱 什么是聚合管道
聚合管道可以把多个处理步骤串成一条流水线:数据像水流一样,依次经过每个阶段(stage),每个阶段加工一遍,再把结果交给下一个阶段。
可以把它类比成工厂流水线:原材料(集合里的文档)从一头进入,经过「筛选」「分组」「排序」等一道道工序,从另一头出来的就是加工好的成品(统计结果)。
基本语法是 db.集合名.aggregate(),参数是一个数组,数组里按顺序放各个阶段:
db.user.aggregate([
{ 阶段1 },
{ 阶段2 },
]);
数组里阶段的顺序就是数据流过的顺序,先写的先执行。
在开始之前,先连上 mongosh,并准备好本文的示例数据。为了让统计结果有意义,这次我们让每个城市都有多条数据:
docker exec -it mongo-demo mongosh -u root -p 123456 --authenticationDatabase admin
use blog_demo
// 先清空旧数据,保证大家的结果和本文一致
db.user.deleteMany({})
db.user.insertMany([
{ username: '小明', age: 20, city: '北京' },
{ username: '小红', age: 25, city: '上海' },
{ username: '小刚', age: 30, city: '广州' },
{ username: '小丽', age: 28, city: '北京' },
{ username: '周七', age: 26, city: '北京' },
{ username: '吴八', age: 24, city: '上海' },
]);
mongo:8)编写和验证。deleteMany({}) 会清空整个集合,如果你之前有想保留的数据,跳过这一步即可,只是统计结果会和本文略有差异。✨ $match 与 $group
聚合管道里阶段有很多,我们先掌握两个常用的:$match 和 $group。
$match 负责过滤,把不满足条件的文档挡在门外。它的条件写法和 find 完全一样,比如只看北京的用户:
db.user.aggregate([
{ $match: { city: '北京' } },
]);
结果和 db.user.find({ city: '北京' }) 一样,能查出小明、小丽、周七三条数据。那为什么不直接用 find 呢?因为 $match 通常只是流水线的工序之一,后面还要接着加工。
$group 负责分组统计,这是聚合管道的核心。比如按城市统计人数:
db.user.aggregate([
{
$group: {
_id: '$city',
count: { $sum: 1 },
},
},
]);
执行结果是:
[
{ _id: '北京', count: 3 },
{ _id: '上海', count: 2 },
{ _id: '广州', count: 1 },
]
这段配置有三个要点,逐个来看:
_id: '$city':注意这里的_id不是文档的 id,在$group里它被重新定义成分组依据,表示「按 city 字段的值分组」。相同城市的文档会被归到同一组。'$city':在聚合表达式里,引用字段的值要在字段名前加$前缀,写成'$city'。不加$的话,MongoDB 会把它当成普通字符串。count: { $sum: 1 }:定义一个名叫count的新字段,$sum: 1表示每来一条文档就加一,数完一组就得到了这组的人数。count这个名字是我们自己起的,换成total也可以。
可以打个比喻:$group 像把一堆卡片按花色分成几摞,_id 决定按什么分,$sum: 1 就是分完后数每一摞有几张。
⚡ 组合多个阶段
单个阶段只能做一件事,聚合管道的威力在于把多个阶段组合起来。来看一个完整需求:统计 24 岁及以上的用户,按城市分组,算出各城市的人数和平均年龄,再按人数从多到少排列。
db.user.aggregate([
// 第一道工序:筛出 age >= 24 的文档
{ $match: { age: { $gte: 24 } } },
// 第二道工序:按城市分组,统计人数和平均年龄
{
$group: {
_id: '$city',
count: { $sum: 1 },
avgAge: { $avg: '$age' },
},
},
// 第三道工序:按人数降序排列
{ $sort: { count: -1 } },
]);
逐行走一遍这条流水线:
- 数据先经过
$match:小明 20 岁被筛掉,剩下小红、小刚、小丽、周七、吴八共 5 条进入下一关。 - 经过
$group:按城市分成三摞,北京 2 人(小丽 28、周七 26),上海 2 人(小红 25、吴八 24),广州 1 人(小刚 30)。$avg: '$age'会对每组算出平均年龄。 - 经过
$sort:count: -1表示按人数降序(1 是升序),人数多的城市排在前面。
结果是:
[
{ _id: '北京', count: 2, avgAge: 27 },
{ _id: '上海', count: 2, avgAge: 24.5 },
{ _id: '广州', count: 1, avgAge: 30 },
]
$sort 写在 $group 前面,排序的对象还是原始文档,count 字段还不存在,排序就没有意义了。写聚合管道时,先在脑子里过一遍「数据流到这一关时长什么样」。💡 和 find 怎么分工
学到这里你可能会问:这些操作为什么不用 find 做?两者的分工可以这样把握:
- 只是把数据查出来:用
find。条件筛选、排序、分页它都能做,写法简单直观。 - 需要分组、统计、多步加工:用
aggregate。比如按城市统计人数、算平均值、先筛选再统计再排序,这类需求find做不到或很勉强。
一句话总结:find 回答「把数据拿给我」,aggregate 回答「帮我分析数据」。日常开发中 find 用得更多,聚合管道则是处理统计报表类需求的常用工具。
🧾 小节总结
- 聚合管道把多个阶段串成流水线,数据依次经过每个阶段被逐步加工,语法是
db.集合名.aggregate([阶段数组])。 $match负责过滤,条件写法和find完全一致。$group负责分组统计,_id指定分组依据,$sum: 1用来计数,$avg: '$age'用来算平均值。- 聚合表达式里引用字段的值要加
$前缀,如'$city'。 $sort: { count: -1 }按字段降序排列,阶段顺序会影响结果。- 简单查询用
find,分组统计和多步加工用aggregate。
❓ 知识问答
Q1:$group 里的 _id 为什么查出来是城市名,而不是文档的 id?
在 $group 阶段里,_id 被重新定义为「分组依据」,你写 _id: '$city',输出的 _id 就是各组的城市值。这是 $group 的固定语法,和文档自带的 _id 字段是两回事。
Q2:什么时候字段名要加 $ 前缀?
在聚合管道里,凡是想取字段的值就要加 $,比如 '$city'、'$age';不加 $ 的字符串就是普通文本。而在 $match 的条件里写法和 find 一样,字段名不需要加 $。
Q3:执行 aggregate 会修改集合里的数据吗?
不会。聚合管道只读取数据并返回加工后的结果,原始文档原封不动,可以放心反复执行。
Q4:$match 写了和 find 一样的条件,为什么还要走聚合管道?
单看过滤确实没区别。$match 的价值在于它能和 $group、$sort 串起来,先缩小数据范围再做统计,让后面的阶段少处理数据。
Q5:除了 $sum 和 $avg,还有哪些常用的统计操作?
常用的还有 $max(取每组某字段的最大值)、$min(最小值)、$first(每组第一条的某字段值)。掌握 $sum 和 $avg 的写法后,这几个换上去就能直接用。
🧪 小练习
练习一:用聚合管道按城市分组,统计每个城市用户的最大年龄(提示:用 $max)。
db.user.aggregate([
// 请在这里编写代码
]);
练习二:先筛出 25 岁及以上的用户,再按城市统计人数,并按人数升序排列。
db.user.aggregate([
// 请在这里编写代码
]);
🎉 恭喜你已经掌握聚合管道的入门技能啦,也恭喜你完成了整门 MongoDB 课程!回顾一下这趟旅程:从 Docker 安装与增删改查起步,到条件查询、文档模型的嵌套与数组,再到 Node.js 官方驱动和 Mongoose 操作数据库,最后用索引优化查询、用聚合管道做分组统计。把这套技能和前面的 Express、Node.js 课程结合起来,你已经具备了独立开发全栈项目的数据库功底,挑一个小项目动手练起来吧!
