MongoDB: 聚合管道入门:aggregate方法与基础阶段

最后更新:2026-08-26

聚合管道是 MongoDB 最强大的数据分析工具——掌握它能用 MongoDB 替代 90% 的 SQL 分析场景。

本课程入门聚合管道基础、阶段概念、执行顺序。

1. 你将学到


2. 什么是聚合管道?

概念说明:聚合管道(Aggregation Pipeline)是 MongoDB 最强大的数据分析框架。它将数据处理拆分为多个顺序执行的阶段(stage),每个阶段接收上一阶段的输出作为输入,进行筛选、转换、分组或统计,最终产出结果。这种"流水线"模式源自 UNIX 管道思想,让复杂数据分析变得可组合、可调试。

工作原理:聚合管道按数组顺序逐阶段执行。第一个阶段接收集合中的所有文档,每个阶段对文档流进行变换(过滤、投影、分组等),输出传递给下一阶段。MongoDB 查询优化器会尝试将 $match$sort 推到管道前端以利用索引。单阶段内存限制 100MB,超出需 allowDiskUse: true

管道优化器的自动重排:MongoDB 查询优化器会自动重排某些阶段以提高性能——1. $match 前移:如果 $match 出现在 $project/$group 后,优化器会尝试将其前移(因为 $match 后的数据量更小);2. $sort + $match 合并:连续的 $sort + $match 可能合并为使用索引的 $sort + $match;3. $project 前移:如果 $project 只影响后续 $match/$sort 不需要的字段,优化器可能前移 $project 减少字段。但优化器不会改变用户定义的语义——结果始终与原始顺序相同。

100MB 内存限制的实际影响:每个聚合阶段有 100MB 内存限制——超出报错"Exceeded memory limit"。这个限制是设计层面的保护机制(防止单个聚合查询耗尽服务器内存)。应对方案:1. allowDiskUse: true(溢写到临时文件,性能下降但不会报错);2. 在 $group 前用 $match 减少输入量;3. 避免在 $group 中用 $push 收集大数组(改用 $sum 计数);4. 将大聚合拆分为多个小聚合(分批处理)。allowDiskUse 是最后手段——应先优化管道结构减少内存需求。

聚合管道 vs SQL 聚合的对比:MongoDB 聚合管道在概念上等价于 SQL 的 SELECT...GROUP BY...HAVING,但实现方式不同——1. SQL 用单条语句组合所有操作,MongoDB 用数组链式排列阶段;2. SQL 的 WHERE 对应 $match,GROUP BY 对应 $group,HAVING 对应 $match(在 $group 之后),SELECT 对应 $project,ORDER BY 对应 $sort;3. SQL 子查询对应嵌套管道或 $lookup;4. SQL 窗口函数对应 $setWindowFields。从 SQL 迁移到聚合管道的心法:把 SQL 子句拆解为独立阶段,按数据流顺序排列。

聚合管道的学习路线图:聚合管道从入门到精通分三个阶段——1. 入门(本课程):掌握 7 个基础阶段($match/$group/$project/$sort/$limit/$skip/$count)和管道执行模型;2. 进阶(下课程):掌握表达式体系($cond/$switch/$dateOperators/$typeOperators/$stringOperators/$arrayOperators)和复杂变换;3. 高级(后续课程):掌握多集合关联($lookup/$unwind)、分面搜索($facet/$bucket)、文本/地理搜索($text/$geoNear)。建议按顺序学习,每个阶段做 3-5 个实战练习巩固。

100%
graph LR
    A[集合<br/>1000 文档] --> B[$match<br/>筛选]
    B --> C[$group<br/>分组聚合]
    C --> D[$sort<br/>排序]
    D --> E[$limit<br/>限制]
    E --> F[结果]

    style B fill:#fff3cd
    style C fill:#d4edda
100%
sequenceDiagram
    participant DB as MongoDB
    participant S1 as $match
    participant S2 as $group
    participant S3 as $sort
    participant S4 as $limit

    DB->>S1: 1000 文档
    Note over S1: 过滤: category=Electronics
    S1->>S2: 250 文档
    Note over S2: 按 brand 分组<br/>$sum, $avg
    S2->>S3: 15 组
    Note over S3: 按 total 降序
    S3->>S4: 15 组
    Note over S4: 取前 10
    S4-->>DB: 10 组结果

类比 UNIX 管道:

BASH
# 找出评分 > 4 的商品,按价格降序,前 10 个
cat products.json | jq 'select(.rating > 4)' | jq 'sort_by(-.price)' | head -10

