MongoDB指南---16、聚合

上一篇文章:
MongoDB指南—15、特殊的索引和集合:地理空间索引、使用GridFS存储文件

下一篇文章:
MongoDB指南—17、MapReduce

如果你有数据存储在MongoDB中,你想做的可能就不仅仅是将数据提取出来那么简单了;你可能希望对数据进行分析并加以利用。本章介绍MongoDB提供的聚合工具:

  • 聚合框架;
  • MapReduce;
  • 几个简单聚合命令:count、distinct和group。

 聚合框架

使用聚合框架可以对集合中的文档进行变换和组合。基本上,可以用多个构件创建一个管道(pipeline),用于对一连串的文档进行处理。这些构件包括筛选(filtering)、投射(projecting)、分组(grouping)、排序(sorting)、限制(limiting)和跳过(skipping)。
例如,有一个保存着杂志文章的集合,你可能希望找出发表文章最多的那个作者。假设每篇文章被保存为MongoDB中的一个文档,可以按照如下步骤创建管道。

  1. 将每个文章文档中的作者投射出来。
  2. 将作者按照名字排序,统计每个名字出现的次数。
  3. 将作者按照名字出现次数降序排列。
  4. 将返回结果限制为前5个。

这里面的每一步都对应聚合框架中的一个操作符:

  1. {“$project” : {“author” : 1}}

这样可以将”author”从每个文档中投射出来。
这个语法与查询中的字段选择器比较像:可以通过指定”fieldname” : 1选择需要投射的字段,或者通过指定”fieldname”:0排除不需要的字段。执行完这个”$project”操作之后,结果集中的每个文档都会以{“_id” : id, “author” : “authorName”}这样的形式表示。这些结果只会在内存中存在,不会被写入磁盘。

  1. {“$group” : {“_id” : “$author”, “count” : {“$sum” : 1}}}

这样就会将作者按照名字排序,某个作者的名字每出现一次,就会对这个作者的”count”加1。
这里首先指定了需要进行分组的字段”author”。这是由”_id” : “$author”指定的。可以将这个操作想象为:这个操作执行完后,每个作者只对应一个结果文档,所以”author”就成了文档的唯一标识符(”_id”)。
第二个字段的意思是为分组内每个文档的”count”字段加1。注意,新加入的文档中并不会有”count”字段;这”$group”创建的一个新字段。
执行完这一步之后,结果集中的每个文档会是这样的结构:

{"_id" : "authorName", "count" : articleCount}。
  1. {“$sort” : {“count” : -1}}

这个操作会对结果集中的文档根据”count”字段进行降序排列。

  1. {“$limit” : 5}

这个操作将最终的返回结果限制为当前结果中的前5个文档。
在MongoDB中实际运行时,要将这些操作分别传给aggregate()函数:

> db.articles.aggregate({"$project" : {"author" : 1}},
... {"$group" : {"_id" : "$author", "count" : {"$sum" : 1}}},
... {"$sort" : {"count" : -1}},
... {"$limit" : 5})
{
    "result" : [
        {
            "_id" : "R. L. Stine",
            "count" : 430
        }, 
        {
            "_id" : "Edgar Wallace",
            "count" : 175
        },
        {
            "_id" : "Nora Roberts",
            "count" : 145
        },
        {
            "_id" : "Erle Stanley Gardner",
            "count" : 140
        },
        {
            "_id" : "Agatha Christie",
            "count" : 85
        }
    ],
    "ok" : 1
}

aggregate()会返回一个文档数组,其中的内容是发表文章最多的5个作者。

如果管道没有给出预期的结果,就需要进行调试,调试时,可以先只指定第一个管道操作符。如果这时得到了预期结果,那就再指定第二个管道操作符。以前面的例子来说,首先要试着只使用”$project”操作符进行聚合;如果这个操作符的结果是有效的,就再添加”$group”操作符;如果结果还是有效的,就再添加”$sort”;最后再添加”$limit”操作符。这样就可以逐步定位到造成问题的操作符。

