MongoDB 简介

聚合管道入门

理解聚合管道的工作方式,学会用 $match、$group、$sort 完成分组统计。

🎯 引言

前面几篇我们学会了用 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: '上海' },
]);
本文基于 MongoDB 8.0(Docker 镜像 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' 会对每组算出平均年龄。
  • 经过 $sortcount: -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 课程结合起来,你已经具备了独立开发全栈项目的数据库功底,挑一个小项目动手练起来吧!