# MongoDB 聚合
db.products.aggregate([
  { $match: { rating: { $gt: 4 } } },
  { $sort: { price: -1 } },
  { $limit: 10 }
]);

3. aggregate() 基本语法

概念说明db.collection.aggregate(pipeline, options) 是执行聚合管道的入口方法。pipeline 是 stage 对象数组,options 控制执行行为(内存限制、超时、索引提示)。aggregate 返回游标,支持 toArray() 一次性获取或 forEach() 逐条迭代。

工作原理:MongoDB 接收到 aggregate 命令后,查询优化器首先分析管道结构,尝试将 $match 阶段前移(与 $sort 合并利用索引),然后按优化后的顺序逐阶段执行。每个阶段在内存中维护文档流,超出 100MB 限制时报错(除非 allowDiskUse: true)。

聚合选项的生产配置:aggregate 的 options 参数在生产环境需要合理配置——1. allowDiskUse: true:大聚合必须开启(防止 100MB 内存限制报错),但优先优化管道减少内存需求(磁盘 I/O 比内存慢 100 倍);2. maxTimeMS: 30000:设置超时(30 秒),防止慢聚合拖垮整个实例;3. hint: {category: 1}:强制使用指定索引(当优化器选择错误索引时手动纠正);4. batchSize: 100:控制每批返回文档数(小批次减少首次响应时间,大批次减少网络往返)。生产环境的聚合必须有 maxTimeMS——没有超时保护的聚合是定时炸弹。

聚合管道的优化器行为:MongoDB 的聚合优化器会自动做一些优化——1. $match 下推:如果 $match 出现在 $group/$project 后,优化器会尝试将其下推到更早的阶段(但不保证总是成功);2. $sort + $limit 优化:连续的 $sort + $limit 会被优化为 Top N 模式(只维护 N 个元素的堆,而非全量排序);3. 不会做的优化:不会重排用户定义的阶段顺序(如把 $match 从第 3 位移到第 1 位),不会将 $group 后的 $match 前移。理解优化器的行为边界,有助于手写更高效的管道——"不要依赖优化器,自己把 $match 写在最前面"。

聚合游标的两种消费方式:aggregate 返回游标(cursor),不是数组——两种消费方式——1. cursor.toArray():一次性获取所有结果到内存(适合小结果集,< 1000 条),代码简洁但内存占用高;2. cursor.forEach() / for await...of:逐条处理(适合大结果集或流式处理),内存占用低但代码稍复杂。生产环境推荐 for await...of——即使当前结果集小,未来数据增长后不会内存溢出。用 toArray() 的代码在数据量从 100 条增长到 10 万条时会突然 OOM。

参数 类型 说明
pipeline Array 阶段对象数组,按顺序执行
options.allowDiskUse Boolean 允许临时写入磁盘(默认 false)
options.maxTimeMS Number 超时时间(毫秒)
options.batchSize Number 每批返回文档数
options.hint String/Object 强制使用指定索引
JAVASCRIPT
// === 基本用法 ===
db.products.aggregate([
  { $match: { category: 'Electronics' } },
  { $group: { _id: '$brand', total: { $sum: 1 } } }
]);

// === 返回游标 ===
const cursor = db.products.aggregate([...]);
const results = await cursor.toArray();

// === 聚合选项 ===
db.products.aggregate(
  [{ $match: {} }],
  {
    allowDiskUse: true,    // 允许使用磁盘(处理大数据集)
    maxTimeMS: 30000,      // 30 秒超时
    batchSize: 100,        // 批次大小
    hint: 'category_1'     // 强制使用索引
  }
);

4. 基础阶段详解

概念说明:聚合管道提供 30+ 个阶段操作符,本节聚焦最基础的 7 个:$match(过滤)、$group(分组聚合)、$project(投影/计算)、$sort(排序)、$limit(限制数量)、$skip(跳过)、$count(计数)。它们组合使用能覆盖 80% 的日常分析场景。

工作原理:每个阶段有特定的输入输出转换规则。$match 不改变文档结构,仅过滤;$group 将多文档合并为组,每组输出一条;$project 选择/计算输出字段;$sort/$limit/$skip 控制文档顺序和数量。关键优化原则:$match 尽早放,减少后续处理量。