本书写作时,聚合框架还不能对集合进行写入操作,因此所有结果必须返回给客户端。所以,聚合的结果必须要限制在16 MB以内(MongoDB支持的最大响应消息大小)。

管道操作符

每个操作符都会接受一连串的文档,对这些文档做一些类型转换,最后将转换后的文档作为结果传递给下一个操作符(对于最后一个管道操作符,是将结果返回给客户端)。
不同的管道操作符可以按任意顺序组合在一起使用,而且可以被重复任意多次。例如,可以先做”$match”,然后做”$group”,然后再做”$match”(与之前的”$match”匹配不同的查询条件)。

 $match

$match用于对文档集合进行筛选,之后就可以在筛选得到的文档子集上做聚合。例如,如果想对Oregon(俄勒冈州,简写为OR)的用户做统计,就可以使用{$match : {“state” : “OR”}}。”$match”可以使用所有常规的查询操作符(”$gt”、”$lt”、”$in”等)。有一个例外需要注意:不能在”$match”中使用地理空间操作符。
通常,在实际使用中应该尽可能将”$match”放在管道的前面位置。这样做有两个好处:一是可以快速将不需要的文档过滤掉,以减少管道的工作量;二是如果在投射和分组之前执行”$match”,查询可以使用索引。

 $project

相对于“普通”的查询而言,管道中的投射操作更加强大。使用”$project”可以从子文档中提取字段,可以重命名字段,还可以在这些字段上进行一些有意思的操作。
最简单的一个”$project”操作是从文档中选择想要的字段。可以指定包含或者不包含一个字段,它的语法与查询中的第二个参数类似。如果在原来的集合上执行下面的代码,返回的结果文档中只包含一个”author”字段。

> db.articles.aggregate({"$project" : {"author" : 1, "_id" : 0}})

默认情况下,如果文档中存在”_id”字段,这个字段就会被返回(”_id”字段可以被一些管道操作符移除,也可能已经被之前的投射操作给移除了)。可以使用上面的代码将”_id”从结果文档中移除。包含字段和排除字段的规则与常规查询中的语法一致。
也可以将投射过的字段进行重命名。例如,可以将每个用户文档的”_id”在返回结果中重命名为”userId”:

> db.users.aggregate({"$project" : {"userId" : "$_id", "_id" : 0}})
{
    "result" : [
        {
            "userId" : ObjectId("50e4b32427b160e099ddbee7")
        },
        {
            "userId" : ObjectId("50e4b32527b160e099ddbee8")
        }
        ...
    ],
    "ok" : 1
}

这里的”$fieldname”语法是为了在聚合框架中引用fieldname字段(上面的例子中是”_id”)的值。例如,”$age”会被替换为”age”字段的内容(可能是数值,也可能是字符串),”$tags.3″会被替换为tags数组中的第4个元素。所以,上面例子中的”$_id”会被替换为进入管道的每个文档的”_id”字段的值。
注意,必须明确指定将”_id”排除,否则这个字段的值会被返回两次:一次被标为”userId”,一次被标为”_id”。可以使用这种技术生成字段的多个副本,以便在之后的”$group”中使用。
在对字段进行重命名时,MongoDB并不会记录字段的历史名称。因此,如果在”originalFieldname”字段上有一个索引,聚合框架无法在下面的排序操作中使用这个索引,尽管人眼一下子就能看出下面代码中的”newFieldname”与”originalFieldname”表示同一个字段。

> db.articles.aggregate({"$project" : {"newFieldname" : "$originalFieldname"}},
... {"$sort" : {"newFieldname" : 1}})

所以,应该尽量在修改字段名称之前使用索引。

1. 管道表达式

最简单的”$project”表达式是包含和排除字段,以及字段名称(”$fieldname”)。但是,还有一些更强大的选项。也可以使用表达式(expression)将多个字面量和变量组合在一个值中使用。
在聚合框架中有几个表达式可用来组合或者进行任意深度的嵌套,以便创建复杂的表达式。

