MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

MongoDB聚合管道中的$group操作符

2022-09-283.2k 阅读

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字段是一个包含customerorderDate的文档。这意味着,文档会先按客户分组,在每个客户组内再按订单日期分组。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操作符按userIdvisitTime升序排序,确保在每个用户组内,文档按访问时间顺序排列。然后$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都能发挥重要作用,帮助我们从数据中提取有价值的信息。