100%
graph TB
    A[基础阶段] --> B[$match<br/>过滤文档<br/>类似 find 的 filter]
    A --> C[$group<br/>分组聚合<br/>$sum/$avg/$min/$max]
    A --> D[$project<br/>字段投影<br/>选择/计算字段]
    A --> E[$sort<br/>排序<br/>1升序 -1降序]
    A --> F[$limit/$skip<br/>分页<br/>限制/跳过]
    A --> G[$count<br/>计数<br/>输出文档数]
    
    B --> H["✅ 放最前面<br/>利用索引减少数据量"]
    
    style B fill:#fff3cd
    style C fill:#d4edda
    style H fill:#d4edda

(1) $match 筛选文档

$match 前置优化原理:$match 是聚合管道中唯一能利用索引的阶段,将其放在管道最前端是最重要的优化原则。原因:1. $match 在 $group 前执行,直接利用索引过滤,减少后续处理的数据量;2. MongoDB 查询优化器会尝试将 $match 下推到 $lookup 前执行,但不会重排用户定义的阶段顺序;3. $match 在 $group 后执行意味着先聚合全部数据再过滤,浪费大量计算资源。

聚合 vs 查询选择指南:何时用 aggregate 而非 find?判断标准:1. 需要分组统计($group)→必须 aggregate;2. 需要字段计算/重命名($project 计算字段)→aggregate;3. 需要多步转换(先过滤再分组再排序)→aggregate;4. 简单 CRUD 查询→find 更高效。aggregate 的开销比 find 大(管道初始化、阶段间数据传递),简单查询不要过度使用。

$match 与 find 的性能差异:虽然 $match 语法与 find 相同,但执行上下文不同——find 是独立查询,优化器全力优化;$match 是管道的一环,优化能力受管道结构限制。关键差异:1. find 可以用 covered query(索引覆盖查询,不读取文档),$match 在管道中始终读取文档;2. find 的 $hint 直接生效,$match 的 hint 通过 aggregate options 传入;3. find 返回的游标支持 batchSize 控制,aggregate 游标行为类似但初始化开销更大。

$match 索引利用的最佳实践:$match 利用索引的条件——1. $match 必须是管道的第一个阶段(或 $lookup 的子管道第一个阶段),否则无法利用索引;2. $match 的条件字段必须有索引(单字段索引或复合索引的前缀);3. $match + $sort 组合可以用复合索引同时满足过滤和排序;4. 用 explain() 验证:db.orders.aggregate([{$match: {status: 'paid'}}]).explain() 查看是否命中 IXSCAN。如果看到 COLLSCAN,说明 $match 没有利用索引——需要建索引或调整管道顺序。生产环境应定期检查慢查询日志,确认聚合管道的 $match 使用了索引。

JAVASCRIPT
// === $match 类似 find 的 filter ===
db.products.aggregate([
  { $match: { category: 'Electronics', price: { $gte: 100 } } }
]);
$match 优势 说明
在管道早期过滤 减少后续阶段处理的数据量
可使用索引 性能高(类似 find)

(2) $group 分组聚合

$group 分组原理:$group 是聚合管道中最核心的阶段——它将文档流按 _id 字段分组,每组独立执行累加器计算,输出一条聚合结果。_id 的值决定分组粒度:字段引用('_id: $category')按单字段分组,对象表达式('_id: {year, month}')按多字段组合分组,null 表示不分组对整个集合统计。理解 $group 的"一对多→一对一"转换是掌握聚合管道的关键。

$group 累加器详解:$group 提供多种累加器——$sum(求和/计数)、$avg(均值)、$min/$max(极值)、$first/$last(首末值)、$push/$addToSet(收集为数组/去重数组)。关键区别:$sum: 1 是计数(每条文档+1),$sum: '$field' 是求和(累加字段值);$push 保留重复值,$addToSet 自动去重;$first/$last 的值取决于输入排序(无 $sort 时不可预测)。$group 后文档数量急剧减少(N 条→M 个分组),后续阶段处理量小。

$group 的 _id 设计模式:$group 的 _id 决定分组粒度,是聚合结果最关键的设计决策——1. 单字段分组(_id: '$category'):按分类统计,输出每个分类一行;2. 多字段组合分组(_id: {category: '$category', status: '$status'}):按分类+状态交叉统计,输出每个组合一行;3. 日期分组(_id: {year: {$year: '$createdAt'}, month: {$month: '$createdAt'}}):按年月统计趋势;4. 全局统计(_id: null):不分组,对整个集合做一次汇总。_id 越精细,分组越多,结果越详细但聚合效果越弱;_id 越粗,分组越少,聚合效果越强但信息损失越多。

