news 2026/9/17 14:40:33

MongoDB聚合管道:查询统计与性能优化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MongoDB聚合管道:查询统计与性能优化实战

第一次被聚合管道教做人,是在一个"订单看板"的需求上。当时订单集合里也就七八万条数据,我用了最朴素的做法:find({status: "paid"})把全部订单捞回应用层,然后用一个 for 循环累加出总额、订单数,再用一个 Map 按用户分组。开发环境跑得好好的,测试环境数据一上来,接口响应从 200ms 涨到 6 秒,服务器内存也跟着往上蹿。后来改成aggregate,同样的统计口径,响应回到了几十毫秒。那一刻我才真正理解,MongoDB 的聚合函数查询统计不是"另一种查询语法",而是把计算推到数据所在节点的思维方式——这在分布式数据库、NoSQL 的语境下尤其关键,因为数据本来就散在多个分片上,你把它们全拉回应用层再算,等于把分布式的好处全扔了。

这篇内容就是围绕 MongoDB 聚合管道做查询统计这件事,把我这些年踩过的坑、常用的骨架、以及一些文档里不怎么强调的细节捋一遍。适合已经会写基本的find、想搞清楚$group$unwind$lookup$facet到底怎么配合的人,也适合做数据看板、报表、后台统计的开发者。文章里出现的所有写法都可以直接复制到mongosh或者 Compass 里跑,我会尽量把"为什么这么写"讲清楚,而不只是丢一段代码。

1. 从 find() 加循环到聚合管道:统计需求为什么必须换工具

1.1 应用层循环累加的三个致命问题

先说清楚为什么不能继续用find加循环。第一个问题是网络传输成本。假设一个订单文档平均 1KB,100 万条就是 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之后,文档就只剩_idtotal两个字段了,原来的订单明细全没了。理解这一点很重要,因为很多人会写出"在$group之后还想引用原始字段"的错误管道。

1.3 分布式场景下聚合并行是怎么发生的

既然标题里带"分布式数据库",这里值得多说一句。在分片集群里,$group的执行被拆成两半:分片上的部分聚合mongos 上的最终合并。每个分片先对本地的文档做一次$group,产出一个"局部结果"(比如每个分片各自算出的用户消费小计),然后把局部结果发给 mongos,mongos 再把这些小计合并成最终结果。

这个机制带来的一个直接推论是:$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取首尾文档依赖前置$sort

4. 数组型数据的统计:$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索引字段之外,其他字段(orderNoamount)都被复制了一份。理解"其他字段被复制"这点很关键,因为这意味着$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 两个数组同时展开的顺序陷阱

有些文档里有两个数组字段,比如itemsdiscounts。如果对两个都$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同样不能省——如果订单的userIdusers里找不到(用户被删了、数据不一致),不加这个参数,这条订单会被$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"天然有索引,但如果关联的是userIdorderNo这类业务字段,就得手动建:

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返回的是字符串,适合直接当分组键展示,前端不用再格式化。缺点是字符串不能做时间运算。

$dateTrunc(5.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 } } ] }} ])

结果是一个文档,里面有byCategorybyCitytopProductstrend四个数组。一次请求,一次前置过滤,四个分支各自独立跑。

$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 didn't allow external sort。这个报错信息有个坑:它往往指向$group$sort,但真正超限的可能是在它们之前把文档放大很多的阶段,比如$unwind或者一个$lookup把每条订单都挂上了一堆用户数据。

触发条件的判断可以从三个维度入手:分组基数(distinct 的_id数量)是不是特别大、$push的数组是不是很长、$unwind的放大倍数是不是很高。这三个里任何一个超预期,内存都会爆。

8.2 allowDiskUse 和它的代价

db.orders.aggregate([...], { allowDiskUse: true })

开启之后,超限的部分会写到临时文件。它能救急,但代价是查询速度大概会慢一个数量级,因为磁盘 IO 比内存慢太多了。

我的处理流程是:开发环境一律不加allowDiskUse,先看能不能通过优化管道解决;如果优化到极限还是超,再考虑加。优化的优先级按下面的顺序来:

  1. $match前置,并且确认索引命中。
  2. 尽早$project瘦身,把不用的字段先丢掉。
  3. 减少$unwind的放大倍数,能不用数组表达式就不用$unwind
  4. 降低分组基数,或者把一次大统计拆成多次小统计(比如按天分批)。
  5. 前面都做完还超,才加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" } } } ])

看几个关键数字:

  • totalDocsExaminednReturned的比值。理想情况接近 1,如果远大于 1(比如 100 万扫出 1 万),说明索引没吃上,出现了全集合扫描。
  • 有没有出现COLLSCAN字样,出现就是全表扫描。
  • executionTimeMillisEstimate,看整体耗时。

另一个实用技巧是分段验证:把管道截断到$group之前,先看看中间结果的条数是不是符合预期。如果$unwind之后文档数比原始文档数多了十几倍,那你就知道内存问题的来源了。

注意:explain返回的结构在不同版本之间有差异,5.0 之后聚合的 explain 结构有调整。看的时候重点找stages数组里每个阶段的nReturneddocsExamined,这两个是通用的。

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 上限byCitytrend我加了$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" } } ])

$merge(4.2+)的whenMatchedwhenNotMatched两个参数控制写入行为,做成幂等的重跑逻辑很方便——同一天重跑会覆盖而不是重复插入。

我个人的体会是,MongoDB 的聚合管道看着阶段多、操作符杂,但真正高频用到的就那么十几个。把$match前置、$group的累加器语义、$unwind的放大效应、时区口径这四件事吃透,剩下的大部分都是查文档的事。真正难的不是语法,是在写第一行管道之前,先想清楚数据在每一站会变成什么样、数量是放大还是缩小、分组键的基数有多大。我踩过的坑里,十有八九都是没想清楚这几件事,而不是不会写某个操作符。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 14:38:46

MCP 服务里的 DeepSeek 请求走 TaoToken,uv 天气查询流程不用改

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 14:38:17

Notepad-- 文件对比:文本逐行高亮差异,10M 内二进制也能比

Notepad-- 文件对比&#xff1a;文本逐行高亮差异&#xff0c;10M 内二进制也能比 【免费下载链接】notepad-- 一个支持windows/linux/mac的文本编辑器&#xff0c;目标是做中国人自己的编辑器&#xff0c;来自中国。 项目地址: https://gitcode.com/GitHub_Trending/no/note…

作者头像 李华
网站建设 2026/9/17 14:37:26

自然连接⋈的真相:不是自动匹配,而是隐式多条件陷阱

1. 项目概述&#xff1a;为什么“自然连接”是数据库里最常被误解、也最该被吃透的操作&#xff1f;“土话笔记&#xff1a;数据库——自然连接(符号⋈)”这个标题&#xff0c;乍看像学生课后随手记的潦草笔记&#xff0c;但恰恰是这种带点烟火气的命名&#xff0c;戳中了数据库…

作者头像 李华
网站建设 2026/9/17 14:32:36

DataGrip连接达梦数据库完整指南:从JDBC驱动到排障实战

最近接手一个从Oracle往达梦数据库迁移的项目&#xff0c;数据切过去了&#xff0c;开发工具却卡了我整整两天。打开DataGrip一看&#xff0c;驱动列表里根本没达梦这一项。网上翻了很多资料&#xff0c;一半是讲达梦自带的DM管理工具怎么用&#xff0c;另一半是零散提问没人完…

作者头像 李华