
第一次被聚合管道教做人是在一个订单看板的需求上。当时订单集合里也就七八万条数据我用了最朴素的做法find({status: paid})把全部订单捞回应用层然后用一个 for 循环累加出总额、订单数再用一个 Map 按用户分组。开发环境跑得好好的测试环境数据一上来接口响应从 200ms 涨到 6 秒服务器内存也跟着往上蹿。后来改成aggregate同样的统计口径响应回到了几十毫秒。那一刻我才真正理解MongoDB 的聚合函数查询统计不是另一种查询语法而是把计算推到数据所在节点的思维方式——这在分布式数据库、NoSQL 的语境下尤其关键因为数据本来就散在多个分片上你把它们全拉回应用层再算等于把分布式的好处全扔了。这篇内容就是围绕 MongoDB 聚合管道做查询统计这件事把我这些年踩过的坑、常用的骨架、以及一些文档里不怎么强调的细节捋一遍。适合已经会写基本的find、想搞清楚$group、$unwind、$lookup、$facet到底怎么配合的人也适合做数据看板、报表、后台统计的开发者。文章里出现的所有写法都可以直接复制到mongosh或者 Compass 里跑我会尽量把为什么这么写讲清楚而不只是丢一段代码。1. 从 find() 加循环到聚合管道统计需求为什么必须换工具1.1 应用层循环累加的三个致命问题先说清楚为什么不能继续用find加循环。第一个问题是网络传输成本。假设一个订单文档平均 1KB100 万条就是 1GB 数据要从数据库传到应用进程而你真正需要的只是几个数字。这个开销和计算本身没关系纯粹是浪费。第二个问题是内存。把 100 万条文档反序列化成对象在大多数语言里占用的是原始数据的好几倍。Node.js 默认堆内存就一两 G很容易直接触发 OOM。你可能觉得加个分页就好了但统计必须建立在全量之上——分十页查每页算完再合并逻辑复杂度直线上升而且中间任何一页数据变动都会让结果失真。第三个问题是无法复用数据库的能力。索引、并行扫描、内存管理策略、分片下的局部聚合这些都是数据库内建的能力。你在应用层写循环等于把这些全绕过去了。打个比方你要统计仓库里每种货物的总重量正确做法是让仓管在仓库里用叉车和地磅清点而不是把货一件件搬到你家客厅再数。// 不推荐全量拉回应用层 const orders await db.collection(orders).find({ status: paid }).toArray(); const total orders.reduce((sum, o) sum Number(o.amount), 0); // 推荐让数据库算 const [result] await db.collection(orders).aggregate([ { $match: { status: paid } }, { $group: { _id: null, total: { $sum: $amount }, orders: { $sum: 1 } } } ]).toArray();1.2 聚合管道的心智模型文档在传送带上逐站加工aggregate()接收的是一个数组数组里每个元素叫阶段stage。前一个阶段的输出文档流成为后一个阶段的输入。你写的是顺序不是嵌套括号——这点和 SQL 的子查询思维差别很大。SQL 里你可能写SELECT ... FROM (SELECT ... FROM ...)一层套一层管道里你就是平铺地往下写像流水线一样。db.orders.aggregate([ { $match: { status: paid } }, // 站点1筛选 { $group: { _id: $userId, total: { $sum: $amount } } }, // 站点2分组累加 { $sort: { total: -1 } }, // 站点3排序 { $limit: 10 } // 站点4取前10 ])这段代码的含义是先留下已支付订单再按用户分组算出消费总额然后按总额降序排最后取前 10 名。每一步的输出都是一批文档只是文档结构在变——$group之后文档就只剩_id和total两个字段了原来的订单明细全没了。理解这一点很重要因为很多人会写出在$group之后还想引用原始字段的错误管道。1.3 分布式场景下聚合并行是怎么发生的既然标题里带分布式数据库这里值得多说一句。在分片集群里$group的执行被拆成两半分片上的部分聚合和mongos 上的最终合并。每个分片先对本地的文档做一次$group产出一个局部结果比如每个分片各自算出的用户消费小计然后把局部结果发给 mongosmongos 再把这些小计合并成最终结果。这个机制带来的一个直接推论是$group的性能很大程度上取决于分片是否均衡、以及分组键的分布。如果某个热点用户的订单集中在单个分片那个分片就会成为瓶颈。而$match如果命中分片键mongos 可以直接把请求定向到特定分片避免广播查询——这就是为什么前面反复强调$match要前置。统计需求形态推荐做法典型场合单维度分组累加$group 累加器按用户/品类统计金额、数量多维度交叉报表$facet多分支一个看板同时出多张表结果需要带明细$push$slice每个用户最近 5 笔订单需要关联其他集合$lookup 索引补用户昵称、补商品名称2. 管道阶段的摆放顺序$match、$project、$group、$sort、$limit 谁先谁后2.1 $match 前置唯一能吃到索引的阶段在整条管道里能利用索引来减少扫描文档数的阶段其实只有$match和$sort而且都有前提。$match必须出现在管道最前面严格说是$group、$unwind这类会改变文档形态的阶段之前优化器才会尝试把它下推到查询层用索引。举个对比就明白了。需求是统计 2024 年 8 月各品类的销售额集合里有 300 万条历史订单8 月的数据只有 12 万条。// 反面写法先分组再过滤 db.orders.aggregate([ { $group: { _id: $category, total: { $sum: $amount } } }, { $match: { month: 2024-08 } } ])这种写法的问题是$match里的month在$group之后才存在所以它不可能下推。数据库必须把全部 300 万条数据先聚合一遍然后过滤掉绝大部分——聚合的中间结果可能还超过 100MB 内存限制。// 正面写法先过滤再分组 db.orders.aggregate([ { $match: { status: paid, createdAt: { $gte: ISODate(2024-08-01T00:00:00Z), $lt: ISODate(2024-09-01T00:00:00Z) } } }, { $group: { _id: $category, total: { $sum: $amount } } } ])配合{ status: 1, createdAt: -1 }这个复合索引扫描的文档数从 300 万降到 12 万后面的$group压力直接小了一个量级。注意$match里如果写$expr来调用聚合表达式索引通常就吃不到了。能用普通查询操作符$gt、$in、$eq表达的就别用$expr。2.2 $project 的第二重作用给文档瘦身大部分人用$project是为了选字段但它真正的价值是尽早缩小文档体积。管道中每个阶段的内存占用、以及分片间传输的数据量都和文档大小成正比。如果原始订单文档有 20 多个字段后面只用到 3 个早点$project掉能省下可观的内存。db.orders.aggregate([ { $match: { status: paid } }, { $project: { userId: 1, amount: 1, category: 1, createdAt: 1, city: 1 } }, { $group: { _id: $category, total: { $sum: $amount } } } ])这里要注意$project和$addFields的区别很多新手会搞混$project是白名单语义你没写1的字段会被丢掉_id是例外默认保留想丢掉要显式写_id: 0。$addFields是增量语义保留原有全部字段只增加或覆盖你指定的那几个。选哪个取决于你想不想留一手。如果后面某个阶段还需要原始字段用$addFields更安全如果确定只要几个字段用$project更省资源。2.3 $group 之后接 $sort $limit 的 TopN 技巧$group的输出是无序的别指望_id有任何顺序规律。所以做 TopN 统计必须显式加$sort。这里有个优化点当$sort后面紧跟$limit时MongoDB 会做 top-k 排序——维护一个大小为 k 的堆不必把全部结果排完。数据量大时这个优化能省下大量内存。但要小心如果$sort和$limit中间插了$project或$addFields某些版本下这个优化可能被打断。所以我的习惯是只要不需要在中间加工字段就让$sort和$limit紧挨着。db.orders.aggregate([ { $match: { status: paid } }, { $group: { _id: $sku, qty: { $sum: $qty } } }, { $sort: { qty: -1 } }, { $limit: 10 } // 紧挨 $sort触发 top-k ])2.4 阶段顺序的检查清单我把判断顺序的逻辑整理成一张表写管道之前扫一眼基本不会出大错场景推荐顺序原因有时间/状态过滤$match→$group唯一能利用索引的时机字段很多但只用几个$match→$project→$group提早瘦身降低内存TopN 排行$match→$group→$sort→$limit触发 top-k 排序优化需要先排序再取每组的头几条$match→$sort→$group$first/$last依赖排序多分支报表$match→$facet避免重复过滤3. 累加器全家桶$sum、$avg、$push、$addToSet、$first、$last 的适用边界3.1 $sum 不只是求和$sum: 1 是计数的主力写法$group里最常用的累加器就是$sum但它有两种完全不同的用法。第一种是求和{ $sum: $amount }。第二种是计数{ $sum: 1 }——每遇到一条文档就加 1本质上是分组内文档数。db.orders.aggregate([ { $match: { status: paid } }, { $group: { _id: $city, orders: { $sum: 1 }, // 订单数 total: { $sum: $amount } // 金额合计 }}, { $sort: { orders: -1 } } ])还有个很多人不知道的技巧当$sum的字段是数组时它会自动把数组元素逐个相加。所以如果你的订单文档里直接存了prices: [10, 20, 30]写{ $sum: $prices }得到的是 60不需要先$unwind。这个特性在统计每个订单的商品总额时特别顺手。关于 null 和缺失字段的处理$sum会忽略非数值null、字符串、缺失字段都不参与运算如果分组内一个数值都没有结果是 0 而不是 null。这一点和$avg不一样下面说。3.2 $avg、$min、$max 的缺失值陷阱$avg只对数值求平均同样忽略非数值。但关键在于如果分组内没有任何数值$avg返回 null。这在报表里会造成空白格前端渲染时可能直接显示 null体验很差。常见的兜底写法是用$ifNull{ $group: { _id: $category, avgPrice: { $avg: $price }, minPrice: { $min: $price }, maxPrice: { $max: $price } }}, { $project: { avgPrice: { $ifNull: [$avgPrice, 0] }, minPrice: { $ifNull: [$minPrice, 0] }, maxPrice: { $ifNull: [$maxPrice, 0] }, range: { $subtract: [$maxPrice, $minPrice] } }}注意$min和$max的返回值类型跟输入一致如果字段是NumberDecimal返回的也是 Decimal前端处理时要留意。另外$avg有个进阶用法{ $avg: { $sum: $amount } }这种嵌套写法可以算加权平均虽然不直观但确实管用。3.3 $push 与 $addToSet把明细带回来统计数据之外看板经常需要每组附带几条明细比如每个用户最近 5 笔订单、每个品类销量前 3 的商品。这时候用$push。db.orders.aggregate([ { $match: { status: paid } }, { $sort: { createdAt: -1 } }, // 关键先进组前排序 { $group: { _id: $userId, total: { $sum: $amount }, recent: { $push: { orderNo: $orderNo, amount: $amount, at: $createdAt } } }}, { $project: { total: 1, recent: { $slice: [$recent, 5] } } } ])这里的$slice是必须的。原因很硬单个 BSON 文档有 16MB 上限。如果一个用户有上万笔订单$push全部明细进去文档直接超过限制查询会以BSONObjectTooLarge报错。所以$push之后一定要用$slice截断我一般截 3 到 10 条够看板展示就行。$addToSet是去重版本把唯一值放进数组。它适合统计某字段有哪些不同取值比如每个城市出现过哪些支付方式。但有两个注意点一是它不保证顺序别指望输出是插入顺序二是文档级去重比较的是字段和值的完全一致包括字段顺序所以对象元素去重时容易出意外。3.4 $first 与 $last 的隐式依赖必须先排序$first和$last取的是分组内文档流的第一个和最后一个而这个顺序完全取决于$group之前有没有$sort。如果不排序文档流顺序是不确定的结果自然也就是随机的——这个坑非常隐蔽因为代码能跑通但每次刷新结果可能不一样。db.orders.aggregate([ { $match: { status: paid } }, { $sort: { createdAt: -1 } }, // 没有这行下面就是随机 { $group: { _id: $userId, lastOrder: { $first: $$ROOT } } } ])$first: $$ROOT表示取整条原始文档配合排序就能拿到每个用户的最近一笔订单。这个写法在做最新状态快照统计时很好用。累加器典型用途是否忽略 null是否去重注意点$sum求和、计数是否数组字段自动展开$avg平均值是否全非数值时返回 null$min/$max极值是否返回值类型跟随输入$push收集明细是否必须配合$slice$addToSet收集唯一值是是顺序不保证$first/$last取首尾文档否否依赖前置$sort4. 数组型数据的统计$unwind、$size、$filter 的配合套路4.1 $unwind 到底把一条文档拆成了什么样订单里嵌items数组是很常见的建模方式一条订单包含多个商品行。要按商品的品类维度统计销售额就必须把数组摊平——这就是$unwind的职责。假设文档是这样的{ _id: ObjectId(...), orderNo: SO20240901001, amount: NumberDecimal(328.00), items: [ { sku: A001, category: 数码, price: 199, qty: 1 }, { sku: B017, category: 家居, price: 129, qty: 1 } ] }执行{ $unwind: { path: $items, includeArrayIndex: idx } }之后会变成两条文档除了items从数组变成了单个对象、多了一个idx索引字段之外其他字段orderNo、amount都被复制了一份。理解其他字段被复制这点很关键因为这意味着$unwind之后的{ $sum: $amount }会把订单金额重复计算 N 次——这是报表数字虚高最常见的元凶。所以$unwind之后统计金额正确的累加对象应该是items里的字段或者用$multiply算商品行小计db.orders.aggregate([ { $match: { status: paid } }, { $unwind: $items }, { $group: { _id: $items.category, total: { $sum: { $multiply: [$items.price, $items.qty] } }, rows: { $sum: 1 } }}, { $sort: { total: -1 } } ])4.2 preserveNullAndEmptyArrays一个参数救过无数报表$unwind默认会丢弃items为空数组或字段缺失的文档。这在统计订单数时会出问题购物车清空后提交的订单、或者 items 字段压根没写的脏数据会从结果里消失导致你算出来的订单数比countDocuments少。解决方法是加preserveNullAndEmptyArrays: true。加了之后这类文档会被保留items的值变成 null字段缺失的情况上游字段不受影响。db.orders.aggregate([ { $match: { status: paid } }, { $unwind: { path: $items, preserveNullAndEmptyArrays: true } }, { $group: { _id: $items.category, orders: { $sum: 1 } } } ])这时候_id会出现一个 null 分组代表没有商品明细的订单。这个分组不是 bug反而是有价值的信号——它提醒你数据质量有问题。我一般会在看板上把它单独展示出来或者用$match过滤掉但同时在日志里记一笔。报表数字对不上的时候第一个要检查的就是$unwind有没有悄悄吃掉文档。4.3 不拆数组也能统计$size、$filter、$reduce 的替代方案$unwind的代价是文档数被放大后续所有阶段都在放大后的数据上跑。如果统计维度不跨越分组边界用数组表达式在单文档内算完更划算。db.orders.aggregate([ { $match: { status: paid } }, { $project: { orderNo: 1, itemCount: { $size: $items }, // 商品行数 expensiveCount: { // 单价 100 的行数 $size: { $filter: { input: $items, as: it, cond: { $gt: [$$it.price, 100] } } } }, itemsTotal: { // 商品行金额合计 $reduce: { input: $items, initialValue: 0, in: { $add: [$$value, { $multiply: [$$this.price, $$this.qty] }] } } } }} ])这三个操作符分工很清楚$size数个数$filter挑出满足条件的子集返回数组外面套$size才得到数量$reduce做自定义累加。$$this指当前元素$$value指累计值这个命名规则记熟了就不容易写错。判断该用哪种方式的准则很简单如果后面要按数组里的某个字段分组必须$unwind如果只是在文档级别汇总数组内部的信息优先用数组表达式。4.4 两个数组同时展开的顺序陷阱有些文档里有两个数组字段比如items和discounts。如果对两个都$unwind会得到笛卡尔积——3 个商品乘以 2 个折扣等于 6 条文档金额统计直接爆炸。这种情况我一般只$unwind一个另一个用$filter或$map在文档内处理。如果业务上确实需要展开两个数组一定要明确写出预期结果并且在测试环境用小数据集验证条数。我自己踩过一次明细表从几万行变成几百万行跑了一整晚才出结果后来查出来就是双重$unwind。5. 跨集合补维度$lookup 的写法、索引与性能代价5.1 基础形式与 pipeline 形式统计数据出来之后往往还要补上人看的名字——订单里存的是userId报表要展示用户名这就得关联users集合。db.orders.aggregate([ { $match: { status: paid } }, { $lookup: { from: users, localField: userId, foreignField: _id, as: user }}, { $unwind: { path: $user, preserveNullAndEmptyArrays: true } }, { $group: { _id: $user.city, total: { $sum: $amount } } } ])$lookup的结果user是数组因为关联可能匹配到多条。通常紧跟着一个$unwind把它摊平。这里的preserveNullAndEmptyArrays同样不能省——如果订单的userId在users里找不到用户被删了、数据不一致不加这个参数这条订单会被$unwind丢掉统计又会少数据。如果关联时需要带条件用 pipeline 形式需要 3.6 及以上版本db.orders.aggregate([ { $match: { status: paid } }, { $lookup: { from: users, let: { uid: $userId }, pipeline: [ { $match: { $expr: { $eq: [$_id, $$uid] } } }, { $project: { name: 1, city: 1 } } // 只在关联时取需要的字段 ], as: user }} ])pipeline 形式的好处是可以在关联时顺手$project只把需要的字段带回来减少内存占用。let定义变量$$uid在子管道里引用这个$$前缀是硬性语法漏了会直接报未定义变量。5.2 索引是 $lookup 的命门$lookup最容易出性能事故的地方在于from集合的foreignField如果没索引每处理一条左文档就要全集合扫一遍。注意是每条。左表 10 万条右表 50 万条最坏情况就是 10 万次全表扫描——这个复杂度在几千条数据上完全看不出来上量之后就是灾难。上线前一定要检查db.users.getIndexes()看到_id是默认索引所以foreignField: _id天然有索引但如果关联的是userId、orderNo这类业务字段就得手动建db.users.createIndex({ userId: 1 })5.3 用冗余字段换掉 $lookup如果某个统计只需要关联集合里的一个字段比如用户名而这个字段几乎不变我会考虑把它冗余到订单集合里。这是 NoSQL 里很典型的做法用冗余换关联和关系型数据库的范式思维完全不同。方案延迟数据一致性维护成本适用场景$lookup实时关联中到高强一致低关联字段经常变、数据集不大冗余字段低最终一致中需要同步机制读多写少、字段稳定应用层二次查询取决于缓存可控高关联集合被大量共用选哪个没有绝对答案。我的经验是报表查询频率高、关联字段又基本不变比如用户名、城市冗余最划算如果字段会频繁更新比如用户等级、余额老老实实用$lookup或者接受一点延迟做异步同步。提示$lookup只能在分片集合之间做关联时如果from集合也是分片的关联键需要包含分片键否则 4.4 以前版本会直接报错。升级到 5.1 后限制放宽了一些但性能上依然要注意。6. 时间维度统计时区、$dateToString 与报表口径6.1 时区这个坑比语法难缠MongoDB 内部统一用 UTC 存储时间。这本身没问题但问题是$year、$month、$dayOfMonth这些操作符默认按 UTC 取值。假设你在东八区9 月 1 日早上 7 点产生的订单它的 UTC 时间是 8 月 31 日 23 点。如果你直接$month分组这笔订单会被算进 8 月。日报表、月报表里这个错位会持续存在——每天 0 点到 8 点的数据都会归到前一天而 8 点到 24 点则是正常的。这种差一点点的错误最难发现因为总量看起来是对的。解决办法是显式指定时区db.orders.aggregate([ { $match: { status: paid } }, { $group: { _id: { $dateToString: { format: %Y-%m-%d, date: $createdAt, timezone: Asia/Shanghai } }, total: { $sum: $amount }, orders: { $sum: 1 } }}, { $sort: { _id: 1 } } ])6.2 $dateToString 与 $dateTrunc 的选择$dateToString返回的是字符串适合直接当分组键展示前端不用再格式化。缺点是字符串不能做时间运算。$dateTrunc5.0 引入返回的是Date 类型优势是后续还能继续用时间操作符比如算两个时间桶的间隔、或者再做范围过滤。// 5.0 按小时截断 { $group: { _id: { $dateTrunc: { date: $createdAt, unit: hour, timezone: Asia/Shanghai } }, orders: { $sum: 1 } }}如果版本比较老比如 4.4没有$dateTrunc我的绕法是先用$dateToString转成字符串再$dateFromString转回时间或者在$project里用$subtract减去时间戳的余数来取整。6.3 按周、按月统计的口径要先和业务对齐周统计有个经典争议周一开始还是周日开始$isoWeek是 ISO 标准周一$week是周日起算。这两个函数算出来的周编号可能差一而且跨年时的表现也不一样。这类口径问题必须在写代码之前和运营、产品确认清楚不然看板一上线就要被追问为什么这周的数字和上周对不上。另外$week和$isoWeek返回的是周序号不是日期做趋势图时前端还得把序号映射成日期区间。如果趋势图要连续展示建议在应用层补齐没有数据的周否则折线图会突然跳一段。6.4 时间范围过滤和分组口径要一致前面提到时区会整体偏移 8 小时还有一个连带问题$match的时间边界也要按同样的口径换算。如果$group按北京时间分组但$match用 UTC 的月份边界就会出现两个错误——要么漏掉当月前 8 小时的数据要么把上月末 8 小时算进来。// 要统计北京时间 8 月实际的时间范围是 UTC 7/31 16:00 到 8/31 16:00 const start ISODate(2024-07-31T16:00:00Z); const end ISODate(2024-08-31T16:00:00Z); db.orders.aggregate([ { $match: { status: paid, createdAt: { $gte: start, $lt: end } } }, { $group: { _id: { $dateToString: { format: %Y-%m-%d, date: $createdAt, timezone: Asia/Shanghai } }, total: { $sum: $amount } }}, { $sort: { _id: 1 } } ])我更稳的做法是$match的时间边界故意放宽一点比如前后各多取一天分组之后再按精确日期过滤。多扫一天数据的代价很小但能避免边界算错导致的漏数。7. 一个请求出多份报表$facet 与 $bucket 的实战用法7.1 $facet 把多个管道塞进一个请求做数据看板的时候同一个筛选条件往往要出好几张表按品类销售额、按城市订单数、Top10 商品、每日趋势。如果不用$facet就得发四次请求每次各自$match一遍。如果$match前面还有$lookup或者复杂的$unwind重复成本会成倍放大。$facet的设计就是把共享前置阶段变成可能db.orders.aggregate([ { $match: { status: paid, createdAt: { $gte: start, $lt: end } } }, { $facet: { byCategory: [ { $unwind: $items }, { $group: { _id: $items.category, total: { $sum: { $multiply: [$items.price, $items.qty] } } } }, { $sort: { total: -1 } } ], byCity: [ { $group: { _id: $city, orders: { $sum: 1 }, total: { $sum: $amount } } }, { $sort: { orders: -1 } } ], topProducts: [ { $unwind: $items }, { $group: { _id: $items.sku, qty: { $sum: $items.qty } } }, { $sort: { qty: -1 } }, { $limit: 10 } ], trend: [ { $group: { _id: { $dateToString: { format: %Y-%m-%d, date: $createdAt, timezone: Asia/Shanghai } }, total: { $sum: $amount } }}, { $sort: { _id: 1 } } ] }} ])结果是一个文档里面有byCategory、byCity、topProducts、trend四个数组。一次请求一次前置过滤四个分支各自独立跑。用$facet有几个硬性限制必须记住子管道里不能再嵌套$facet会直接报错。每个子管道的输出会汇总到一个结果文档里而这个文档有16MB 上限。所以每个分支末尾最好加$limit尤其是可能产出大量行的分支。内存限制方面4.4 之前每个 facet 子管道各自算 100MB之后的版本行为有调整建议不要依赖具体数字而是通过加$limit来控制输出规模。7.2 $bucket 做区间分布统计另一个高频需求是区间分布订单金额落在 0-100、100-500、500-1000、1000 以上各有多少单。用$switch配合$group能写但代码很啰嗦$bucket是专门干这个的{ $bucket: { groupBy: $amount, boundaries: [0, 100, 500, 1000], default: 1000, output: { orders: { $sum: 1 }, total: { $sum: $amount } } }}几个细节boundaries是左闭右开所以[0, 100]表示0 且 100。default是必须考虑的用来接住超出边界范围的值。如果不写default而数据里存在小于第一个边界或大于等于最后一个边界的值查询会直接报错。我见过不止一次线上查询因为这个报错排查半天。groupBy的字段类型必须和boundaries的元素类型一致。如果amount存的是NumberDecimal边界的数字会被当成 double某些版本会类型不匹配报错——这时用{ $toDecimal: $amount }或{ $toDouble: $amount }显式转换一下最稳妥。$bucketAuto会让数据库自动分桶适合做直方图但它自己决定边界报表口径不稳定我一般只在探索性分析时用正式看板还是用$bucket固定边界。7.3 $facet 里放 $bucket 的典型结构把分布统计和其他分支一起塞进$facet一次返回看板要的所有数字这是我最常用的报表骨架db.orders.aggregate([ { $match: { status: paid, createdAt: { $gte: start, $lt: end } } }, { $facet: { summary: [ { $group: { _id: null, orders: { $sum: 1 }, total: { $sum: $amount }, avg: { $avg: $amount } } }, { $project: { _id: 0, orders: 1, total: 1, avg: { $round: [$avg, 2] } } } ], amountBuckets: [ { $bucket: { groupBy: { $toDouble: $amount }, boundaries: [0, 100, 500, 1000, 5000], default: 5000, output: { orders: { $sum: 1 } } }} ] }} ])8. 内存、磁盘与索引聚合性能的边界条件和排查顺序8.1 100MB 这条线是怎么触发的聚合阶段默认每个阶段最多用 100MB 内存超了就报Exceeded memory limit for $group, but didnt allow external sort。这个报错信息有个坑它往往指向$group或$sort但真正超限的可能是在它们之前把文档放大很多的阶段比如$unwind或者一个$lookup把每条订单都挂上了一堆用户数据。触发条件的判断可以从三个维度入手分组基数distinct 的_id数量是不是特别大、$push的数组是不是很长、$unwind的放大倍数是不是很高。这三个里任何一个超预期内存都会爆。8.2 allowDiskUse 和它的代价db.orders.aggregate([...], { allowDiskUse: true })开启之后超限的部分会写到临时文件。它能救急但代价是查询速度大概会慢一个数量级因为磁盘 IO 比内存慢太多了。我的处理流程是开发环境一律不加allowDiskUse先看能不能通过优化管道解决如果优化到极限还是超再考虑加。优化的优先级按下面的顺序来$match前置并且确认索引命中。尽早$project瘦身把不用的字段先丢掉。减少$unwind的放大倍数能不用数组表达式就不用$unwind。降低分组基数或者把一次大统计拆成多次小统计比如按天分批。前面都做完还超才加allowDiskUse: true。8.3 各阶段和索引的关系这个表我贴在工位上写管道前扫一眼阶段能否利用索引说明$match能必须位于管道前部且用普通查询操作符$sort能在$match之后、改变文档形态的阶段之前$group不能必须遍历全部输入文档$lookup部分from集合的foreignField索引决定性能$unwind不能会放大文档数量$facet不能子管道各自处理8.4 用 explain 定位问题explain是排查聚合性能的第一工具注意要用executionStats模式db.orders.explain(executionStats).aggregate([ { $match: { status: paid } }, { $group: { _id: $city, total: { $sum: $amount } } } ])看几个关键数字totalDocsExamined和nReturned的比值。理想情况接近 1如果远大于 1比如 100 万扫出 1 万说明索引没吃上出现了全集合扫描。有没有出现COLLSCAN字样出现就是全表扫描。executionTimeMillisEstimate看整体耗时。另一个实用技巧是分段验证把管道截断到$group之前先看看中间结果的条数是不是符合预期。如果$unwind之后文档数比原始文档数多了十几倍那你就知道内存问题的来源了。注意explain返回的结构在不同版本之间有差异5.0 之后聚合的 explain 结构有调整。看的时候重点找stages数组里每个阶段的nReturned和docsExamined这两个是通用的。9. 完整案例订单统计从需求拆到可执行脚本9.1 需求拆解和数据结构假设运营要给一个订单看板需求是这五项总订单数、总金额、客单价按品类销售额 Top5按城市订单分布近 30 天每日订单金额趋势北京时间单笔金额区间分布数据模型大致是这样{ _id: ObjectId(...), orderNo: SO20240901001, userId: ObjectId(...), city: 杭州, amount: NumberDecimal(328.00), status: paid, createdAt: ISODate(2024-09-01T02:13:00Z), items: [ { sku: A001, category: 数码, price: NumberDecimal(199.00), qty: 1 }, { sku: B017, category: 家居, price: NumberDecimal(129.00), qty: 1 } ] }9.2 索引先建管道后写这一步很多人会跳过直接写管道然后抱怨慢。实际上索引决定了$match的效率是整条管道的性能地基。db.orders.createIndex({ status: 1, createdAt: -1 })复合索引字段顺序有讲究等值条件放前面范围条件放后面。status是等值createdAt是范围所以status在前。如果反过来写{ createdAt: -1, status: 1 }createdAt的范围扫描会先限制住候选集后面的status等值过滤效果就差了。9.3 完整聚合脚本const start ISODate(2024-08-14T16:00:00Z); // 北京时间 8/15 00:00 const end ISODate(2024-09-13T16:00:00Z); // 北京时间 9/14 00:00 const report db.orders.aggregate([ { $match: { status: paid, createdAt: { $gte: start, $lt: end } } }, { $facet: { // 1. 总量指标 summary: [ { $group: { _id: null, orders: { $sum: 1 }, total: { $sum: $amount }, avg: { $avg: $amount } }}, { $project: { _id: 0, orders: 1, total: { $round: [$total, 2] }, avg: { $round: [{ $ifNull: [$avg, 0] }, 2] } }} ], // 2. 品类销售额 Top5 byCategory: [ { $unwind: $items }, { $group: { _id: $items.category, total: { $sum: { $multiply: [$items.price, $items.qty] } }, rows: { $sum: 1 } }}, { $sort: { total: -1 } }, { $limit: 5 } ], // 3. 城市分布 byCity: [ { $group: { _id: $city, orders: { $sum: 1 }, total: { $sum: $amount } } }, { $sort: { orders: -1 } }, { $limit: 20 } ], // 4. 每日趋势北京时间 trend: [ { $group: { _id: { $dateToString: { format: %Y-%m-%d, date: $createdAt, timezone: Asia/Shanghai } }, total: { $sum: $amount }, orders: { $sum: 1 } }}, { $sort: { _id: 1 } } ], // 5. 金额区间分布 amountBuckets: [ { $bucket: { groupBy: { $toDouble: $amount }, boundaries: [0, 100, 500, 1000, 5000], default: 5000, output: { orders: { $sum: 1 }, total: { $sum: $amount } } }} ] }} ], { allowDiskUse: true }).toArray()[0];9.4 几个验证和收尾动作脚本跑通之后我会做三件事。第一件是对总数。用db.orders.countDocuments({ status: paid, createdAt: { $gte: start, $lt: end } })的结果和summary里的orders比对。如果对不上九成是某个分支的$unwind吃掉了文档或者$match条件不一致。第二件是检查 16MB 上限。byCity和trend我加了$limit但byCategory只限了 5 条如果品类特别多amountBuckets也可能没限。正式上线前用真实数据量跑一次确认结果文档不会触顶。第三件是考虑预聚合。如果数据量到了千万级实时跑$facet就算有索引也会慢。这时候常规做法是用$merge把结果写进一张统计表定时任务每天跑一次看板直接读统计表db.orders.aggregate([ { $match: { status: paid, createdAt: { $gte: start, $lt: end } } }, { $group: { _id: { $dateToString: { format: %Y-%m-%d, date: $createdAt, timezone: Asia/Shanghai } }, total: { $sum: $amount }, orders: { $sum: 1 } }}, { $merge: { into: daily_stats, on: _id, whenMatched: replace, whenNotMatched: insert } } ])$merge4.2的whenMatched和whenNotMatched两个参数控制写入行为做成幂等的重跑逻辑很方便——同一天重跑会覆盖而不是重复插入。我个人的体会是MongoDB 的聚合管道看着阶段多、操作符杂但真正高频用到的就那么十几个。把$match前置、$group的累加器语义、$unwind的放大效应、时区口径这四件事吃透剩下的大部分都是查文档的事。真正难的不是语法是在写第一行管道之前先想清楚数据在每一站会变成什么样、数量是放大还是缩小、分组键的基数有多大。我踩过的坑里十有八九都是没想清楚这几件事而不是不会写某个操作符。