累加器的组合使用:$group 的多个累加器可以同时使用,构建丰富的统计结果——1. 同时计算 count/avg/min/max(基本统计四件套,覆盖描述性统计需求);2. $push 收集分组内的所有值(如收集每个分类的所有商品名),但注意 $push 可能产生大数组(用 $slice 截取或 $size 计数替代);3. $addToSet 收集不重复值(如统计每个分类有多少不同品牌),但 $addToSet 比其他累加器慢(需要做去重比较);4. $sum + $cond 实现条件计数(统计每个分类中 price > 1000 的商品数量:$sum: {$cond: [{$gt: ['$price', 1000]}, 1, 0]})。

$group 的常见陷阱与排错:$group 有三个常见陷阱——1. _id 不能省略:$group 必须指定 _id(即使是 null),否则报错;2. $group 后字段丢失:$group 只保留 _id 和累加器字段,原始字段全部丢失(如 $group 后要访问 name 字段,必须在累加器中 $push: '$name' 或在 $group 前用 $project 保留需要的字段再在 $group 后用 $lookup 关联回来);3. $group 改变文档结构:$group 后的文档是全新的聚合结果,与原始文档结构完全不同——后续阶段只能引用 _id 和累加器产生的字段。排错技巧:逐阶段运行聚合(去掉后续阶段),观察每步的输出结构。

JAVASCRIPT
// === 按字段分组 ===
db.products.aggregate([
  { $group: {
      _id: '$category',         // 分组字段
      count: { $sum: 1 },       // 计数
      avgPrice: { $avg: '$price' },
      maxPrice: { $max: '$price' },
      minPrice: { $min: '$price' }
  }}
]);
// [
//   { _id: 'Electronics', count: 250, avgPrice: 599, maxPrice: 1999, minPrice: 99 },
//   { _id: 'Books', count: 200, avgPrice: 29, maxPrice: 79, minPrice: 9 },
//   ...
// ]

// === 多字段分组 ===
db.orders.aggregate([
  { $group: {
      _id: { year: { $year: '$createdAt' }, month: { $month: '$createdAt' } },
      total: { $sum: '$total' },
      count: { $sum: 1 }
  }}
]);

// === 整个集合分组 ===
db.products.aggregate([
  { $group: {
      _id: null,                // 不分组,整个集合统计
      totalProducts: { $sum: 1 },
      avgPrice: { $avg: '$price' }
  }}
]);

(3) $project 字段投影

$project 的两种用法:$project 有两种用途——1. 字段选择(类似 SQL 的 SELECT),用 1/0 控制字段是否输出;2. 字段计算(类似 SQL 的 AS),用表达式创建新字段。注意:$project 的 1/0 规则中,_id 默认输出(需显式设 0 隐藏),其他字段一旦有一个设 1,其余默认 0(白名单模式);一旦有一个设 0,其余默认 1(黑名单模式)。混合使用会导致意外行为。

JAVASCRIPT
// === 选择输出字段 ===
db.products.aggregate([
  { $project: {
      sku: 1,
      title: 1,
      price: 1,
      discountedPrice: { $multiply: ['$price', 0.9] }  // 计算新字段
  }}
]);

// === 重命名字段 ===
db.products.aggregate([
  { $project: {
      productName: '$title',     // 重命名 title → productName
      price: 1,
      category: 1
  }}
]);

(4) $sort 排序

$sort 的内存限制与优化:$sort 在内存中排序,默认 100MB 内存限制——超出报错(除非 allowDiskUse: true)。优化策略:1. $sort 前用 $match 减少数据量(排序 1000 条比排序 100000 条快得多);2. 排序字段建索引(索引已排序,MongoDB 可直接按索引顺序返回,无需内存排序);3. $sort + $limit 组合时,MongoDB 只维护 Top N 的堆(而非全量排序),内存占用从 O(N) 降为 O(limit);4. 复合索引 {category: 1, price: -1} 可同时满足 $match + $sort,实现"覆盖查询+排序"。

JAVASCRIPT
// === 1 升序,-1 降序 ===
db.products.aggregate([
  { $sort: { price: -1 } }
]);

// === 多字段排序 ===
db.products.aggregate([
  { $sort: { category: 1, price: -1 } }
]);

(5) $limit / $skip 分页

$skip 的性能陷阱:$skip 越大性能越差——$skip(10000) 需要 MongoDB 扫描并丢弃前 10000 条文档,只是不返回给客户端,但扫描和排序的开销仍然存在。深度分页优化:1. cursor-based 分页(用 _id: {$gt: lastId} 替代 skip,性能恒定);2. 限制最大页码(如只允许翻到第 50 页,超过则提示缩小搜索范围);3. $sort + $limit 的 Top N 模式(不 skip,只取前 N 条)。offset 分页只在数据量小(< 1000 页)时可接受。

