MongoDB 的聚合管道由多个阶段串联而成,数据从前一个阶段流向下一阶段。$group 阶段的作用是把上一个阶段输出的大量文档按指定条件归组,每一组只输出一个汇总文档。这个阶段在订单统计、日志分析、用户行为汇总等场景中几乎无法绕开。它和 find 配合客户端循环计算相比,能把汇总压力放在数据库端,减少网络传输并利用数据库的并行执行能力。

一、$group 的基础语法和常用累加器
$group 的语法固定为 { $group: { _id: 分组键, 字段名: { 累加器: 表达式 } } }。其中 _id 是必须提供的分组依据,可以是字段路径、常量、对象或 null。当 _id 为 null 时,所有输入文档会被归为一组,适合做全局统计。累加器负责对每组内的文档执行计算,常用的包括 $sum、$avg、$max、$min、$first、$last、$push 和 $addToSet。每个累加器都要写在分组字段的值位置,字段名可以自定义,结果会出现在输出文档中。
以订单集合为例,如果每个文档包含 status 和 amount 字段,想按状态统计总金额和订单数量,可以直接引用字段路径。引用某个字段的值需要在字段名前加美元符号,例如 $amount 表示取 amount 字段。$sum 的值如果是 1,则相当于计数。下面代码按 status 分组,计算每组的订单总金额、订单数和平均金额。
db.orders.aggregate([
{
$group: {
_id: "$status",
totalAmount: { $sum: "$amount" },
orderCount: { $sum: 1 },
avgAmount: { $avg: "$amount" }
}
}
])
需要注意,$group 不会自动排序输出文档,结果顺序通常与分组键在内存哈希表中的遍历顺序有关。如果需要稳定顺序,必须再追加 $sort 阶段。此外,$group 只能对分组后的字段进行输出,原始文档中的其他字段如果没有被累加器处理,不会出现在结果中。这个限制能保证输出结构清晰,但初学者容易在查询后直接访问未聚合字段,导致结果不符合预期。
二、多字段分组与分组后过滤排序
实际统计很少只需要一个分组维度。比如既想按地区又想按月份查看销售额,单字段分组明显不够。这时可以把 _id 写成一个文档对象,对象里的每个字段都代表一个分组维度。MongoDB 不会要求分组键必须来自原始字段,还可以使用日期表达式、字符串拼接表达式等生成新的键。多字段分组后,输出文档的 _id 会包含嵌套对象,后续排序时需要写完整路径。
在分组之前通常需要先执行 $match 过滤掉无关数据,这样可以减少进入 $group 的文档数量,降低内存占用。分组之后如果只关心金额最高的几个组,可以继续用 $sort 和 $limit。比如按照商品类目和年份统计销售额,并只保留销售额前五的组合,管道可以写成下面这样。
db.sales.aggregate([
{
$match: {
saleDate: { $gte: ISODate("2024-01-01") }
}
},
{
$group: {
_id: {
year: { $year: "$saleDate" },
category: "$category"
},
totalSales: { $sum: "$amount" },
dealCount: { $sum: 1 }
}
},
{
$sort: { "_id.year": 1, totalSales: -1 }
},
{
$limit: 5
}
])
$sort 中引用 _id 内部字段时,需要写成 "_id.year" 这样的字符串,因为输出文档的 _id 是对象。totalSales 按 -1 降序排列,结合 $limit 5 就能得到每个年份中销售额最高的五个类目。这里 $match 放在最前面,利用 saleDate 字段上的索引可以快速缩小范围。如果先分组再过滤,性能和可读性都会变差。
三、实战案例:订单统计与性能优化
以一个典型的电商订单集合为例,每个文档大致包含 customerId、region、status、amount 和 createdAt 字段。产品需求是统计某段时间内各区域每天的总销售额、平均订单金额以及独立客户数。这个需求如果写业务代码需要在内存里维护多个 Map,数据量大时很容易出现内存溢出。用聚合管道可以让数据库完成绝大部分工作。
下面管道先限制时间范围和订单状态,再按日期字符串和地区分组。日期字段用 $dateToString 转成 yyyy-MM-dd 格式,便于按天聚合。独立客户数通过 $addToSet 收集客户 ID 后,再用 $size 计算数组长度。总金额和平均金额分别用 $sum 和 $avg,最后按日期升序、金额降序取前 20 条。
db.orders.aggregate([
{
$match: {
createdAt: { $gte: ISODate("2025-01-01"), $lt: ISODate("2025-07-01") },
status: { $in: ["completed", "paid"] }
}
},
{
$group: {
_id: {
day: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } },
region: "$region"
},
totalAmount: { $sum: "$amount" },
avgOrderValue: { $avg: "$amount" },
uniqueCustomers: { $addToSet: "$customerId" }
}
},
{
$project: {
_id: 1,
totalAmount: 1,
avgOrderValue: 1,
customerCount: { $size: "$uniqueCustomers" }
}
},
{
$sort: { "_id.day": 1, totalAmount: -1 }
},
{
$limit: 20
}
])
$addToSet 会为每个分组收集不重复的 customerId,后续 $project 用 $size 计算数组长度,从而得到独立客户数。这个方法在客户 ID 不存在重复的集合中也能使用,但如果某个订单里 customerId 为 null 或缺失,$addToSet 会把 null 视作一个值加入数组,导致独立客户数可能比实际多一。此时可以在 $match 阶段加上 customerId: { $ne: null } 过滤。
性能层面,$group 会把中间结果保存在内存中,默认允许使用 100MB。如果分组键非常多,比如要按用户 ID 分组且用户量上千万,很容易触发内存限制而报错。此时可以在 aggregate 方法中设置 allowDiskUse: true,让 MongoDB 将中间数据写到磁盘临时文件,代价是执行时间变长。另一个优化点是保证分组字段类型一致,字符串 "123" 和数字 123 会被视为不同键,导致结果分裂。索引也不能直接消除 $group 的开销,因为它仍然需要扫描匹配的文档,但合理的前置 $match 可以大幅减少输入量。
$group 是 Mongo 聚合场景中使用频率最高的阶段之一,掌握 _id 表达式、累加器组合和管道顺序之后,大部分分组统计需求都能用一条聚合语句完成。多练习从真实业务指标倒推聚合管道结构,比死记硬背语法更有效。
MongoDB聚合管道$group分组统计聚合案例修改时间:2026-09-30 11:30:30