说实话,MongoDB 的聚合框架里,$group 是我用得最多、也最容易被新手问懵的一个阶段。很多人学到这里,文档翻了好几遍,一上手写聚合就报错,或者查出来的结果和预期完全对不上。归根到底,$group 不是一个"背语法"就能搞定的操作,它的执行逻辑、字段引用规则、累加器语义、内存限制,任何一环没吃透,写出来的管道就是一颗定时炸弹。这篇我用一个完整的订单集合示例,把 $group 从原理到实操拆开讲一遍,特别是那些官方文档里不会明说、但生产环境一定会遇到的坑。适用人群:刚接触聚合管道的初级工程师,以及写了不少 $group 但偶尔被奇怪报错卡住的中级开发。
1. 聚合管道:$group到底在解决什么问题
1.1 先看懂聚合管道的整体编排
MongoDB 的聚合框架 aggregate() 本质上是一条流水线。文档从集合里被读出来,依次经过管道里的每个阶段,每个阶段把上一步的结果处理完交给下一步。$match 负责筛选,$sort 负责排序,$project 负责改字段形状,而 $group 负责把若干文档"合并"成一个文档,同时用累加器把组内数据汇总成你想要的值。
这里最关键的一点是:$group 一旦执行,数据流的形态就从"逐条明细文档"变成了"按组聚合的结果"。这个转变直接影响后面阶段的写法。比如在 $group 之后再想拿原始文档里的某个普通字段,是拿不到的,你只能访问 _id 和你在 group 阶段里显式定义的累加字段。
用一个生活类比理解:$group 就像 Excel 里的透视表。原始明细是一行行订单,透视表按照某个列(比如客户、分类)把行合并,然后对合并后的行执行求和、平均、计数。透视表里你只能看到拖进去的维度字段和度量字段,其他原始列的信息在合并后不会自动出现,除非你显式把它们放进结果里。$group 的行为一模一样。
如果熟悉 SQL,$group 对应的是 GROUP BY 子句,累加器对应 COUNT、SUM、AVG、MIN、MAX 这些聚合函数。对应关系大致如下:
| SQL 聚合写法 | MongoDB $group 写法 |
|---|---|
| GROUP BY category | _id: "$category" |
| COUNT(*) | { count: { $sum: 1 } } |
| SUM(price) | { total: { $sum: "$price" } } |
| AVG(price) | { avg: { $avg: "$price" } } |
| MIN(price) / MAX(price) | { min: { $min: "$price" } } / { max: { $max: "$price" } } |
| GROUP BY category, customer | _id: { category: "$category", customer: "$customer" } |
对照着看会清晰很多,但这不是说 MongoDB 完全等价于 SQL。SQL 的 GROUP BY 输出列有严格规则,MongoDB 的 $group 更加灵活,你可以用 $push 把明细"装回"数组里,这在 SQL 里要借助 STRING_AGG 或 JSON_ARRAYAGG 这类函数才能实现。
1.2 $group的语法:本质上就是一个分组合并器
$group 的语法结构如下:
javascript复制{
$group: {
_id: <分组表达式>,
<输出字段1>: { <累加器>: <表达式> },
<输出字段2>: { <累加器>: <表达式> }
}
}
新手最容易搞混的就是 _id。注意,这里的 _id 不是分组后文档的"主键",而是"按什么分组"。它可以是一个字段引用(写成 "$字段名"),可以是一个由多个字段拼成的文档,也可以是一个表达式。除了 _id 以外,其他输出字段必须用累加器操作符包起来,比如:
javascript复制{ total: { $sum: "$price" } }
为什么必须包一层累加器?因为 $group 的语义是"合并",每个输出字段必须定义明确的合并策略,否则文档无法合并。你直接写 { total: "$price" },等于是告诉 Mongo "把 total 设置成 price"——但组里有好几条文档,到底取哪一条的 price?Mongo 没法确定,所以直接报错。
$group 的底层执行模型是这样的:聚合框架遍历输入文档,对每个文档根据 _id 表达式计算结果,结果相同的文档归入同一个桶,然后在这个桶上依次执行累加器。整个阶段在内存里维护一个哈希表,分组键作为 key,累加器的中间状态作为 value。所以分组数量越多,内存消耗越大;默认内存上限是 100MB,超过就得加 allowDiskUse: true,这个细节后面会细说。
输出文档的结构很简单:每个分组产出一个文档,文档里必然有 _id 字段(值是分组键),以及你在阶段里定义的所有累加字段。上面示例的输入是 6 条订单,如果按 customer 分组,输出就是 3 个文档,每个文档代表一个客户。这个"文档数变少"的特征,是所有 $group 查询的共同表现。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析:$group的每个参数都要抠明白
2.1 _id字段:分组依据可以有多复杂
单字段分组是最常见的用法:
javascript复制db.orders.aggregate([
{ $group: { _id: "$category", totalSales: { $sum: "$price" } } }
])
注意 $category 里的 $ 前缀是必须有的,它告诉 Mongo 这是一个字段引用,不是字符串字面量。如果写成 _id: "category",Mongo 会把每个文档都分到同一个组,因为对每个文档来说,这个表达式的值都是字符串 "category",完全一样,结果等于全部聚成了一组。这个低级错误我见过太多次了。
多字段分组有两种写法。第一种是直接在 _id 里写一个对象:
javascript复制db.orders.aggregate([
{
$group: {
_id: { category: "$category", customer: "$customer" },
count: { $sum: 1 }
}
}
])
输出 _id 是一个嵌套对象:{ category: "...", customer: "..." }。第二种是利用 _id 表达式接收数组的写法:
javascript复制{ $group: { _id: ["$category", "$customer"], count: { $sum: 1 } } }
这种写法的 _id 输出是一个数组 ["...", "..."]。两种都能实现"按多个字段分组"的效果,但团队协作时推荐用对象写法,因为字段名一目了然,后续 $project 或者报表映射时更直观。
_id 还可以用表达式做"条件分组"。比如把价格超过 1000 的订单归为高价组,否则归为普通组:
javascript复制db.orders.aggregate([
{
$group: {
_id: {
$cond: { if: { $gte: ["$price", 1000] }, then: "high", else: "normal" }
},
total: { $sum: "$price" }
}
}
])
按时间维度分组是另一个高频场景,也是最容易踩坑的。如果直接用 _id: "$orderDate",由于日期精确到毫秒,几乎每条订单都会落在独立的分组里,达不到"按月统计"的效果。正确做法是先转换格式:
javascript复制db.orders.aggregate([
{
$group: {
_id: {
$dateToString: { format: "%Y-%m", date: "$orderDate" }
},
totalSales: { $sum: { $multiply: ["$price", "$qty"] } }
}
}
])
如果需要对年、月分别统计,也可以用 $year 和 $month 抽取字段后再作为组合分组键。这些都是 _id 表达式灵活性的体现,把它当成"计算分组键的地方"来理解,思路会顺畅很多。
2.2 常用累加器操作符
累加器是 $group 的核心武器,每个累加器的语义不同,用错场景会得到完全错误的结果。我把常用的整理成了一张对照表:
| 累加器 | 作用 | 典型使用场景 | 注意事项 |
|---|---|---|---|
$sum |
求和,支持数值;用 $sum: 1 可计数 |
统计销售额、订单量 | 字符串字段会返回 0,不会报错 |
$avg |
求平均 | 客单价、平均库存 | 缺失字段不参与计算 |
$min / $max |
取最小/最大值 | 首单时间、最高价格 | 比较类型不一致时结果可能异常 |
$push |
把表达式结果放入数组返回 | 收集组内明细列表 | 组内顺序不确定,需要先 $sort |
$addToSet |
类似 $push,但自动去重 |
收集不重复的客户名单 | 去重是基于 BSON 值比较的 |
$first / $last |
取组内第一条/最后一条 | 配合 $sort 取最新状态 |
必须配合 $sort 才有意义 |
$count |
统计文档数量(Mongo 5.0+) | 替代 $sum: 1 |
旧版本不支持 |
$sum: 1 这个写法值得单独说。很多教程里计数都用 { $sum: 1 },原理很简单:累加器对每个输入文档执行一次,把常量 1 加进去,所以最后的值就是组内文档总数。Mongo 5.0 之后新增了 { $count: {} } 累加器,可以直接统计文档数,写法更直观;不过为了兼容旧版本集群,不少项目仍然坚持 $sum: 1。我个人在维护老项目时会先确认 MongoDB 版本再决定用哪种,避免上线后才发现某个从节点版本不够。
$push 和 $addToSet 的区别,看名字就懂:$push 会把元素逐个加进数组,重复值也会保留;$addToSet 只在元素不存在时才加入,相当于自动去重。比如某个客户买了两台手机,$push 出来的数组里会出现两个"手机",$addToSet 只会保留一个。如果数组只是用来展示不重复的标签,用 $addToSet 更省内存;如果确实需要保留每条明细,用 $push。
$first 和 $last 单独使用没有意义,因为 $group 处理文档的顺序是不确定的。必须先有 $sort 阶段明确排序方向,再取 first/last。比如我想知道每个客户最近一次下单的商品,就必须先 $sort 按 orderDate 降序,再 $group 取 $first。这个顺序要求是聚合管道里最容易忽略的隐性约束。
2.3 千万别忽略的字段命名与类型细节
$group 输出字段的名字,不能以 $ 开头,也不能包含点号等特殊字符,这个限制和 $project 一样。实际项目里踩坑最多的是把输出字段写成了 $totalSales,Mongo 会直接报错。我平时写代码时养成了一个习惯:所有聚合输出字段名尽量用驼峰或下划线命名,避开特殊字符,从根上减少这类低级错误。
另一个隐蔽的坑是数值类型。price 如果是字符串 "2999",$sum 算出来的结果不是数值加法,而是返回 0,因为 $sum 遇到字符串类型时直接忽略,并不会报错。这种情况项目里并不少见,尤其是从老系统导入数据时,价格字段是字符串格式。处理办法是先在 $project 阶段用 $toDouble 或 $convert 把字段转成数值,再做 $group:
javascript复制db.orders.aggregate([
{
$project: {
category: 1,
priceNum: { $toDouble: { $ifNull: ["$price", 0] } },
qty: 1
}
},
{
$group: {
_id: "$category",
totalSales: { $sum: { $multiply: ["$priceNum", "$qty"] } }
}
}
])
$convert 比 $toDouble 更灵活,支持指定 onError 和 onNull 的兜底值。类型问题在聚合管道里非常隐蔽,因为 Mongo 是弱类型数据库,同一个字段在不同文档里可能是字符串、数值甚至数组。写 $group 之前最好先抽样确认字段类型分布,尤其是从外部系统同步过来的数据。
还有日期类型的问题。按日期分组时,直接用 _id: "$orderDate" 会因为毫秒精度导致分组过细,正确做法是用 $dateToString 格式化,或者用 $year / $month 抽取字段。另外时区也是容易忽略的维度:服务器时区与业务时区不一致时,按天分组的边界会偏移。如果业务要求按北京时间统计,必须记得在 $dateToString 里指定 timezone 参数,否则统计口径会出错。
3. 实操过程:从简单到复杂的5个场景复现
3.1 准备一份订单集合作为实验数据
我在本地用 mongosh 建了一个 orders 集合,插入了 6 条模拟订单。字段包括客户、分类、商品名称、单价、数量和下单时间,价格都是数值类型,方便后续演示聚合效果。
javascript复制db.orders.insertMany([
{ _id: 1, customer: "张伟", category: "电子", product: "手机", price: 2999, qty: 2, orderDate: ISODate("2024-01-05T10:00:00Z") },
{ _id: 2, customer: "李娜", category: "电子", product: "耳机", price: 399, qty: 3, orderDate: ISODate("2024-01-12T14:30:00Z") },
{ _id: 3, customer: "张伟", category: "家居", product: "台灯", price: 199, qty: 1, orderDate: ISODate("2024-02-03T09:15:00Z") },
{ _id: 4, customer: "王强", category: "电子", product: "笔记本", price: 6999, qty: 1, orderDate: ISODate("2024-02-18T20:45:00Z") },
{ _id: 5, customer: "李娜", category: "家居", product: "沙发", price: 4599, qty: 1, orderDate: ISODate("2024-03-01T11:00:00Z") },
{ _id: 6, customer: "王强", category: "图书", product: "MongoDB实战", price: 99, qty: 5, orderDate: ISODate("2024-03-15T16:20:00Z") }
])
实际调试时我建议先在 mongosh 里跑一遍 db.orders.find() 确认数据写入成功,再开始聚合。后面所有示例都基于这套数据,你可以直接粘贴复现。
3.2 场景一:按分类统计销售额
先看最基础的用法:统计每个产品分类的总销售额和订单数。销售额应该是单价乘以数量,所以累加值要用 $multiply 算出来:
javascript复制db.orders.aggregate([
{
$group: {
_id: "$category",
totalSales: { $sum: { $multiply: ["$price", "$qty"] } },
orderCount: { $sum: 1 }
}
}
])
结果如下:
javascript复制[
{ _id: "图书", totalSales: 495, orderCount: 1 },
{ _id: "家居", totalSales: 4798, orderCount: 2 },
{ _id: "电子", totalSales: 11194, orderCount: 3 }
]
这里有两个值得注意的点。第一,$multiply 的表达式里,price 和 qty 都要加 $ 前缀,因为它们是字段引用,不是常量。第二,输出里没有 customer 和 product 字段,因为 $group 只保留 _id 和自定义累加字段。很多新手第一次看到结果时觉得"数据丢了",实际上是聚合管道的正常行为。
3.3 场景二:按多个字段分组统计
如果想知道"每个客户在每个分类下的总花费",就需要多字段分组。这是群友问得最多的问题之一——"group by 多个字段怎么写",答案就在 _id 里放一个对象:
javascript复制db.orders.aggregate([
{
$group: {
_id: { customer: "$customer", category: "$category" },
totalSpent: { $sum: { $multiply: ["$price", "$qty"] } }
}
}
])
输出 _id 会是一个嵌套对象:
javascript复制[
{ _id: { customer: "张伟", category: "电子" }, totalSpent: 5998 },
{ _id: { customer: "张伟", category: "家居" }, totalSpent: 199 },
{ _id: { customer: "李娜", category: "电子" }, totalSpent: 1197 },
{ _id: { customer: "李娜", category: "家居" }, totalSpent: 4599 },
{ _id: { customer: "王强", category: "电子" }, totalSpent: 6999 },
{ _id: { customer: "王强", category: "图书" }, totalSpent: 495 }
]
报表展示时,通常后面还会接一个 $project 把嵌套的 _id 拆出来,重命名成 customer 和 category,这样后端 Java 或前端拿到数据后不用再解析嵌套结构。注意,多字段分组的 _id 对象里字段顺序不同,生成的 BSON 结构也不同,虽然统计结果不受影响,但团队里最好固定一种写法,避免代码 review 时无谓的争论。
3.4 场景三:取每组的最新一条记录
这是一个面试和项目中都经常出现的需求:每个客户最近买过什么商品。关键点是先按时间排序再分组,然后利用 $first 取第一条:
javascript复制db.orders.aggregate([
{ $sort: { orderDate: -1 } },
{
$group: {
_id: "$customer",
latestProduct: { $first: "$product" },
latestDate: { $first: "$orderDate" },
latestAmount: { $first: { $multiply: ["$price", "$qty"] } }
}
}
])
输出大约是:
javascript复制[
{ _id: "王强", latestProduct: "MongoDB实战", latestDate: ISODate("2024-03-15T16:20:00Z"), latestAmount: 495 },
{ _id: "李娜", latestProduct: "沙发", latestDate: ISODate("2024-03-01T11:00:00Z"), latestAmount: 4599 },
{ _id: "张伟", latestProduct: "台灯", latestDate: ISODate("2024-02-03T09:15:00Z"), latestAmount: 199 }
]
$sort 必须放在 $group 之前,否则 $first 的结果没有意义。我曾经在项目里为了图省事,把 $sort 写在了 $group 后面,结果 $first 返回的是随机记录,现场排查了很久才发现是顺序问题。这个场景可以自然延伸到"每个分类下最近一单的客户是谁",思路完全相同,把 _id 改成 "$category",再收集 customer 字段即可。
3.5 场景四:用$unwind展开数组后分组
真实世界中,订单文档往往嵌套了数组字段。比如一个订单里有多个商品项,结构类似:
javascript复制{
_id: 7, customer: "陈晨", orderDate: ISODate("2024-03-20T10:00:00Z"),
items: [
{ name: "键盘", price: 299, qty: 1 },
{ name: "鼠标", price: 129, qty: 2 }
]
}
如果直接 $group,只能拿到整个数组作为一个值,无法对数组里的每个元素做聚合。常见做法是先用 $unwind 把数组拆成多行文档,再交给 $group 聚合:
javascript复制db.orders.aggregate([
{ $unwind: "$items" },
{
$group: {
_id: "$_id",
totalAmount: { $sum: { $multiply: ["$items.price", "$items.qty"] } },
itemNames: { $push: "$items.name" }
}
}
])
在这个例子里,$unwind 把每个 items 元素拆成一个独立文档,其他字段保持不变。$group 再按订单 _id 重新聚合,$push 把同一个订单下的所有商品名收集成数组,方便后续查看明细。
$unwind 有一个隐藏参数 preserveNullAndEmptyArrays,默认是 false,意思是如果某个文档的数组字段缺失或为空,这个文档会被直接丢掉。需要保留这类文档时可以显式设置:
javascript复制{ $unwind: { path: "$items", preserveNullAndEmptyArrays: true } }
但要特别注意,设置成 true 之后,这些文档虽然保留下来了,却会因为缺少数组元素而无法在后续 $group 中正确参与聚合,统计口径会变化。实际项目中要结合业务搞清楚,到底是"没有明细的订单不计入统计",还是"没有明细的订单按零元计入统计"。
3.6 场景五:$group配合$match做条件过滤
$match 可以放在 $group 之前做前置过滤,也可以放在之后对聚合结果做二次筛选。前置过滤的意义很大,一方面减少进入 $group 的文档数量,另一方面 $match 是支持索引的,放在最前面可以用上查询优化。
一个典型需求:"只统计电子分类的销售额,并且只保留平均订单金额超过 1000 的分类":
javascript复制db.orders.aggregate([
{ $match: { category: "电子" } },
{
$group: {
_id: "$category",
totalSales: { $sum: { $multiply: ["$price", "$qty"] } },
avgOrderValue: { $avg: { $multiply: ["$price", "$qty"] } }
}
},
{ $match: { avgOrderValue: { $gt: 1000 } } }
])
第二个 $match 里的 avgOrderValue 是 $group 输出的字段名,不是源文档字段。如果你把过滤目标从"平均值"换成"分组订单数大于 1",可以直接用 orderCount 条件。前后两个 $match 语义完全不同,放错位置会导致结果差异,这是我在 review 同事代码时最常挑出来的问题之一。
如果要在 Java 驱动里写同样的管道,需要注意 Document 的嵌套构造方式:
java复制List<Document> pipeline = List.of(
new Document("$match", new Document("category", "电子")),
new Document("$group", new Document("_id", "$category")
.append("totalSales", new Document("$sum", new Document("$multiply", List.of("$price", "$qty"))))),
new Document("$match", new Document("avgOrderValue", new Document("$gt", 1000)))
);
collection.aggregate(pipeline).forEach(doc -> System.out.println(doc.toJson()));
Java 驱动里没有语法糖,所有嵌套都要手动构造 Document,很容易漏掉 $ 前缀。我在帮团队排查代码时,经常发现同事把 "$price" 写成了 "price",编译不报错,运行结果却完全不对。
4. 常见问题与排查技巧实录
4.1 三个高频报错
第一个高频报错是:the group aggregate field 'totalSales' must be defined as an expression inside an object,或者类似的 unknown operator。出现这种报错,百分之九十是因为输出字段的位置写了一个裸字段引用。比如你想统计销售额,错误写法是:
javascript复制{ $group: { _id: "$category", totalSales: "$price" } }
正确的写法必须用累加器包一层:{ totalSales: { $sum: "$price" } }。这不是参数顺序问题,而是语法规则本身,记住"输出字段必须带累加器"就好。
第二个高频报错是:Exceeded memory limit for $group, but didn't allow disk use。这个报错字面意思很好理解:$group 默认只能使用 100MB 内存做分组,当前文档分组太多,内存不够用。解决办法是在 aggregate() 方法中传入 allowDiskUse: true:
javascript复制db.orders.aggregate(
[
{ $group: { _id: "$customer", total: { $sum: "$price" } } }
],
{ allowDiskUse: true }
)
不过 allowDiskUse 只是把中间结果溢写到磁盘,查询会变慢,治标不治本。根本优化方向是:用 $match 先缩小数据范围,分组键尽量精简,避免把整个文档 $push 到数组里。
第三个高频报错是字段名以 $ 开头。如果你在输出字段里写了 { $total: { $sum: "$price" } },Mongo 会直接拒绝,报字段名以 $ 开头不合法。稍微注意就能避免,但这个报错在社区里被问到的频率极高。
4.2 分组结果和预期不一致的排查思路
第一类问题是"为什么所有文档都在同一个组里"。最常见的原因是 _id 写成了字符串而不是字段引用。之前提到过 _id: "category" 会把所有文档归到同一个组,因为分组键固定是字符串,和文档内容无关。排查方法很简单:先打印一条文档,确认字段名拼写,再看 _id 表达式有没有 $ 前缀。
第二类问题是"为什么某个组的 _id 是 null"。当源文档里找不到 _id 表达式引用的字段时,Mongo 会把字段视为缺失值,所有这些缺失文档会被归到同一个组,_id 是 null。解决办法是先用 $match 过滤掉缺失关键字段的文档,或者用 $ifNull 给缺失值一个兜底值:
javascript复制db.orders.aggregate([
{
$group: {
_id: { $ifNull: ["$category", "未知分类"] },
totalSales: { $sum: "$price" }
}
}
])
第三类问题是"为什么 $push 出来的数组顺序不对"。$group 不保证组内文档顺序,即使源文档看起来有先后顺序,聚合结果里的数组也不一定保持这个顺序。如果你需要数组有序,应该先 $sort 再 $group 用 $push 收集,或者直接使用 $first 加 $sort 的方式。我之前做订单明细导出时,就遇到过 $push 出来的商品列表顺序和用户下单顺序不一致的情况,后来在前置管道加了一个 $sort 才解决。
4.3 关于性能与内存的几个建议
-
尽量把
$match放在管道的最开头做前置过滤,这是聚合性能优化的第一原则。$group之前的数据越少,分组和内存压力越小。 -
排序尽量在
$group之前完成。如果$sort放在$group之后,它只能对聚合结果排序,无法利用索引,性能会差很多。 -
如果要输出分组后的明细数组,不要用
$push把整个文档塞进去,哪怕用$addToSet也一样。收集少量字段,比如名称、ID,比收集整个文档省内存得多。我见过一个 2000 万文档的集合,因为$push整个文档,聚合直接超时,改成只推关键字段后性能提升了接近十倍。 -
确认索引是否被使用,可以用
explain("executionStats")检查聚合管道各阶段扫描的文档数。$group本身不走索引,但它前面$match/$sort如果能命中索引,整体性能会好很多。 -
对于超大集合,如果只是常规统计,很多团队会把结果定期物化到一张报表集合里,用
$merge或$out把聚合结果写回,而不是每次查询都临时跑一次全量聚合。这个方案能大幅降低生产环境的查询延迟,也是我接手报表服务后第一个落地优化的点。
结尾
我在实际项目里用 $group 总结过不少报表和监控指标,最大的体会是:$group 本身不难,难的是对"数据流在每个阶段发生了什么"有清晰认知。很多问题不是语法不会,而是不知道 $group 之后普通字段会消失、不知道顺序决定 first/last 的结果、不知道内存有上限。把这些问题理清楚,$group 就是聚合管道里最顺手的那把刀。
最后再分享一个小技巧:调试复杂管道时,不要一口气写完一大串阶段,先跑通 $match 看输入数据,再单独跑 $group 看分组结果,最后加累加器和后续阶段。出问题也方便定位到底在哪一层。按这个思路走,几乎所有聚合问题都能在十分钟内找到答案,这套方法我用了五年,一直在向团队新人推荐。