cursor-based 分页的实现细节:cursor-based 分页用上一页最后一条记录的排序值作为"游标"——1. 第一页:正常查询 $sort + $limit(N);2. 后续页:$match({createdAt: {$lt: lastCursor}, _id: {$lt: lastId}}) + $sort + $limit(N)。用两个字段(排序字段 + _id)确保游标唯一性(排序字段可能重复,但 _id 不会);3. 前端传递:上一页最后一条的 createdAt 和 _id 作为下一页的 cursor 参数;4. 优势:无论翻到第几页,查询复杂度恒定 O(N)(只扫描 N 条,不 skip);5. 限制:无法跳转到任意页码(只能"下一页"),不适合需要页码导航的场景。电商商品列表用 cursor-based,管理后台用 offset-based。

JAVASCRIPT
// === $limit 限制返回数量 ===
db.products.aggregate([
  { $sort: { price: -1 } },
  { $limit: 10 }
]);

// === $skip 跳过 ===
db.products.aggregate([
  { $sort: { price: -1 } },
  { $skip: 20 },
  { $limit: 10 }  // 第 21-30 条
]);

(6) $count 计数

$count 的使用场景:$count 是最简单的聚合阶段——输入 N 条文档,输出 1 条文档包含计数值。它等价于 $group({ _id: null, total: { $sum: 1 } }) 但语法更简洁。常见用途:1. 统计过滤后的文档数($match + $count);2. 在 $facet 中作为子管道获取总数(分页信息);3. 配合 $match 实现条件计数(如"有多少 Electronics 商品")。

JAVASCRIPT
// === 简单计数 ===
db.products.aggregate([
  { $match: { category: 'Electronics' } },
  { $count: 'totalElectronics' }
]);
// [ { totalElectronics: 250 } ]

// === 等同于 countDocuments ===
db.products.countDocuments({ category: 'Electronics' });

5. 累加器

概念说明:累加器(Accumulators)是 $group 阶段中使用的聚合函数,对每组文档执行计算并输出单一结果。$sum 求和、$avg 平均、$min/$max 极值、$first/$last 首尾值、$push/$addToSet 数组累积。累加器是统计分析的核心工具。

工作原理$group 阶段按 _id 字段将文档分组,每组独立执行累加器计算。$sum: 1 计数,$sum: '$field' 求和,$avg: '$field' 求平均,$push: '$field' 将所有值收集为数组,$addToSet: '$field' 收集去重后的值。_id: null 表示不分组,对整个集合统计。

100%
graph LR
    A[分组字段 _id] --> B[组1]
    A --> C[组2]
    A --> D[组3]
    
    B --> E["$sum: {$sum:1}<br/>$avg: {$avg:'$price'}<br/>$push: {$push:'$name'}"]
    C --> F["$sum: {$sum:1}<br/>$avg: {$avg:'$price'}<br/>$push: {$push:'$name'}"]
    D --> G["$sum: {$sum:1}<br/>$avg: {$avg:'$price'}<br/>$push: {$push:'$name'}"]
    
    E --> H[每组输出一条<br/>聚合结果]
    F --> H
    G --> H
    
    style H fill:#d4edda

(1) 完整累加器列表

累加器 含义 示例
$sum 求和 { $sum: '$price' }
$avg 平均值 { $avg: '$price' }
$min 最小值 { $min: '$price' }
$max 最大值 { $max: '$price' }
$first 第一个 { $first: '$name' }
$last 最后一个 { $last: '$name' }
$push 数组累积 { $push: '$name' }
$addToSet 数组去重累积 { $addToSet: '$name' }
$count 计数(顶层用) { $count: 'total' }

▶ 示例 1:累加器实战

累加器的组合使用:实际业务中,多个累加器通常组合在一个 $group 中——例如,电商分类统计需要同时计算 count($sum: 1)、totalRevenue($sum: '$price')、avgPrice($avg: '$price')、maxPrice($max: '$price')。这四个指标在一次 $group 中全部计算,无需多次查询。$group 是"数据压缩"阶段——输入 N 条文档,输出 M 个分组(M << N),后续阶段的处理量急剧减少。

