ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MongoDB聚合管道实战:从$match到$group的常用阶段精讲

MongoDB聚合管道实战:从$match到$group的常用阶段精讲 做过后端开发的迟早要和MongoDB的聚合操作打交道。我第一次接触聚合框架时面对一堆 $match、$group、$sort、$project 完全不知道从哪下手直到后来接手一个订单统计需求把常用阶段挨个用了一遍才算真正开窍。这篇不打算照搬官方文档我就按自己日常写聚合的顺序把最常见的聚合操作阶段梳理一遍顺便把那些文档里没写的坑也拿出来说说。如果你刚开始接触聚合或者虽然写过但总感觉靠几个命令硬撑这篇文章应该能帮你把管道逻辑彻底理清。1. 先把聚合管道的基本逻辑说清楚1.1 聚合管道就是一条流水线MongoDB 的聚合框架核心就是管道模型。你写db.orders.aggregate([{...}, {...}])时方括号里的每个对象就是一个“阶段”。执行时第一个阶段读取集合中的原始文档处理完把结果交给第二个阶段第二个阶段再处理再交给下一个直到最后一个阶段输出最终结果。这个设计很像工厂里的流水线原材料进来先经过切割再打磨再质检最后包装。每个阶段只管自己那一道工序不需要关心其他阶段内部怎么实现。聚合的好处也就在这——你可以把复杂的统计需求拆成一连串小操作而不是写一个巨大的嵌套查询。我经常碰到同事问为什么聚合有时候比 find 循环快因为管道里的阶段不是笨拙地“先全查出来再算”而是像流水线一样边读边处理很多阶段之间还能并行。越早把数据量缩小后面阶段就越轻松。1.2 理解“文档流”比记住语法更重要聚合过程中每个阶段的输入和输出都是一组文档。比如说 $match 阶段输入的是原始集合里的全部文档输出的是过滤后的文档$group 阶段输入的是上个阶段传来的文档输出的是分组统计后的文档。这条“文档流”是理解聚合的关键。另一个容易忽略的点是aggregate() 返回的是游标不是一次性把结果全部算好摆在内存里。只有当你遍历它时数据才会真正从服务器传回来。这也是为什么如果想把聚合结果保存下来要用 $out 或 $merge 写进集合而不是直接返回给客户端。1.3 常见阶段速查表阶段作用常见场景$match过滤文档筛选条件尽早缩小数据量$project投影字段、重命名字段、生成新字段控制输出结构做字段计算$group按字段分组并聚合计算统计数量、金额、平均值等$sort排序结果排序$limit限制返回条数取前N条$skip跳过前N条分页$unwind拆分数组字段处理内嵌数组$lookup关联另一个集合实现类SQL JOIN$addFields增加或覆盖字段管道中间加临时字段$count统计数量替代 count()$facet多维度并行统计一个管道跑多个统计$out/$merge将结果写入集合预聚合落地这些阶段不用死记硬背先记住大类过滤、变换、分组、排序、输出再往里填具体阶段。2. 日常开发最常用的四个阶段2.1 $match过滤越早越好$match 的语法和 find() 的查询条件一样支持 $gt、$gte、$lt、$in、$regex 这些操作符。比如我要查 2024 年已发货的订单db.orders.aggregate([ { $match: { status: shipped, orderDate: { $gte: ISODate(2024-01-01), $lt: ISODate(2025-01-01) } } } ]);写 $match 有一条铁律能往前放就往前放。因为 $match 在管道开头可以直接利用索引把大量不相干的文档过滤掉后面的 $group、$sort 处理的数据量就会小很多。反过来如果先 $unwind 把数组拆开再 $match文档数量会爆炸式增长过滤成本高得离谱。需要注意$match 里不能使用 $where 和 $near 这类操作符。我之前在 $match 里写 $near 做地理查询直接报错后来改成 $geoNear 阶段才解决。另外$match 出现在管道中间时如果字段是前面 $project 刚生成的那就没法走索引了所以中间阶段的过滤尽量提前。2.2 $group分组统计是核心$group 相当于 SQL 里的 GROUP BY。它把文档按 _id 指定的字段分组再用累加器计算各类统计指标。下面这段是按客户统计订单总金额、订单数和平均金额db.orders.aggregate([ { $group: { _id: $customerId, totalAmount: { $sum: $amount }, orderCount: { $sum: 1 }, avgAmount: { $avg: $amount } } } ]);常用的累加器有这些累加器作用$sum求和$sum: 1 就是计数$avg平均值$max / $min最大值 / 最小值$first / $last分组内第一个/最后一个文档的字段值$push把字段值收集成数组$addToSet把字段值收集成数组并去重新手最容易踩的坑是$group 的输出文档里除了 _id 以外的字段必须用累加器表达式生成不能直接写customerName: $customerName。这种写法在 $project 里没问题但在 $group 里会得到 null而且 MongoDB 不报错。如果你确实想保留分组内某个字段的原始值得用 $first、$last 或 $push。$group 的分组键还可以是嵌套文档比如按年月分组{ $group: { _id: { year: { $year: $orderDate }, month: { $month: $orderDate } }, total: { $sum: $amount } } }这样统计时间序列数据非常方便只是输出里的 _id 是嵌套对象后续要展平的话再接 $project 就行。2.3 $sort、$limit、$skip排序和分页三兄弟$sort 负责排序$limit 限制条数$skip 跳过前 N 条。三个经常配合使用做 TopN 和分页db.orders.aggregate([ { $sort: { amount: -1 } }, { $skip: 20 }, { $limit: 10 } ]);这是最常见的分页写法跳过前 20 条取出之后 10 条。问题在于 $skip 太大时分页性能会非常差。比如跳 10000 条MongoDB 依然会把前 10000 条排好再丢掉越到后面越慢。更推荐的做法是“游标分页”。记录上一页最后一条的排序值下一页用 $match 去查这个值之后的数据。比如按金额降序上一页最后一条金额是 500下一页就这样写db.orders.aggregate([ { $match: { amount: { $lt: 500 } } }, { $sort: { amount: -1 } }, { $limit: 10 } ]);如果金额相同的数据很多最好在排序条件里加上唯一字段比如{ amount: -1, _id: -1 }下一页再用amount 500 或 amount 500 且 _id 上次最后_id这样的复合条件去查避免漏数据或重复数据。2.4 $project挑字段、改结构、做计算$project 是控制输出结构的核心阶段。最简单的用法是保留需要的字段去掉不想要的db.orders.aggregate([ { $project: { customerId: 1, amount: 1, status: 1, _id: 0 } } ]);注意 _id 字段默认会带上不想让它出现必须显式写_id: 0。$project 还可以做字段计算比如把金额换算成万元再把订单号和客户ID拼成一个组合字段db.orders.aggregate([ { $project: { _id: 0, amountWan: { $divide: [$amount, 10000] }, ref: { $concat: [$orderNo, -, $customerId] } } } ]);这里要提醒早期 MongoDB 版本中$project 不能同时混用“包含字段”和“排除字段”4.4 之后才放宽。对一个字段也不能同时写 1 和 0。如果你只是想在原文档基础上新增一个字段最好用 $addFields因为 $project 会把没列出来的字段全部丢掉。3. 进阶阶段拆数组、做关联、加字段3.1 $unwind把内嵌数组拆开MongoDB 文档里经常有数组字段比如订单里的 items。如果你想统计每个商品的销量直接用 $group 没法对数组内部元素分组必须先 $unwind 把数组拆开db.orders.aggregate([ { $unwind: $items }, { $group: { _id: $items.productId, totalQty: { $sum: $items.qty } } } ]);$unwind 之后原来一条订单文档会变成多条每条保留数组中的一个元素其他字段都是原始值。它默认会丢弃空数组和没有该字段的文档。如果你不想丢要写成{ $unwind: { path: $items, preserveNullAndEmptyArrays: true } }这个参数很重要。比如做订单明细报表有些订单没有 items如果不加这个参数这些订单就没了统计结果会少一截。我第一次用的时候没注意排查了很久才发现是 $unwind 把空数组过滤掉了。如果数组很长建议先过滤再展开。比如只统计数量 0 的商品可以先用 $filter 把 items 过滤掉再 $unwind避免把没用的元素也拆出来db.orders.aggregate([ { $addFields: { items: { $filter: { input: $items, as: item, cond: { $gt: [$$item.qty, 0] } } } } }, { $unwind: $items } ]);这比“先 $unwind 再 $match”高效得多因为数据爆炸前就已经缩小了范围。3.2 $lookup像 SQL 一样关联集合$lookup 是聚合框架里最接近 SQL JOIN 的阶段。它可以把另一个集合的文档挂到当前文档的数组字段下。比如订单关联客户db.orders.aggregate([ { $lookup: { from: customers, localField: customerId, foreignField: _id, as: customerInfo } } ]);返回的结果里 customerInfo 一定是个数组哪怕只匹配到一条也是只有一个元素的数组。所以我后面通常会接 $unwind 展平或者用 $arrayElemAt: [$customerInfo, 0] 取第一个元素。如果要实现 LEFT JOIN 的效果$unwind 里要加 preserveNullAndEmptyArrays: true。$lookup 对性能非常敏感。关联字段上必须建索引不然两个大集合全扫描速度会慢得离谱。我见过一个线上查询orders 几万条customers 几十万条因为没建索引跑了快十秒加上索引后毫秒级返回。从 MongoDB 5.0 开始$lookup 支持 let pipeline 写法可以在关联时顺便做过滤db.orders.aggregate([ { $lookup: { from: payments, let: { orderId: $_id }, pipeline: [ { $match: { $expr: { $eq: [$orderId, $$orderId] } } }, { $match: { status: success } } ], as: payments } } ]);这种写法能把不想要的数据在关联过程中就排除掉而不是先把关联结果拉回来再过滤。3.3 $addFields 和 $count灵活修改结构与统计总数$addFields 的作用是在管道中给文档增加字段不会删除已有字段。比如我想把订单金额加上税费变成总价后面再按总价过滤db.orders.aggregate([ { $addFields: { totalPrice: { $add: [$amount, $tax] } } }, { $match: { totalPrice: { $gte: 1000 } } } ]);这个写法比用 $project 把所有字段重新列一遍要清爽得多尤其文档字段很多的时候。$count 则是最简单的计数阶段比如统计成功订单数db.orders.aggregate([ { $match: { status: success } }, { $count: successCount } ]);输出结果就是一个文档比如{ successCount: 123 }。如果你在一个管道里还需要做别的统计$count 就不够用了得回到 $group 用 $sum: 1。3.4 $facet 和 $out多维度统计与结果落地$facet 允许你在一个管道里同时跑多个子管道每个子管道独立输出一个统计结果。比如一次拿到总订单数、总金额、各状态订单数db.orders.aggregate([ { $facet: { totalCount: [{ $count: count }], totalAmount: [{ $group: { _id: null, total: { $sum: $amount } } }], byStatus: [ { $group: { _id: $status, count: { $sum: 1 } } } ] } } ]);$facet 的输出是一个文档每个键对应一个子管道的结果数组。做报表时特别方便一次聚合就能拿到多个维度的统计。$out 和 $merge 则可以把聚合结果持久化到新的集合。$out 会直接覆盖目标集合$merge 支持增量合并。这两个阶段非常适合做预聚合报表数据量大时先定时跑一个管道把统计结果存起来前端查汇总表而不是每天实时扫全量数据。4. 从零到一搭一个完整聚合管道4.1 需求拆解与数据模型前面讲了这么多阶段直接看语法容易飘。我拿一个真实场景走一遍完整流程方便你把阶段拼起来。假设 orders 集合里每条订单长这样{ _id: ObjectId(...), customerId: C001, status: completed, orderDate: ISODate(2024-03-15T10:30:00Z), amount: 258.00, tax: 20.64, items: [ { productId: P001, qty: 2, price: 129.00 }, { productId: P002, qty: 1, price: 0.00 } ] }现在老板需要一个报表按月份统计 2024 年已完成订单的销售额、订单数、平均客单价销售额从高到低排序取前 6 个月。拿到需求别急着写代码。先在脑子里拆范围限制2024 年、status 为 completed分组键月份指标销售额 sum(amount)订单数 count平均客单价 avg(amount)排序销售额倒序截断前 6拆完能映射到阶段先 $match再 $group接着 $project 展平 _id最后 $sort $limit。4.2 逐步写下管道代码第一步先过滤db.orders.aggregate([ { $match: { status: completed, orderDate: { $gte: ISODate(2024-01-01), $lt: ISODate(2025-01-01) } } } ]);这一段会大幅减少后续处理的数据量。生产环境可以在 status 和 orderDate 上建复合索引。第二步分组。按年份和月份组合作为 _id同时计算销售额、订单数、平均客单价{ $group: { _id: { year: { $year: $orderDate }, month: { $month: $orderDate } }, salesAmount: { $sum: $amount }, orderCount: { $sum: 1 }, avgOrderAmount: { $avg: $amount } } }这时候输出的 _id 是嵌套对象。如果前端希望 year 和 month 是独立字段就加一个 $project{ $project: { _id: 0, year: $_id.year, month: $_id.month, salesAmount: 1, orderCount: 1, avgOrderAmount: 1 } }最后排序和取前 6{ $sort: { salesAmount: -1 } }, { $limit: 6 }完整管道合起来db.orders.aggregate([ { $match: { status: completed, orderDate: { $gte: ISODate(2024-01-01), $lt: ISODate(2025-01-01) } } }, { $group: { _id: { year: { $year: $orderDate }, month: { $month: $orderDate } }, salesAmount: { $sum: $amount }, orderCount: { $sum: 1 }, avgOrderAmount: { $avg: $amount } } }, { $project: { _id: 0, year: $_id.year, month: $_id.month, salesAmount: 1, orderCount: 1, avgOrderAmount: 1 } }, { $sort: { salesAmount: -1 } }, { $limit: 6 } ]);这样一个完整的统计报表查询就写出来了。如果还要关联客户名可以在 $match 之后、$group 之前加 $lookup如果要按商品维度统计就要在 $group 之前 $unwind items。管道拼装的思路是一样的。4.3 验证结果与检查执行计划写完聚合不是结束我会立刻用 explain 看执行计划。MongoDB Compass 里可以可视化每个阶段的耗时和扫描文档数命令行里可以这样跑db.orders.explain(executionStats).aggregate([ ... ]);重点看几个地方$match 阶段有没有出现 COLLSCAN如果出现了说明索引没建好$group 阶段的输出文档数是否接近预期$sort 阶段消耗了多少内存我在实际项目里经常看到有人先 $group 再 $match这种写法既跑得慢又容易错。比如要统计订单金额大于 1000 的客户数量应该先 $match 把大额订单过滤出来再 $group 计数如果先 $group分组后每个客户的总金额可能包含小额订单后续条件就没法准确表达了。5. 常见问题与排查技巧实录5.1 阶段顺序不对结果差之千里聚合阶段顺序不仅是性能问题更是逻辑问题。$match 和 $unwind 的顺序就非常典型先 $match只能过滤订单最外层字段先 $unwind就能对数组元素做过滤但文档数量会膨胀。举个例子统计每个客户 2024 年超过 500 元的订单数。错误写法是先 $group 把所有年份的订单合并再去 $match 金额条件这肯定查不对。正确做法是先 $match 过滤年份和金额再 $group 计数。所以写管道前一定先把逻辑顺序捋清楚。5.2 $group 输出字段必须用累加器我再把这个坑重复一遍因为实在太多人踩。下面这种写法在 $group 里是不行的db.orders.aggregate([ { $group: { _id: $customerId, customerName: $customerName // 错误返回 null } } ]);$group 阶段的输出除了 _id 外其他字段必须通过累加器生成。想取客户名称可以改成 $first: $customerName或者后期再用 $lookup 关联。如果要用 $first还要注意提前排序否则取到的“第一个”是随机的。比如想取每个客户最新的一笔订单db.orders.aggregate([ { $sort: { orderDate: -1 } }, { $group: { _id: $customerId, latestOrderDate: { $first: $orderDate }, latestAmount: { $first: $amount } } } ]);这个“先排序再分组取 $first”是取每组最新一条的经典写法。5.3 内存限制与 allowDiskUse 的取舍聚合阶段默认有 100MB 内存限制一旦超出就会报错。解决办法是在 aggregate 后面加选项db.orders.aggregate( [ ... ], { allowDiskUse: true } );开启后可以使用磁盘临时文件来完成排序或分组但性能会明显下降。我通常不会一上来就开 allowDiskUse数据量不大时它反而让查询变慢。如果经常报内存不足优先优化管道比如提前 $match、$project 去掉不需要的字段而不是简单加个参数硬扛。5.4 $lookup 的字段类型匹配坑$lookup 的等值连接要求 localField 和 foreignField 的类型严格匹配。比如 orders 里的 customerId 存的是字符串 C001而 customers 集合的 _id 是 ObjectId那么 $lookup 一条都关联不上结果里 customerInfo 永远是空数组。这种问题很隐蔽因为不报错。排查方式是把两个样本文档拿出来对比字段类型确认都是字符串还是都是 ObjectId。如果历史数据有混用可以用 $addFields 先把类型转成一致的再做 $lookup{ $addFields: { customerIdStr: { $toString: $customerId } } }然后用 customerIdStr 作为 localField 去关联。5.5 空数组、null 值和缺失字段$unwind 默认会丢弃空数组和字段缺失的文档这是很多人踩过坑的地方。$group 遇到 null 分组键时也会把 null 单独归为一组。做报表时分组键为 null 的那组经常是脏数据最好在 $group 之前用 $match 排除掉。用 $sort 配合 $limit 做 TopN 时如果排序字段在部分文档中缺失MongoDB 会按 null 处理。升序时 null 排最前降序时 null 排最后很容易让结果里混入一些未预期的文档。如果你不想让缺字段的文档参与排序先加一步过滤。5.6 用 explain 定位慢查询explain 的执行计划是排查聚合性能问题的第一工具。我习惯看 executionStats 模式下的“扫描文档数”。如果扫描文档数远远大于返回文档数很可能说明过滤不够靠前或者索引没用到。聚合里每个阶段都会展示输出文档数。一旦发现某个阶段文档数突然暴涨优先怀疑 $unwind 或 $lookup。比如一个订单 $unwind items 后几万条变成几十万条紧接着的 $match 条件如果写在 $unwind 后前面白白膨胀了一大堆数据这时就该调整阶段顺序把能前置的条件都往前挪。6. 我个人写聚合的几个经验习惯6.1 需求先拆阶段再写代码不会写聚合很多时候不是语法不会而是逻辑没理顺。我拿到一个统计需求会先拿笔在纸上拆要过滤什么、按什么分组、输出哪些字段、最后怎么排序。拆完再映射到阶段代码基本就成型了。这个习惯也方便 code review别人一眼就能看出管道对应了需求里的哪几步。6.2 复杂查询要考虑预聚合数据量大了以后任何实时聚合都可能扛不住。我比较推荐的做法是离线或定时任务把统计结果算好写入一张汇总表业务查询直接查汇总表。$merge 支持增量合并非常适合这种场景。比如每小时跑一次按天的销售统计再把结果合并到 report 集合前端报表秒开。6.3 版本兼容性和代码可读性MongoDB 版本迭代很快写聚合之前先确认目标环境支持哪些阶段和操作符。有些新写法很简洁但线上还在用旧版本上线就报错。另外聚合管道一旦超过三四个阶段记得在代码里分段注释这比事后翻文档有用多了。最后分享一个小技巧多利用 Compass 的可视化聚合工具它可以让你每加一个阶段就看到中间结果对理解管道执行的细节特别有帮助。但生产环境出了问题还是得会命令行下的 explain。聚合框架的阶段看着多核心逻辑不外乎过滤、分组、重塑、关联、排序熟练之后看到需求脑子里自然就会浮现出管道长什么样。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进