2. 数学表达式(mathematical expression)

算术表达式可用于操作数值。指定一组数值,就可以使用这个表达式进行操作了。例如,下面的表达式会将”salary”和”bonus”字段的值相加。

> db.employees.aggregate(
... {
...     "$project" : {
...          "totalPay" : {
...              "$add" : ["$salary", "$bonus"]
...          }
...      }
... })

可以将多个表达式嵌套在一起组成更复杂的表达式。假设我们想要从总金额中扣除为401(k)缴纳的金额。可以使用”$subtract”表达式:

401(k)是美国的一种养老金计划。——译者注

> db.employees.aggregate(
... {
...     "$project" : {
...         "totalPay" : {
...              "$subtract" : [{"$add" : ["$salary", "$bonus"]}, "$401k"]
...         }
...     }
... })

表达式可以进行任意层次的嵌套。
下面是每个操作符的语法:

  • “$add” : [expr1[, expr2, …, exprN]]

这个操作符接受一个或多个表达式作为参数,将这些表达式相加。

  • “$subtract” : [expr1, expr2]

接受两个表达式作为参数,用第一个表达式减去第二个表达式作为结果。

  • “$multiply” : [expr1[, expr2, …, exprN]]

接受一个或者多个表达式,并且将它们相乘。

  • “$divide” : [expr1, expr2]

接受两个表达式,用第一个表达式除以第二个表达式的商作为结果。

  • “$mod” : [expr1, expr2]

接受两个表达式,将第一个表达式除以第二个表达式得到的余数作为结果。

3. 日期表达式(date expression)

许多聚合都是基于时间的:上周发生了什么?上个月发生了什么?过去一年间发生了什么?因此,聚合框架中包含了一些用于提取日期信息的表达式:”$year”、“$month”、”$week”、”$dayOfMonth”、”$dayOfWeek”、”$dayOfYear”、”$hour”、”$minute”和”$second”。只能对日期类型的字段进行日期操作,不能对数值类型字段做日期操作。
每种日期类型的操作都是类似的:接受一个日期表达式,返回一个数值。下面的代码会返回每个雇员入职的月份:

> db.employees.aggregate(
... {
...     "$project" : {
...         "hiredIn" : {"$month" : "$hireDate"}
...     }
... })

也可以使用字面量日期。下面的代码会计算出每个雇员在公司内的工作时间:

> db.employees.aggregate(
... {
...     "$project" : { 
...         "tenure" : {
...             "$subtract" : [{"$year" : new Date()}, {"$year" : "$hireDate"}]
...         }
...     }
... })

4. 字符串表达式(string expression)

也有一些基本的字符串操作可以使用,它们的签名如下所示:

  • “$substr” : [expr, startOffset, numToReturn]

其中第一个参数expr必须是个字符串,这个操作会截取这个字符串的子串(从第startOffset字节开始的numToReturn字节,注意,是字节,不是字符。在多字节编码中尤其要注意这一点)expr必须是字符串。

  • “$concat” : [expr1[, expr2, …, exprN]]

将给定的表达式(或者字符串)连接在一起作为返回结果。

  • “$toLower” : expr

参数expr必须是个字符串值,这个操作返回expr的小写形式。

  • “$toUpper” : expr

参数expr必须是个字符串值,这个操作返回expr的大写形式。
改变字符大小写的操作,只保证对罗马字符有效。
下面是一个生成 j.doe@example.com格式的email地址的例子。它提取”$firstname”的第一个字符,将其与多个常量字符串和”$lastname”连接成一个字符串:

> db.employees.aggregate(
... {
...     "$project" : {
...         "email" : {
...             "$concat" : [
...                 {"$substr" : ["$firstName", 0, 1]},
...                 ".",
...                 "$lastName",
...                 "@example.com"
...             ]
...         }
...     }
... })

5. 逻辑表达式(logical expression)

有一些逻辑表达式可以用于控制语句。
下面是几个比较表达式。

  • “$cmp” : [expr1, expr2]