$push vs $addToSet 的选择:$push 保留重复值(如 ['Electronics', 'Electronics', 'Books']),$addToSet 自动去重(如 ['Electronics', 'Books'])。选择依据:1. 需要完整的元素列表(含重复)→ $push(如"所有订单的商品列表");2. 只需要不重复的元素→ $addToSet(如"购买过的商品分类")。注意:$push 可能产生非常大的数组(热门分类有 10000 个商品名),需配合 $slice 限制数组长度或 $unwind + $group 重组。

JAVASCRIPT
// === 统计每个分类的商品数和价格统计 ===
db.products.aggregate([
  {
    $group: {
      _id: '$category',
      count: { $sum: 1 },
      totalStock: { $sum: '$stock' },
      avgPrice: { $avg: '$price' },
      maxPrice: { $max: '$price' },
      minPrice: { $min: '$price' },
      topProducts: { $push: '$title' }  // 所有商品名
    }
  }
]);

// === $addToSet 去重累积 ===
db.users.aggregate([
  {
    $group: {
      _id: '$city',
      uniqueRoles: { $addToSet: '$role' }  // 每个城市的角色集合
    }
  }
]);

输出:

TEXT 📖 仅展示
// 执行成功

6. 执行顺序优化

概念说明:聚合管道的性能高度依赖阶段顺序。核心优化原则:$match 尽早放、$project$group 后精简字段、$sort$match 相邻可利用索引。MongoDB 查询优化器会自动进行部分优化(如将 $match 下推到 $lookup 前),但不会改变用户定义的阶段顺序。

工作原理:优化器尝试两种优化:(1) $match 前移至 $sort 前合并为索引扫描;(2) $match 下推到 $lookup 的 pipeline 内部。但优化器不会重排用户定义的阶段顺序——如果 $match$group 之后,它无法自动前移。

100%
graph LR
    subgraph "✅ 优化顺序"
        A1[$match<br/>先过滤] --> B1[$sort<br/>排序] --> C1[$limit<br/>限制]
    end
    
    subgraph "⚠️ 反模式"
        A2[$sort<br/>全量排序] --> B2[$limit<br/>限制] --> C2[$match<br/>后过滤]
    end
    
    style A1 fill:#d4edda
    style C2 fill:#f8d7da
优化策略 效果 适用条件
$match 放最前 减少后续数据量 过滤字段有索引
$match + $sort 相邻 合并为索引扫描 排序字段有复合索引
$project 精简字段 减少内存占用 $group 后使用
hint() 强制索引 避免全集合扫描 优化器选错索引时
JAVASCRIPT
// ✅ 优化:$match 放在最前面
db.products.aggregate([
  { $match: { category: 'Electronics' } },  // 先过滤(用索引)
  { $sort: { price: -1 } },
  { $limit: 10 }
]);

// ⚠️ 反例:$match 放在后面
db.products.aggregate([
  { $sort: { price: -1 } },
  { $limit: 10 },
  { $match: { category: 'Electronics' } }   // 处理完才过滤,浪费资源
]);

7. 综合实战

管道执行原理与内存管理:聚合管道的每个阶段在内存中维护一个文档流,单阶段内存限制 100MB(超出报错,除非 allowDiskUse: true)。这意味着:1. $group 阶段的分组键种类不宜过多(百万级分组键会撑爆内存);2. $push 累加器收集所有值到数组,数据量大时极易超限;3. $sort 在内存中排序,大量数据需索引支持否则溢出。优化策略:$match 前置减少数据量、$project 精简字段、用 $sort + $limit 替代全量排序。

(1) 电商销售统计

JAVASCRIPT
// === 每月销售统计 ===
db.orders.aggregate([
  { $match: { status: 'paid', createdAt: { $gte: new Date('2026-01-01') } } },
  {
    $group: {
      _id: {
        year: { $year: '$createdAt' },
        month: { $month: '$createdAt' }
      },
      totalSales: { $sum: '$total' },
      orderCount: { $sum: 1 },
      avgOrderValue: { $avg: '$total' }
    }
  },
  { $sort: { '_id.year': 1, '_id.month': 1 } }
]);

(2) 商品分类分析

JAVASCRIPT
// === 商品分类 + 价格区间分析 ===
db.products.aggregate([
  {
    $bucket: {
      groupBy: '$price',
      boundaries: [0, 100, 500, 1000, 5000, 10000],
      default: 'Other',
      output: {
        count: { $sum: 1 },
        products: { $push: '$title' }
      }
    }
  }
]);

▶ 示例 2:ShopHub 电商分类分析管道

