MongoDB聚合管道中的$group操作符
MongoDB聚合管道中的$group操作符基础
在MongoDB的聚合框架中,$group
操作符是一个非常强大且核心的工具。它主要用于根据指定的表达式对输入文档进行分组,并对每个组应用累加器操作。简单来说,$group
可以将相似的文档归为一组,然后在每组上执行一些聚合计算,例如求和、求平均值、计数等。
基本语法
$group
操作符的基本语法如下:
{
$group: {
_id: <expression>,
<field1>: { <accumulator1>: <expression1> },
<field2>: { <accumulator2>: <expression2> },
...
}
}
_id
字段是必需的,它定义了分组的依据。<expression>
可以是一个字段路径(例如$fieldName
),表示按照文档中的某个字段进行分组;也可以是一个更复杂的表达式,例如将多个字段组合起来进行分组。<field1>
、<field2>
等是自定义的输出字段,在每个组中计算得到。<accumulator1>
、<accumulator2>
等是累加器操作符,用于对组内的文档数据进行计算,如$sum
、$avg
、$first
等。
简单示例 - 按单一字段分组并计数
假设我们有一个集合orders
,包含以下文档结构:
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56710"),
"orderId" : 1,
"customer" : "Alice",
"amount" : 100,
"orderDate" : ISODate("2023-01-01T00:00:00Z")
}
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56711"),
"orderId" : 2,
"customer" : "Bob",
"amount" : 150,
"orderDate" : ISODate("2023-01-02T00:00:00Z")
}
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56712"),
"orderId" : 3,
"customer" : "Alice",
"amount" : 200,
"orderDate" : ISODate("2023-01-03T00:00:00Z")
}
我们想要统计每个客户的订单数量。可以使用以下聚合管道:
db.orders.aggregate([
{
$group: {
_id: "$customer",
orderCount: { $sum: 1 }
}
}
]);
在这个例子中:
_id: "$customer"
表示按照customer
字段进行分组。orderCount: { $sum: 1 }
使用$sum
累加器,对每个组内的文档进行计数。因为每个文档代表一个订单,每次遇到一个新文档就加1,从而统计出每个客户的订单数量。
执行上述聚合操作后,结果如下:
{ "_id" : "Bob", "orderCount" : 1 }
{ "_id" : "Alice", "orderCount" : 2 }
使用复杂表达式进行分组
按多个字段分组
有时,我们可能需要根据多个字段来分组。例如,在orders
集合中,我们不仅想按客户分组,还想按订单日期分组,统计每个客户在每天的订单总金额。可以这样写聚合管道:
db.orders.aggregate([
{
$group: {
_id: {
customer: "$customer",
orderDate: "$orderDate"
},
totalAmount: { $sum: "$amount" }
}
}
]);
这里_id
字段是一个包含customer
和orderDate
的文档。这意味着,文档会先按客户分组,在每个客户组内再按订单日期分组。totalAmount
使用$sum
累加器计算每个分组内订单金额的总和。
假设集合中有如下文档:
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56710"),
"orderId" : 1,
"customer" : "Alice",
"amount" : 100,
"orderDate" : ISODate("2023-01-01T00:00:00Z")
}
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56711"),
"orderId" : 2,
"customer" : "Alice",
"amount" : 150,
"orderDate" : ISODate("2023-01-01T00:00:00Z")
}
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56712"),
"orderId" : 3,
"customer" : "Alice",
"amount" : 200,
"orderDate" : ISODate("2023-01-02T00:00:00Z")
}
执行上述聚合操作后,结果如下:
{
"_id" : {
"customer" : "Alice",
"orderDate" : ISODate("2023-01-01T00:00:00Z")
},
"totalAmount" : 250
}
{
"_id" : {
"customer" : "Alice",
"orderDate" : ISODate("2023-01-02T00:00:00Z")
},
"totalAmount" : 200
}
使用表达式计算分组值
除了直接使用文档中的字段,还可以使用表达式来生成分组值。例如,我们想按订单金额的范围对订单进行分组,统计每个金额范围内的订单数量。假设订单金额范围分为三个区间:0 - 100,101 - 200,201及以上。
db.orders.aggregate([
{
$group: {
_id: {
$cond: [
{ $lte: ["$amount", 100] },
"0 - 100",
{
$cond: [
{ $lte: ["$amount", 200] },
"101 - 200",
"201及以上"
]
}
]
},
orderCount: { $sum: 1 }
}
}
]);
这里_id
字段使用$cond
表达式,根据amount
字段的值来确定分组。$cond
表达式是一个三元运算符,第一个参数是条件,第二个参数是条件为真时返回的值,第三个参数是条件为假时返回的值。通过嵌套$cond
表达式,我们可以实现多个区间的判断。
常用的累加器操作符
$sum
$sum
累加器用于计算组内指定表达式的总和。前面的例子中已经多次使用过$sum
来统计订单数量或订单总金额。例如,在products
集合中,每个文档包含产品的数量和价格,我们想计算每个类别产品的总价值:
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56710"),
"productName" : "Product A",
"category" : "Electronics",
"quantity" : 5,
"price" : 100
}
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56711"),
"productName" : "Product B",
"category" : "Clothing",
"quantity" : 3,
"price" : 50
}
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56712"),
"productName" : "Product C",
"category" : "Electronics",
"quantity" : 2,
"price" : 150
}
聚合管道如下:
db.products.aggregate([
{
$group: {
_id: "$category",
totalValue: {
$sum: { $multiply: ["$quantity", "$price"] }
}
}
}
]);
这里$sum
的参数是一个$multiply
表达式,用于计算每个产品的价值(数量 * 价格),然后对组内所有产品的价值求和,得到每个类别的总价值。
$avg
$avg
累加器用于计算组内指定表达式的平均值。例如,在students
集合中,每个文档包含学生的姓名和成绩,我们想计算每个班级学生的平均成绩:
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56710"),
"studentName" : "Tom",
"class" : "Class 1",
"score" : 85
}
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56711"),
"studentName" : "Jerry",
"class" : "Class 1",
"score" : 90
}
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56712"),
"studentName" : "Alice",
"class" : "Class 2",
"score" : 75
}
聚合管道如下:
db.students.aggregate([
{
$group: {
_id: "$class",
averageScore: { $avg: "$score" }
}
}
]);
执行结果会返回每个班级的平均成绩。
$first和$last
$first
累加器返回组内文档中指定表达式的第一个值,$last
则返回最后一个值。这在一些需要获取最早或最晚数据的场景中很有用。例如,在websiteVisits
集合中,每个文档记录了用户的访问时间和页面:
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56710"),
"userId" : 1,
"visitTime" : ISODate("2023-01-01T08:00:00Z"),
"page" : "Home"
}
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56711"),
"userId" : 1,
"visitTime" : ISODate("2023-01-01T08:10:00Z"),
"page" : "Product"
}
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56712"),
"userId" : 2,
"visitTime" : ISODate("2023-01-01T09:00:00Z"),
"page" : "Home"
}
我们想知道每个用户第一次和最后一次访问的页面,可以使用以下聚合管道:
db.websiteVisits.aggregate([
{
$sort: {
userId: 1,
visitTime: 1
}
},
{
$group: {
_id: "$userId",
firstPage: { $first: "$page" },
lastPage: { $last: "$page" }
}
}
]);
这里先使用$sort
操作符按userId
和visitTime
升序排序,确保在每个用户组内,文档按访问时间顺序排列。然后$group
操作符使用$first
和$last
累加器分别获取每个用户的第一个和最后一个访问页面。
$push和$addToSet
$push
累加器将组内文档中指定表达式的值以数组的形式收集起来。例如,在books
集合中,每个文档包含书籍的标题和作者,我们想列出每个作者的所有书籍:
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56710"),
"bookTitle" : "Book A",
"author" : "Author 1"
}
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56711"),
"bookTitle" : "Book B",
"author" : "Author 2"
}
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56712"),
"bookTitle" : "Book C",
"author" : "Author 1"
}
聚合管道如下:
db.books.aggregate([
{
$group: {
_id: "$author",
books: { $push: "$bookTitle" }
}
}
]);
执行结果会返回每个作者及其所著书籍的数组。
$addToSet
与$push
类似,但它会去除数组中的重复值。如果我们不想在列出作者书籍时出现重复书籍标题,可以使用$addToSet
:
db.books.aggregate([
{
$group: {
_id: "$author",
books: { $addToSet: "$bookTitle" }
}
}
]);
$group操作符与其他聚合操作符的配合
与$match配合
$match
操作符用于筛选文档,在聚合管道中,它通常在$group
之前使用,以减少参与分组的数据量,提高聚合效率。例如,在orders
集合中,我们只想统计金额大于100的订单中每个客户的订单数量:
db.orders.aggregate([
{
$match: {
amount: { $gt: 100 }
}
},
{
$group: {
_id: "$customer",
orderCount: { $sum: 1 }
}
}
]);
这里$match
先筛选出金额大于100的订单文档,然后$group
在这些筛选后的文档上进行分组和计数。
与$sort配合
$sort
操作符用于对文档进行排序。在$group
之前使用$sort
可以影响分组的顺序以及累加器操作的顺序。例如,在employees
集合中,每个文档包含员工的姓名、部门和工资,我们想按部门分组,并获取每个部门工资最高的员工姓名:
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56710"),
"employeeName" : "Employee A",
"department" : "HR",
"salary" : 5000
}
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56711"),
"employeeName" : "Employee B",
"department" : "IT",
"salary" : 6000
}
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56712"),
"employeeName" : "Employee C",
"department" : "HR",
"salary" : 5500
}
聚合管道如下:
db.employees.aggregate([
{
$sort: {
department: 1,
salary: -1
}
},
{
$group: {
_id: "$department",
highestPaidEmployee: { $first: "$employeeName" }
}
}
]);
先使用$sort
按部门升序、工资降序排序,这样在每个部门组内,工资最高的员工排在前面。然后$group
使用$first
累加器获取每个部门工资最高的员工姓名。
与$project配合
$project
操作符用于修改输出文档的结构,在$group
之后使用$project
可以对分组后的结果进行进一步的处理和格式化。例如,在前面统计每个客户订单数量的例子中,我们在分组后想将结果中的_id
字段重命名为customer
,并添加一个新的字段isHighVolume
,表示订单数量是否大于10:
db.orders.aggregate([
{
$group: {
_id: "$customer",
orderCount: { $sum: 1 }
}
},
{
$project: {
customer: "$_id",
_id: 0,
orderCount: 1,
isHighVolume: { $gt: ["$orderCount", 10] }
}
}
]);
这里$project
将_id
重命名为customer
,并通过$gt
表达式判断订单数量是否大于10,生成isHighVolume
字段。同时,_id: 0
表示不输出原来的_id
字段。
在复杂场景下使用$group
多层分组
在某些情况下,我们可能需要进行多层分组。例如,在一个销售数据集合sales
中,文档包含地区、城市、店铺和销售额等字段,我们想先按地区分组,然后在每个地区内按城市分组,统计每个城市的店铺数量和总销售额,最后再统计每个地区的店铺总数和总销售额。
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56710"),
"region" : "North",
"city" : "City A",
"store" : "Store 1",
"salesAmount" : 1000
}
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56711"),
"region" : "North",
"city" : "City A",
"store" : "Store 2",
"salesAmount" : 1500
}
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56712"),
"region" : "South",
"city" : "City B",
"store" : "Store 3",
"salesAmount" : 2000
}
聚合管道如下:
db.sales.aggregate([
{
$group: {
_id: {
region: "$region",
city: "$city"
},
storeCount: { $sum: 1 },
totalSales: { $sum: "$salesAmount" }
}
},
{
$group: {
_id: "$_id.region",
cityStats: {
$push: {
city: "$_id.city",
storeCount: "$storeCount",
totalSales: "$totalSales"
}
},
totalStores: { $sum: "$storeCount" },
totalRegionSales: { $sum: "$totalSales" }
}
}
]);
在第一个$group
中,先按地区和城市进行分组,统计每个城市的店铺数量和总销售额。在第二个$group
中,再按地区分组,将每个城市的统计结果通过$push
收集到cityStats
数组中,并计算每个地区的店铺总数和总销售额。
处理嵌套文档
当集合中的文档包含嵌套结构时,$group
操作符同样可以发挥作用。例如,在一个博客文章集合blogPosts
中,每个文档包含文章标题、作者和评论数组,评论数组中的每个元素包含评论者姓名和评论内容:
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56710"),
"title" : "Article 1",
"author" : "Author 1",
"comments" : [
{
"commenter" : "Commenter 1",
"content" : "Great article!"
},
{
"commenter" : "Commenter 2",
"content" : "I agree."
}
]
}
{
"_id" : ObjectId("6306f8f4a2b1f859c9c56711"),
"title" : "Article 2",
"author" : "Author 2",
"comments" : [
{
"commenter" : "Commenter 3",
"content" : "Interesting topic."
}
]
}
我们想统计每个作者的文章数量以及总的评论数量。可以使用以下聚合管道:
db.blogPosts.aggregate([
{
$unwind: "$comments"
},
{
$group: {
_id: "$author",
articleCount: { $sum: 1 },
commentCount: { $sum: 1 }
}
}
]);
这里先使用$unwind
操作符将comments
数组展开,使每个评论成为一个单独的文档。然后$group
按作者分组,统计文章数量(因为每个文档代表一篇文章,所以$sum: 1
)和评论数量(展开后的每个评论文档也计为1)。
性能优化与注意事项
性能优化
- 合理使用索引:如果在
$match
或$sort
操作中使用的字段上有索引,可以显著提高聚合效率。例如,在前面统计金额大于100的订单中每个客户的订单数量的例子中,如果amount
字段上有索引,$match
操作会更快。 - 减少数据量:在聚合管道的开头使用
$match
尽可能地筛选掉不需要的数据,减少参与后续操作(如$group
)的数据量,从而提高性能。 - 避免过度嵌套:在多层分组等复杂操作中,尽量简化嵌套结构,避免过多的中间计算和数据处理,以减少内存和计算资源的消耗。
注意事项
_id
的唯一性:$group
操作中的_id
字段必须唯一标识每个组。如果_id
表达式不能保证唯一性,可能会导致分组结果不准确。- 累加器的使用:不同的累加器有不同的行为和适用场景。例如,
$avg
在组内没有文档时会返回null
,而$sum
会返回0。在使用累加器时,要清楚其行为,以确保得到正确的结果。 - 内存限制:在处理大量数据时,聚合操作可能会受到内存限制。如果聚合操作在内存中无法完成,可以考虑使用
allowDiskUse
选项,但这可能会降低性能。
通过深入理解和灵活运用$group
操作符及其与其他聚合操作符的配合,我们可以在MongoDB中高效地处理各种复杂的数据分析和聚合需求。无论是简单的分组计数,还是多层分组、处理嵌套文档等复杂场景,$group
都能发挥重要作用,帮助我们从数据中提取有价值的信息。