比较expr1和expr2。如果expr1等于expr2,返回0;如果expr1 < expr2,返回一个负数;如果expr1 >expr2,返回一个正数。

  • “$strcasecmp” : [string1, string2]

比较string1和string2,区分大小写。只对罗马字符组成的字符串有效。

  • “$eq”/”$ne”/”$gt”/”$gte”/”$lt”/”$lte” : [expr1, expr2]

对expr1和expr2执行相应的比较操作,返回比较的结果(true或false)。

下面是几个布尔表达式。

  • “$and” : [expr1[, expr2, …, exprN]]

如果所有表达式的值都是true,那就返回true,否则返回false。

  • “$or” : [expr1[, expr2, …, exprN]]

只要有任意表达式的值为true,就返回true,否则返回false。

  • “$not” : expr

对expr取反。

还有两个控制语句。

  • “$cond” : [booleanExpr, trueExpr, falseExpr]

如果booleanExpr的值是true,那就返回trueExpr,否则返回falseExpr。

  • “$ifNull” : [expr, replacementExpr]

如果expr是null,返回replacementExpr,否则返回expr。

通过这些操作符,就可以在聚合中使用更复杂的逻辑,可以对不同数据执行不同的代码,得到不同的结果。
管道对于输入数据的形式有特定要求,所以这些操作符在传入数据时要特别注意。算术操作符必须接受数值,日期操作符必须接受日期,字符串操作符必须接受字符串,如果有字符缺失,这些操作符就会报错。如果你的数据集不一致,可以通过这个条件来检测缺失的值,并且进行填充。

6. 一个提取的例子

假如有个教授想通过某种比较复杂的计算为学生打分:出勤率占10%,日常测验成绩占30%,期末考试占60%(如果是老师最宠爱的学生,那么分数就是100)。可以使用如下代码:

> db.students.aggregate(
... {
...     "$project" : {
...         "grade" : {
...             "$cond" : [
...                 "$teachersPet",
...                 100, // if
...                 {    // else
...                     "$add" : [
...                         {"$multiply" : [.1, "$attendanceAvg"]},
...                         {"$multiply" : [.3, "$quizzAvg"]},
...                         {"$multiply" : [.6, "$testAvg"]}
...                     ]
...                 }
...             ]
...         }
...     }
... }) 

$group

$group操作可以将文档依据特定字段的不同值进行分组。下面是几个分组的例子。

  • 如果我们以分钟作为计量单位,希望找出每天的平均湿度,就可以根据”day”字段进行分组。
  • 如果有一个学生集合,希望按照分数等级将学生分为多个组,可以根据”grade”字段进行分组。
  • 如果有一个用户集合,希望知道每个城市有多少用户,可以根据”state”和”city”两个字段对集合进行分组,每个”city”/”state”对对应一个分组。不应该只根据”city”字段进行分组,因为不同的州可能拥有相同名字的城市。

如果选定了需要进行分组的字段,就可以将选定的字段传递给”$group”函数的”_id”字段。对于上面的例子,相应的代码如下:

{"$group" : {"_id" : "$day"}}
{"$group" : {"_id" : "$grade"}}
{"$group" : {"_id" : {"state" : "$state", "city" : "$city"}}}

如果执行这些代码,结果集中每个分组对应一个只有一个字段(分组键)的文档。例如,按学生分数等级进行分组的结果可能是:{“result” : [{“_id” : “A+”}, {“_id” : “A”}, {“_id” : “A-“}, …, {“_id” : “F”}], “ok” : 1}。通过上面这些代码,可以得到特定字段中每一个不同的值,但是所有例子都要求基于这些分组进行一些计算。因此,可以添加一些字段,使用分组操作符对每个分组中的文档做一些计算。

1. 分组操作符

这些分组操作符允许对每个分组进行计算,得到相应的结果。7.1节介绍过”$sum”分组操作符的作用:分组中每出现一个文档,它就对计算结果加1,这样便可以得到每个分组中的文档数量。

2. 算术操作符