JAVASCRIPT
// 场景:ShopHub 运营团队需要多维度分析商品数据
// 准备数据
db.products.insertMany([
  { sku: 'PHONE-001', title: 'Smartphone X', category: 'Electronics', brand: 'TechCorp', price: 599, stock: 120, rating: 4.5 },
  { sku: 'PHONE-002', title: 'Smartphone Y', category: 'Electronics', brand: 'DataFlow', price: 399, stock: 80, rating: 4.0 },
  { sku: 'LAPTOP-001', title: 'Laptop Pro', category: 'Electronics', brand: 'TechCorp', price: 1299, stock: 50, rating: 4.8 },
  { sku: 'BOOK-001', title: 'MongoDB Guide', category: 'Books', brand: 'AppVenture', price: 29, stock: 500, rating: 4.2 },
  { sku: 'BOOK-002', title: 'Node.js Mastery', category: 'Books', brand: 'AppVenture', price: 39, stock: 300, rating: 4.6 }
]);

// 1. 按分类统计:商品数、均价、最高价、品牌列表
db.products.aggregate([
  {
    $group: {
      _id: '$category',
      count: { $sum: 1 },
      avgPrice: { $avg: '$price' },
      maxPrice: { $max: '$price' },
      minPrice: { $min: '$price' },
      totalStock: { $sum: '$stock' },
      brands: { $addToSet: '$brand' }
    }
  },
  { $sort: { count: -1 } }
]);

// 2. 价格区间分布($bucket)
db.products.aggregate([
  {
    $bucket: {
      groupBy: '$price',
      boundaries: [0, 100, 500, 1000, 5000],
      default: '5000+',
      output: { count: { $sum: 1 }, titles: { $push: '$title' } }
    }
  }
]);

// 3. 高评分商品(rating >= 4.5)
db.products.aggregate([
  { $match: { rating: { $gte: 4.5 } } },
  { $project: { sku: 1, title: 1, price: 1, rating: 1, _id: 0 } },
  { $sort: { rating: -1, price: 1 } }
]);

输出:1) 分类统计(Electronics: 3商品均价765.67, Books: 2商品均价34);2) 价格分桶(0-100: 2, 500-1000: 1, 1000-5000: 1);3) 高评分商品列表。

$group 的 _id 设计模式:$group 的 _id 决定了分组粒度——1. 单字段分组:_id: '$category'(按分类分组);2. 多字段分组:_id: {category: '$category', brand: '$brand'}(按分类+品牌交叉分组,输出复合键);3. 日期分组:_id: {year: {$year: '$createdAt'}, month: {$month: '$createdAt'}}(按年月分组,月度报表常用);4. 表达式分组:_id: {$cond: [{$gte: ['$price', 100]}, 'premium', 'budget']}(按条件分组,动态分类);5. null 分组:_id: null(不分组,计算全局统计,如总订单数和总销售额)。_id 设计决定了统计维度——多字段 _id 会产生笛卡尔积(每对组合一行输出),字段越多输出行越多。

累加器的组合使用:$group 的累加器可以组合使用——1. $sum + $avg:统计每个分类的商品数和平均价格(count: {$sum: 1}, avgPrice: {$avg: '$price'});2. $min + $max:计算价格范围(minPrice: {$min: '$price'}, maxPrice: {$max: '$price'});3. $push + $addToSet:收集分组内的值(products: {$push: '$title'} 保留重复,brands: {$addToSet: '$brand'} 去重);4. $first + $last:取分组内首尾值(配合 $sort 使用,取最早/最晚的记录)。注意:$push/$addToSet 可能产生大数组(每个分组内的所有值都收集),大数据集需配合 $limit 或 $slice 使用。

▶ 示例 3:$unwind + $group 数组拆分与聚合分析

$unwind 将数组字段拆分为多个文档,是处理嵌套数组数据的核心操作——订单中的商品列表、文章的标签数组、用户的角色列表等,都需要先 $unwind 拆开再做 $group 统计。本示例演示订单商品拆分后按 SKU 统计销量和收入。

JAVASCRIPT
// 测试数据:3 笔订单,每笔包含多个商品
db.orders.insertMany([
  {
    orderId: 'ORD-001', userId: 'u1', status: 'paid',
    items: [
      { sku: 'PHONE-001', name: 'Smartphone X', qty: 2, price: 599 },
      { sku: 'CASE-001', name: 'Phone Case', qty: 3, price: 19 }
    ],
    createdAt: new Date('2026-05-15')
  },
  {
    orderId: 'ORD-002', userId: 'u2', status: 'paid',
    items: [
      { sku: 'PHONE-001', name: 'Smartphone X', qty: 1, price: 599 },
      { sku: 'CHARGER-001', name: 'Fast Charger', qty: 2, price: 35 }
    ],
    createdAt: new Date('2026-06-01')
  },
  {
    orderId: 'ORD-003', userId: 'u1', status: 'paid',
    items: [
      { sku: 'CASE-001', name: 'Phone Case', qty: 5, price: 19 },
      { sku: 'CHARGER-001', name: 'Fast Charger', qty: 1, price: 35 }
    ],
    createdAt: new Date('2026-06-20')
  }
]);

