在MongoDB的聚合框架里,$group绝对是我用得最多的阶段之一。很多人刚接触聚合管道,看到{$group: {_id: "$status", count: {$sum: 1}}}就以为它只是SQL里GROUP BY换个写法,等真正拿它做日报、周报、多维统计的时候,才发现分组键怎么设计、累加器怎么选、内存超限怎么处理,每一个环节都能卡住你。这篇文章我会从最基础的语法讲起,结合订单、用户、文章等常见场景,把$group的原理、写法和生产环境里踩过的坑一次讲透,适合正在学MongoDB聚合,或者想在项目里快速写出分组统计管道的同学。
1. $group到底是什么?先想清楚你要按什么分组
1.1 从一个最常见的例子说起:按字段分组统计
先看一个很典型的场景。假设有一个订单集合orders,里面每条文档表示一笔订单,有状态字段status和金额字段amount:
javascript复制db.orders.insertMany([
{ _id: 1, status: "completed", amount: 120, region: "华东", product: "手机" },
{ _id: 2, status: "pending", amount: 80, region: "华北", product: "耳机" },
{ _id: 3, status: "completed", amount: 200, region: "华东", product: "手机" },
{ _id: 4, status: "cancelled", amount: 0, region: "华南", product: "手表" },
{ _id: 5, status: "completed", amount: 150, region: "华东", product: "耳机" }
])
如果想统计不同状态下的订单数量,聚合管道可以这么写:
javascript复制db.orders.aggregate([
{ $group: { _id: "$status", count: { $sum: 1 } } }
])
返回结果大致是:
javascript复制[
{ _id: "completed", count: 3 },
{ _id: "pending", count: 1 },
{ _id: "cancelled", count: 1 }
]
这里_id就是分组键,值来自文档的status字段。$sum: 1表示每进入一条文档就累加1,相当于SQL里的COUNT(*)。如果还想同时统计每个状态的订单总金额,可以再加一个累加器:
javascript复制db.orders.aggregate([
{ $group: {
_id: "$status",
count: { $sum: 1 },
totalAmount: { $sum: "$amount" }
}}
])
输出里会出现totalAmount字段,就是分组内所有amount相加的结果。
理解$group的关键在于:它会遍历上游传入的文档流,把满足同一个_id表达式结果的文档归为一组,每个组最终只输出一个文档。所以_id是必填项,累加器则是用来对组内文档做计算。很多人一开始容易漏掉_id,或者把累加器直接写成普通字段表达式,这样在MongoDB里是会直接报错的。
1.2 $group跟传统SQL的GROUP BY有什么对应关系
如果你以前写过SQL,会发现$group的很多概念都能对应上。我整理了一个对照表,记忆起来会快很多:
| 操作 | SQL写法 | MongoDB $group写法 |
|---|---|---|
| 分组字段 | GROUP BY status |
_id: "$status" |
| 计数 | COUNT(*) |
count: { $sum: 1 } |
| 求和 | SUM(amount) |
total: { $sum: "$amount" } |
| 平均值 | AVG(amount) |
avg: { $avg: "$amount" } |
| 最小值 | MIN(amount) |
min: { $min: "$amount" } |
| 最大值 | MAX(amount) |
max: { $max: "$amount" } |
| 分组内去重后计数 | COUNT(DISTINCT userId) |
uidCount: { $sum: 1 }配合$addToSet使用 |
| 分组内收集列表 | 通常需要子查询/窗口函数 | list: { $push: "$product" } |
需要注意的是,SQL中SELECT后面出现的非聚合字段,必须同时出现在GROUP BY里,否则主流数据库会直接报错。MongoDB的$group则不同:你想要的非聚合字段,要么作为分组键放进_id,要么用累加器去聚合。如果你想保留某个字段的原始值但不按它分组,那么只能通过$first、$last或者$push等累加器把它收集起来,直接写_id: "$otherField"之外的方式是无法做到的。
1.3 官方文档里的语法结构拆解
$group的语法结构在官方文档里写得很简洁:
javascript复制{
$group: {
_id: <分组表达式>,
<输出字段1>: { <累加器> : <表达式> },
<输出字段2>: { <累加器> : <表达式> }
}
}
首先,_id的值可以是字段路径,比如"$status";也可以是一个常量,比如null,这样所有文档会进入同一个组;还可以是一个文档或数组表达式,用来实现多字段组合分组。
其次,输出字段的键名由你自己定义,不能是_id开头的字段名,因为_id已经被占用了。累加器则是MongoDB提供的一组算子,比如$sum、$avg、$min、$max、$push、$addToSet、$first、$last等。每个字段只能指定一个累加器,但一个$group阶段里可以有多个输出字段,每个字段可以选用不同的累加器。
最后,$group在聚合管道中的位置不是只能出现一次。你可以先按天分组,再把天的结果按月份分组,形成多层汇总。不过多级分组意味着需要更多的计算资源,中间结果也会被重新扫描,所以要评估清楚是否真的有必要。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节:_id、累加器表达式和内存限制
2.1 _id怎么写:单字段、多字段、复合分组键
最简单的单字段分组我们已经见过了,_id: "$status"。如果按多个字段组合分组,很多人会下意识地写成:
javascript复制db.orders.aggregate([
{ $group: {
_id: { region: "$region", status: "$status" },
totalAmount: { $sum: "$amount" }
}}
])
这种写法的效果相当于SQL里的GROUP BY region, status,也就是说订单会先按region分组,再按status细分。输出文档里的_id不再是一个字符串,而是一个子文档:
javascript复制[
{ _id: { region: "华东", status: "completed" }, totalAmount: 470 },
{ _id: { region: "华北", status: "pending" }, totalAmount: 80 },
{ _id: { region: "华南", status: "cancelled" }, totalAmount: 0 }
]
有时候你希望_id直接是一个拼接后的字符串,比如"华东_completed",可以用$concat构造。但是要注意,$concat只接受字符串,如果字段可能缺失或不是字符串,最好先做类型转换。
还有一个很多人会忽略的点:如果分组键本身是一个嵌套子文档,不同文档里子字段的存储顺序不同,可能会导致明明看起来相同的数据被分到不同的组。比如一个文档里嵌套字段是{a: 1, b: 1},另一个文档里是{b: 1, a: 1},在BSON比较规则下它们不一定相等。尽量避免直接拿一个完整的嵌套文档当分组键,改成用"$字段.子字段"的形式去取值会更稳妥。
2.2 常用累加器算子:$sum、$avg、$min、$max、$push、$addToSet等
我把常用的累加器分成三类来理解:
第一类是数值聚合类,$sum、$avg、$min、$max。它们用于对数值字段做求和、平均、最小值、最大值计算。$min和$max也可以用于字符串和日期类型,在BSON类型的自然比较顺序下取最早/最晚的日期,或者按字典序取字符串的首尾值。
第二类是数组收集类,$push、$addToSet。$push会把分组内每个文档的某个字段值追加到数组中,可以用来生成“一个用户的所有订单id”这类结果。$addToSet和$push类似,但会自动去重,适合收集一组不重复的标签或用户id。这两个累加器看起来方便,实际上有一个很大的隐患:如果分组的文档数量特别多,最终生成的数组可能巨大,直接撑爆单个文档16MB的限制。所以在生产环境里,我一般只在分组结果可控的时候才用它们。
第三类是首尾取值类,$first和$last。它们用于获取分组内某个字段的第一个或最后一个值。这里有个关键前提:$group阶段本身不会对文档排序,如果希望$first取到“最早订单”的信息,必须在$group之前先执行$sort,否则结果是不确定的。比如“每个用户最近一次下单时间”这个需求,必须先按时间倒序排序,再对用户分组,然后$first才能代表最近一次。
还有一个冷门的用法,就是用$sum搭配$cond做条件计数。例如统计每个区域里金额大于100的订单数:
javascript复制db.orders.aggregate([
{ $group: {
_id: "$region",
bigOrderCount: { $sum: { $cond: [{ $gt: ["$amount", 100] }, 1, 0] } }
}}
])
这种写法减少了管道里的$match阶段数量,在需要同时统计多组条件的时候特别灵活。
2.3 内存限制和allowDiskUse的坑
$group执行时需要在内存里维护分组状态。老版本的MongoDB对聚合阶段默认有100MB内存限制,如果分组键基数很大,或者$push收集了大量数据,内存占用可能直接超限,报错信息一般会提示Exceeded memory limit。
解决方式很简单,在聚合命令最后加一个选项:
javascript复制db.orders.aggregate([
{ $group: { _id: "$region", totalAmount: { $sum: "$amount" } } }
], { allowDiskUse: true })
allowDiskUse: true允许$group把临时数据写到磁盘上,从而绕过内存上限。但这不是银弹,因为写磁盘会带来明显的IO开销,查询速度会慢不少。6.0版本以后某些聚合阶段默认允许使用磁盘,但不同版本行为不太一样,我的建议是不要依赖默认行为,主动评估数据量,在必要的时候显式开启。
优化方向永远是:先用$match过滤掉不必要的数据,再尽量选择较小的分组键,最后再考虑是否开磁盘。顺序反过来,性能多半会很难看。
2.4 分组键为null或缺失的场景注意
$group有一个很容易踩的隐性问题:如果文档里分组字段不存在,或者值为null,这些文档会被归到同一个组,且_id为null。比如订单集合里有一部分老数据没有region字段,你用_id: "$region"分组时,就会多出来一个_id: null的分组。这不一定是你想要的。
如果希望忽略这些脏数据,可以在$group之前加$match:
javascript复制db.orders.aggregate([
{ $match: { region: { $ne: null } } },
{ $group: { _id: "$region", count: { $sum: 1 } } }
])
如果希望把它们归类为“未知区域”,可以用$ifNull处理一下:
javascript复制db.orders.aggregate([
{ $group: { _id: { $ifNull: ["$region", "未知"] }, count: { $sum: 1 } } }
])
这是一个很实用的小技巧,尤其是在对接外部系统同步过来的数据时,字段缺失几乎是常态。
3. 实操举例:从简单统计到多维报表
3.1 按日期分组统计订单金额
统计每天的订单总额,可能是很多人遇到$group的第一个实际需求。假设订单里有createdAt字段,存储的是ISODate类型:
javascript复制db.orders.aggregate([
{ $match: {
createdAt: { $gte: ISODate("2025-01-01T00:00:00Z"), $lt: ISODate("2025-02-01T00:00:00Z") }
}},
{ $group: {
_id: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt", timezone: "Asia/Shanghai" } },
totalAmount: { $sum: "$amount" },
orderCount: { $sum: 1 }
}},
{ $sort: { _id: 1 } }
])
这里有两个细节特别值得注意。第一,$dateToString默认按UTC时区处理,如果业务系统使用的是北京时间,每日的切分点会偏移8小时,导致统计数据“看起来不对”。我实际排查过很多次这种问题,最后发现都是时区差异造成的。解决办法就是显式指定timezone参数,比如"Asia/Shanghai"。第二,$group之后的结果顺序是无序的,如果希望按日期排序,需要单独加$sort。
日期分组还有一种写法是直接对createdAt做分组,把整个日期对象作为_id。不过由于createdAt通常包含精确时间,这样分组出来的结果是每秒一组,基本没有统计意义。所以要先做格式化或按年月日截断。
3.2 多个字段组合分组:按地区和产品类别统计
假设运营需要看每个地区、每个产品类别的销售汇总,这个时候分组键不再是单一字段,而是两个字段的组合:
javascript复制db.orders.aggregate([
{ $group: {
_id: { region: "$region", category: "$category" },
totalAmount: { $sum: "$amount" },
avgAmount: { $avg: "$amount" },
orderCount: { $sum: 1 }
}},
{ $sort: { "_id.region": 1, "_id.category": 1 } }
])
输出结果中_id是一个嵌套文档,包含region和category。你可以在下游阶段继续对这个嵌套字段做操作,比如在$project里改写成更方便前端使用的结构:
javascript复制{ $project: {
region: "$_id.region",
category: "$_id.category",
totalAmount: 1,
avgAmount: 1,
orderCount: 1,
_id: 0
}}
多字段分组最常见的问题是:后续如果需要按其中一个字段继续汇总,就得再写一个$group阶段。比如先算出“地区和品类”维度,再汇总成“只有地区”维度。这种多级汇总会额外扫描一次中间结果,数据量大的时候成本不低,所以我建议提前确认产品到底需要哪个粒度的统计,能一次分组解决就不做多级。
3.3 用$group配合$project、$sort完成Top N分析
“每个区域销量前三的商品”是另一个经典需求。如果只是用$group分组,然后$max取最大值,只能拿到单条销量最高的商品,拿不到前三名。要拿到Top 3,一般用先排序、再分组、最后切片的方式:
javascript复制db.orders.aggregate([
{ $sort: { region: 1, quantity: -1 } },
{ $group: {
_id: "$region",
products: { $push: { product: "$product", quantity: "$quantity" } }
}},
{ $project: {
region: "$_id",
topProducts: { $slice: ["$products", 3] },
_id: 0
}}
])
这样每个区域分组里,products数组中的顺序就是按quantity从高到低排列的,因为$sort在$group之前已经完成了排序。$project里的$slice再取出前3条,实现Top 3的效果。
需要注意的是,$sort会对整个集合排序,如果没有索引支撑,性能压力会比较大。如果只关心Top N,还有另外一种思路:先按区域分组,每个组内用$max找到最大值,再回到原始集合里筛选,但这样往往要多写几条查询,反而更麻烦。实际使用中,我会先评估分组数量和数据总量,再决定是否用这种$push + $slice方案。如果单组文档数量特别大,这个方案会把所有明细都塞到数组里,内存很容易爆。
3.4 $group + $unwind处理数组字段
当文档的某个字段是数组,而且你需要针对数组里的每个元素做统计时,不能直接$group,得先用$unwind把数组拆开。比如文章集合里每篇文章有tags数组,想统计每个标签下的文章数:
javascript复制db.articles.aggregate([
{ $unwind: "$tags" },
{ $group: { _id: "$tags", articleCount: { $sum: 1 } } },
{ $sort: { articleCount: -1 } }
])
$unwind会把一条带多个标签的文章文档复制成多条,每条文档保留同一个标签。复制后的文档会被$group重新聚合,所以最终每个标签只输出一条汇总结果。
这里有个小坑:如果tags字段为空数组或者缺失,默认情况下$unwind会直接丢弃这些文档,导致统计结果比实际文章总数少。如果想保留空数组文档,需要加选项:
javascript复制{ $unwind: { path: "$tags", preserveNullAndEmptyArrays: true } }
加了之后,没有标签的文章也会进入管道,只是tags字段为null,最终会被归入一个_id: null的分组里。
3.5 用$group实现去重和集合操作
有些场景下需要用$group做去重。最简单的全局去重是_id: null,把所有文档放进同一个组,然后用$addToSet收集某个字段的唯一值:
javascript复制db.users.aggregate([
{ $group: { _id: null, allEmails: { $addToSet: "$email" } } }
])
这样可以得到一个数组,里面是所有不重复的邮箱地址。不过要小心,如果用户量很大,这会让单个文档极其庞大,很可能超过16MB限制。我一般只在数据量小,或者做一次性数据质量检查时用这种写法。
如果要检查某个字段是否有重复值,可以反过来按字段本身分组,然后用$sum: 1计数,再用$match筛出数量大于1的组:
javascript复制db.users.aggregate([
{ $group: { _id: "$email", count: { $sum: 1 } } },
{ $match: { count: { $gt: 1 } } },
{ $sort: { count: -1 } }
])
这个查询在找重复邮箱、重复手机号时非常实用,比在应用层循环判断高效得多。
4. 常见问题与性能优化
4.1 分组结果超过16MB文档限制怎么办
$group每个分组最终输出一个文档,这个文档本身也有16MB的BSON大小上限。如果某个分组太大,比如你用$push把一个热门商品的所有订单明细都塞到数组里,就会遇到BSONObj too large之类的错误。
解决思路有几个。第一,只聚合必要字段,不要把明细文档整体$push进去,而是$push一个只包含关键字段的子文档,或者提前用$project裁剪字段。第二,如果明细确实很重要,考虑换一种展示方式,比如把结果写入临时集合,再分页查询。第三,调整分组粒度,避免单组数据过大。比如把“按商品分组”改成“按商品+日期分组”,每个组自然会变瘦。
不要把16MB限制当成只是一个阈值,它实际上在提醒你:$group的输出应该是统计结果,而不是明细大礼包。一旦你发现自己想把几百条甚至几万条原始文档塞进一个数组,就应该重新审视需求了。
4.2 分组导致内存使用过高
$group的基数越大,内存中需要维护的分组状态就越多。比如对userId分组,全站可能有上百万个用户,每个分组都要在内存里保留一条状态记录,内存压力会非常大。
我常用的排查方式是先用explain看每个阶段的docsExamined和totalDocsProduced,确认$group到底处理了多少输入文档。如果输入数据量确实很大,优先从源头削减:在$group之前加更准确的$match,过滤掉不需要参与统计的数据;或者用$project提前把不需要的字段去掉,让管道移动的数据更轻。
另外,分组键的选择会影响内存。用字符串作为_id比用整个子文档作为_id更省内存,因为子文档作为键时,分组状态需要保存更复杂的数据结构。如果多个字段组合分组,可以用$concat拼成一个字符串,虽然查询结果的可读性差一点,但内存表现通常会更好。
4.3 $group和$sort的顺序优化
$group和$sort在管道里的先后顺序,会直接决定性能表现。如果只需要对分组后的结果排序,比如统计每个城市的订单量后按订单量降序排列,那么顺序就是先$group后$sort,这很自然:
javascript复制db.orders.aggregate([
{ $group: { _id: "$city", orderCount: { $sum: 1 } } },
{ $sort: { orderCount: -1 } }
])
但如果需要在每个分组内取“最早一条订单”或者“最新一条订单”,就必须先$sort再$group。因为$group内部不保证文档顺序,$first、$last没有排序支撑是不可靠的。这种先$sort的方案,会导致整个集合参与排序,成本很高。优化的办法是在排序字段上建索引,让数据库尽可能不把全量数据读入内存排序。
还有一种情况是$sort和$group的分组键一致,比如先按区域排序,按区域分组。这时MongoDB的优化器有可能做一些流水线优化,但不同版本的行为不完全一样,不要盲目依赖。最稳妥的做法还是用explain验证执行计划。
4.4 如何在分组前过滤数据:$match前置
$group之前加$match几乎是一条铁律。如果先分组所有历史数据,再在结果上过滤,数据库处理的数据量会大很多,而且聚合管道里$match如果放在$group后面,往往就无法利用索引了。正确做法是把筛选条件放在最前面:
javascript复制db.orders.aggregate([
{ $match: {
createdAt: { $gte: ISODate("2025-01-01T00:00:00Z") },
status: "completed"
}},
{ $group: { _id: "$region", totalAmount: { $sum: "$amount" } } }
])
对应索引可以建在{ status: 1, createdAt: 1 }上。需要注意的是,$match里如果包含了对分组字段的等值条件,甚至可以在分组之前就大大缩小分组键的基数,这样$group的内存压力也会小很多。
如果管道里有多个$match,尽量把它们尽量往前合并,减少中间文档流的大小。经验法则是:所有过滤条件下推,所有计算后置。这样管道前面的阶段输出越少,后面所有阶段都会受益。
4.5 分片集合的$group优化
在MongoDB分片集群里,$group的执行方式和单机不一样。聚合管道会被拆分成一部分在每个分片上执行,另一部分在mongos或合并节点上执行。如果分组键和分片键一致,那么每个分片上的数据天然就是“同一组的数据已经在同一个分片”,聚合效率最高。如果分组键和分片键不一致,管道可能需要把大量分组中间结果传到合并节点,网络IO和内存开销都会变大。
所以在设计分片键时,如果能预见到高频的分组统计维度,尽量把分片键设计成这个维度。不过分片键一旦确定很难更改,这需要在一开始就做好规划。对于已经存在的分片集合,如果遇到跨分片聚合性能差的问题,可以尝试用$facet或者预先物化汇总表的方式来缓解,而不是每条统计都实时跑全量聚合。
5. 一些我踩过坑之后总结的经验
5.1 不要在$group前滥用$project
很多初学者习惯在$group之前先$project,把字段挑出来再分组,觉得这样能减少后续处理量。但实际效果有时候适得其反。$project本身也是一个管道阶段,需要遍历上游数据并构造新文档,如果只是为了去掉两个大字段,节省的内存可能远小于增加的计算开销。
更合理的裁剪方式是:如果原始文档里有特别大的无用字段,比如一个日志字符串或者完整的内容体,而这些字段完全不参与聚合,可以在$group之前用$project排除掉。但如果只是去掉几个小字段,或者字段本来就不大,直接$group就行。MongoDB的聚合引擎在处理$group时,会按表达式读取所需字段,并不会说因为文档里其他字段很多而导致分组结果变大。
5.2 用$group做数据清洗时注意类型不一致
现实项目里的数据远没有测试数据干净。举个很常见的例子,订单金额字段amount在早期系统里存的是字符串,后面新系统改成了数字类型。这时候直接用$sum: "$amount",MongoDB对字符串类型和数值类型混在一起求和会出现意外结果,甚至直接报类型转换错误。
我的做法是,在$group之前用$convert统一类型:
javascript复制db.orders.aggregate([
{ $addFields: {
amountNum: { $convert: { input: "$amount", to: "double", onError: 0, onNull: 0 } }
}},
{ $group: { _id: "$region", total: { $sum: "$amountNum" } } }
])
onError: 0表示转换失败时当作0处理,这样不会因为个别脏数据拖垮整个聚合。做数据汇总的管道里,类型转换这一步宁可多写,也不能省。因为生产环境里的脏数据,远比你想的要多。
5.3 在Java/Node.js驱动中写$group的注意点
如果你不是直接在MongoDB Shell里敲命令,而是在Java或Node.js应用里拼聚合管道,也有一些细节要注意。Java驱动里可以用Aggregates.group方法,但复杂的_id表达式和累加器构造起来很啰嗦。更通用的做法是用Document对象构建管道:
java复制List<Document> pipeline = Arrays.asList(
new Document("$group", new Document("_id", "$status")
.append("count", new Document("$sum", 1)))
);
ordersCollection.aggregate(pipeline).iterator();
Node.js这边就直观很多:
javascript复制const result = await db.collection('orders').aggregate([
{ $group: { _id: '$status', count: { $sum: 1 } } }
]).toArray();
无论哪种驱动,都要注意数字类型处理。JavaScript的Number存在精度问题,当$sum的结果超过2^53时,建议在驱动里显式使用Long类型,或者在后端用字符串/Decimal128处理金额。金额类统计绝对不能简单用浮点数累加,否则精度损失会在报表里被无限放大。
5.4 用explain确认聚合执行计划
写出聚合管道之后,不要急着直接上线。我习惯先用explain看执行计划:
javascript复制db.orders.explain("executionStats").aggregate([
{ $match: { createdAt: { $gte: ISODate("2025-01-01T00:00:00Z") } } },
{ $group: { _id: "$region", totalAmount: { $sum: "$amount" } } }
])
重点关注几个地方:docsExamined是否接近全部扫描,有没有命中索引;totalDocsProduced是否远小于输入,筛选是否提前;各个阶段的内存占用有没有到达警告线。如果docsExamined特别大,说明$match前没有有效索引,或者过滤条件写得不够靠前。
$group本身一般不会出现在索引扫描优化范围里,但通过explain可以看到每个阶段处理了多少文档。这能帮我快速定位性能瓶颈:到底是$group之前的输入量太大,还是$group本身的输出文档过多。看明白这一步,优化方向基本就清晰了。
最后想多提一句,$group虽然看起来只是聚合管道里一个普通阶段,但它的设计思路其实很考验你对数据分布的理解。分组键选得好,一条管道能同时完成多维统计和Top N分析;选得不好,内存和磁盘都会跟着遭罪。如果你在实际项目中遇到过其他诡异的$group问题,欢迎多交流,这个阶段值得花时间仔细打磨。