有两个操作符可以用于对数值类型字段的值进行计算:”$sum”和”$average”。

  • “$sum” : value

对于分组中的每一个文档,将value与计算结果相加。注意,上面的例子中使用了一个字面量数字1,但是这里也可以使用比较复杂的值。例如,如果有一个集合,其中的内容是各个国家的销售数据,使用下面的代码就可以得到每个国家的总收入:

> db.sales.aggregate(
... {
...     "$group" : {
...         "_id" : "$country",
...         "totalRevenue" : {"$sum" : "$revenue"}
...     }
... })
  • “$avg” : value

返回每个分组的平均值。
例如,下面的代码会返回每个国家的平均收入,以及每个国家的销量:

> db.sales.aggregate(
... {
...     "$group" : {
...         "_id" : "$country",
...         "totalRevenue" : {"$avg" : "$revenue"},
...         "numSales" : {"$sum" : 1}
...     }
... }) 

3. 极值操作符(extreme operator)

下面的四个操作符可用于得到数据集合中的“边缘”值。

  • “$max” : expr 返回分组内的最大值。
  • “$min” : expr

返回分组内的最小值。

  • “$first” : expr 返回分组的第一个值,忽略后面所有值。只有排序之后,明确知道数据顺序时这个操作才有意义。
  • “$last” : expr

与”$first”相反,返回分组的最后一个值。

“$max”和”$min”会查看每一个文档,以便得到极值。因此,如果数据是无序的,这两个操作符也可以有效工作;如果数据是有序的,这两个操作符就会有些浪费。假设有一个存有学生考试成绩的数据集,需要找到其中的最高分与最低分:

> db.scores.aggregate(
... {
...     "$group" : {
...         "_id" : "$grade",
...         "lowestScore" : {"$min" : "$score"},
...         "highestScore" : {"$max" : "$score"}
...         }
... })

另一方面,如果数据集是按照希望的字段排序过的,那么”$first”和”$last”操作符就会非常有用。下面的代码与上面的代码可以得到同样的结果:

> db.scores.aggregate(
... {
...     "$sort" : {"score" : 1}
... },
... {
...     "$group" : {
...         "_id" : "$grade",
...         "lowestScore" : {"$first" : "$score"},
...         "highestScore" : {"$last" : "$score"}
...     }
... })

如果数据是排过序的,那么$first和$last会比$min和$max效率更高。如果不准备对数据进行排序,那么直接使用$min和$max会比先排序再使用$first和$last效率更高。

4. 数组操作符

有两个操作符可以进行数组操作。

  • “$addToSet” : expr

如果当前数组中不包含expr ,那就将它添加到数组中。在返回结果集中,每个元素最多只出现一次,而且元素的顺序是不确定的。

  • “$push” : expr

不管expr是什么值,都将它添加到数组中。返回包含所有值的数组。

5. 分组行为

有两个操作符不能用前面介绍的流式工作方式对文档进行处理,”$group”是其中之一。大部分操作符的工作方式都是流式的,只要有新文档进入,就可以对新文档进行处理,但是”$group”必须要等收到所有的文档之后,才能对文档进行分组,然后才能将各个分组发送给管道中的下一个操作符。这意味着,在分片的情况下,”$group”会先在每个分片上执行,然后各个分片上的分组结果会被发送到mongos再进行最后的统一分组,剩余的管道工作也都是在mongos(而不是在分片)上运行的。

 $unwind

拆分(unwind)可以将数组中的每一个值拆分为单独的文档。例如,如果有一篇拥有多条评论的博客文章,可以使用$unwind将每条评论拆分为一个独立的文档:

> db.blog.findOne()
{
    "_id" : ObjectId("50eeffc4c82a5271290530be"),
    "author" : "k",
    "post" : "Hello, world!",
    "comments" : [
        {
            "author" : "mark",
            "date" : ISODate("2013-01-10T17:52:04.148Z"),
            "text" : "Nice post"
        },
        {
            "author" : "bill",
            "date" : ISODate("2013-01-10T17:52:04.148Z"),
            "text" : "I agree"
        }
    ]
}
> db.blog.aggregate({"$unwind" : "$comments"})
{
    "results" :
        {
            "_id" : ObjectId("50eeffc4c82a5271290530be"),
            "author" : "k",
            "post" : "Hello, world!",
            "comments" : {
                "author" : "mark",
                "date" : ISODate("2013-01-10T17:52:04.148Z"),
                "text" : "Nice post"
            }
        },
        {
            "_id" : ObjectId("50eeffc4c82a5271290530be"),
            "author" : "k",
            "post" : "Hello, world!",
            "comments" : {
                "author" : "bill",
                "date" : ISODate("2013-01-10T17:52:04.148Z"),
                "text" : "I agree"
            }
        }
    ],
    "ok" : 1
}

如果希望在查询中得到特定的子文档,这个操作符就会非常有用:先使用”$unwind”得到所有子文档,再使用”$match”得到想要的文档。例如,如果要得到特定用户的所有评论(只需要得到评论,不需要返回评论所属的文章),使用普通的查询是不可能做到的。但是,通过提取、拆分、匹配,就很容易了:

> db.blog.aggregate({"$project" : {"comments" : "$comments"}},
... {"$unwind" : "$comments"},
... {"$match" : {"comments.author" : "Mark"}})

由于最后得到的结果仍然是一个”comments”子文档,所以你可能希望再做一次投射,以便让输出结果更优雅。

$sort

可以根据任何字段(或者多个字段)进行排序,与在普通查询中的语法相同。如果要对大量的文档进行排序,强烈建议在管道的第一阶段进行排序,这时的排序操作可以使用索引。否则,排序过程就会比较慢,而且会占用大量内存。
可以在排序中使用文档中实际存在的字段,也可以使用在投射时重命名的字段:

> db.employees.aggregate(
... {
...     "$project" : {
...         "compensation" : {
...             "$add" : ["$salary", "$bonus"]
...         },
...         "name" : 1
...     }
... },
... {
...     "$sort" : {"compensation" : -1, "name" : 1}
... })

这个例子会对员工排序,最终的结果是按照报酬从高到低,姓名从A到Z的顺序排列。
排序方向可以是1(升序)和-1(降序)。
与前面讲过的”$group”一样,”$sort”也是一个无法使用流式工作方式的操作符。”$sort”也必须要接收到所有文档之后才能进行排序。在分片环境下,先在各个分片上进行排序,然后将各个分片的排序结果发送到mongos做进一步处理。

 $limit

$limit会接受一个数字n,返回结果集中的前n个文档。

 $skip

$skip也是接受一个数字n,丢弃结果集中的前n个文档,将剩余文档作为结果返回。在“普通”查询中,如果需要跳过大量的数据,那么这个操作符的效率会很低。在聚合中也是如此,因为它必须要先匹配到所有需要跳过的文档,然后再将这些文档丢弃。

 使用管道

应该尽量在管道的开始阶段(执行”$project”、”$group”或者”$unwind”操作之前)就将尽可能多的文档和字段过滤掉。管道如果不是直接从原先的集合中使用数据,那就无法在筛选和排序中使用索引。如果可能,聚合管道会尝试对操作进行排序,以便能够有效使用索引。
MongoDB不允许单一的聚合操作占用过多的系统内存:如果MongoDB发现某个聚合操作占用了20%以上的内存,这个操作就会直接输出错误。允许将输出结果利用管道放入一个集合中是为了方便以后使用(这样可以将所需的内存减至最小)。
如果能够通过”$match”操作迅速减小结果集的大小,就可以使用管道进行实时聚合。由于管道会不断包含更多的文档,会越来越复杂,所以几乎不可能实时得到管道的操作结果。

上一篇文章:
MongoDB指南—15、特殊的索引和集合:地理空间索引、使用GridFS存储文件

下一篇文章:
MongoDB指南—17、MapReduce

    原文作者:Mark
    原文地址: https://segmentfault.com/a/1190000019539659
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