// === 1. 按 SKU 统计销量和收入 ===
db.orders.aggregate([
  { $match: { status: 'paid' } },
  { $unwind: '$items' },
  {
    $group: {
      _id: '$items.sku',
      name: { $first: '$items.name' },
      totalQty: { $sum: '$items.qty' },
      totalRevenue: { $sum: { $multiply: ['$items.qty', '$items.price'] } },
      orderCount: { $sum: 1 }
    }
  },
  { $sort: { totalRevenue: -1 } },
  {
    $project: {
      sku: '$_id', name: 1, totalQty: 1, totalRevenue: 1,
      avgOrderQty: { $round: [{ $divide: ['$totalQty', '$orderCount'] }, 1] },
      _id: 0
    }
  }
]);

// === 2. 按商品+月份交叉统计(多字段 _id)===
db.orders.aggregate([
  { $match: { status: 'paid' } },
  { $unwind: '$items' },
  {
    $group: {
      _id: {
        sku: '$items.sku',
        month: { $dateToString: { format: '%Y-%m', date: '$createdAt' } }
      },
      monthlyQty: { $sum: '$items.qty' },
      monthlyRevenue: { $sum: { $multiply: ['$items.qty', '$items.price'] } }
    }
  },
  { $sort: { '_id.month': 1, monthlyRevenue: -1 } }
]);

// === 3. $unwind 的索引优化 ===
// $unwind 后文档数 = 原文档数 × 平均数组长度
// 大数组(>100 元素)$unwind 后可能产生大量中间文档
// 优化策略:先 $match 过滤再 $unwind,减少拆分数量
db.orders.aggregate([
  { $match: { status: 'paid', createdAt: { $gte: new Date('2026-06-01') } } },
  { $unwind: '$items' },
  { $match: { 'items.price': { $gte: 30 } } },
  {
    $group: {
      _id: '$items.sku',
      highValueQty: { $sum: '$items.qty' },
      highValueRevenue: { $sum: { $multiply: ['$items.qty', '$items.price'] } }
    }
  }
]);

输出:1) 按 SKU 统计——PHONE-001 总销量 3、收入 1797,CASE-001 总销量 8、收入 152,CHARGER-001 总销量 3、收入 105;2) 按商品+月份交叉——每个 SKU 每月的销量和收入;3) 优化版只统计 6 月后价格 ≥30 的商品。

$unwind 的注意事项:1. 数组为空时 $unwind 会丢弃整个文档——使用 { $unwind: { path: '$items', preserveNullAndEmptyArrays: true } } 保留空数组文档;2. $unwind 后文档数膨胀——100 个订单 × 平均 5 个商品 = 500 个中间文档,$match 前置可减少膨胀;3. 嵌套数组需要多次 $unwind——订单→商品→商品标签需要两次 $unwind,注意笛卡尔积风险。

❓ 常见问题

Q 聚合管道与 find 性能差多少?
A 聚合管道更强大但开销更大。简单查询用 find,复杂分析用 aggregate。
Q 聚合管道如何用索引?
A $match / $sort / $group 都能用索引。hint() 选项可强制使用特定索引。
Q 聚合管道的内存限制?
A 单 stage 内存限制 100MB。超过需 allowDiskUse: true 临时写入磁盘。
Q aggregate 返回的游标如何迭代?
Acursor.hasNext()cursor.next(),或在 mongosh 中直接遍历。

📖 小节


📝 作业

  1. 基础题(⭐):用 $group 统计每个分类的商品数量、平均价格。
  2. 基础题(⭐):用 $match + $sort + $limit 查询价格前 10 的 Electronics 商品。
  3. 进阶题(⭐⭐):按月份统计订单总数和总销售额($year/$month + $group)。
  4. 进阶题(⭐⭐):用 $project 计算 discountedPrice(price × 0.9)。
  5. 挑战题(⭐⭐⭐):完整销售仪表盘(按月/分类/客户三个维度的销售分析)。
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