上一篇文章: MongoDB指南---1五、特殊的索引和集合:地理空间索引、使用GridFS存储文件
下一篇文章: MongoDB指南---1七、MapReduce
若是你有数据存储在MongoDB中,你想作的可能就不只仅是将数据提取出来那么简单了;你可能但愿对数据进行分析并加以利用。本章介绍MongoDB提供的聚合工具:express
使用聚合框架能够对集合中的文档进行变换和组合。基本上,能够用多个构件建立一个管道(pipeline),用于对一连串的文档进行处理。这些构件包括筛选(filtering)、投射(projecting)、分组(grouping)、排序(sorting)、限制(limiting)和跳过(skipping)。
例如,有一个保存着杂志文章的集合,你可能但愿找出发表文章最多的那个做者。假设每篇文章被保存为MongoDB中的一个文档,能够按照以下步骤建立管道。segmentfault
这里面的每一步都对应聚合框架中的一个操做符:数组
这样能够将"author"从每一个文档中投射出来。
这个语法与查询中的字段选择器比较像:能够经过指定"fieldname" : 1选择须要投射的字段,或者经过指定"fieldname":0排除不须要的字段。执行完这个"$project"操做以后,结果集中的每一个文档都会以{"_id" : id, "author" : "authorName"}这样的形式表示。这些结果只会在内存中存在,不会被写入磁盘。框架
这样就会将做者按照名字排序,某个做者的名字每出现一次,就会对这个做者的"count"加1。
这里首先指定了须要进行分组的字段"author"。这是由"_id" : "$author"指定的。能够将这个操做想象为:这个操做执行完后,每一个做者只对应一个结果文档,因此"author"就成了文档的惟一标识符("_id")。
第二个字段的意思是为分组内每一个文档的"count"字段加1。注意,新加入的文档中并不会有"count"字段;这"$group"建立的一个新字段。
执行完这一步以后,结果集中的每一个文档会是这样的结构:ide
{"_id" : "authorName", "count" : articleCount}。
这个操做会对结果集中的文档根据"count"字段进行降序排列。函数
这个操做将最终的返回结果限制为当前结果中的前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个做者。post
若是管道没有给出预期的结果,就须要进行调试,调试时,能够先只指定第一个管道操做符。若是这时获得了预期结果,那就再指定第二个管道操做符。之前面的例子来讲,首先要试着只使用"$project"操做符进行聚合;若是这个操做符的结果是有效的,就再添加"$group"操做符;若是结果仍是有效的,就再添加"$sort";最后再添加"$limit"操做符。这样就能够逐步定位到形成问题的操做符。
本书写做时,聚合框架还不能对集合进行写入操做,所以全部结果必须返回给客户端。因此,聚合的结果必需要限制在16 MB之内(MongoDB支持的最大响应消息大小)。ui
每一个操做符都会接受一连串的文档,对这些文档作一些类型转换,最后将转换后的文档做为结果传递给下一个操做符(对于最后一个管道操做符,是将结果返回给客户端)。
不一样的管道操做符能够按任意顺序组合在一块儿使用,并且能够被重复任意屡次。例如,能够先作"$match",而后作"$group",而后再作"$match"(与以前的"$match"匹配不一样的查询条件)。编码
$match用于对文档集合进行筛选,以后就能够在筛选获得的文档子集上作聚合。例如,若是想对Oregon(俄勒冈州,简写为OR)的用户作统计,就可使用{$match : {"state" : "OR"}}。"$match"可使用全部常规的查询操做符("$gt"、"$lt"、"$in"等)。有一个例外须要注意:不能在"$match"中使用地理空间操做符。
一般,在实际使用中应该尽量将"$match"放在管道的前面位置。这样作有两个好处:一是能够快速将不须要的文档过滤掉,以减小管道的工做量;二是若是在投射和分组以前执行"$match",查询可使用索引。
相对于“普通”的查询而言,管道中的投射操做更增强大。使用"$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}})
因此,应该尽可能在修改字段名称以前使用索引。
最简单的"$project"表达式是包含和排除字段,以及字段名称("$fieldname")。可是,还有一些更强大的选项。也可使用表达式(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"] ... } ... } ... })
表达式能够进行任意层次的嵌套。
下面是每一个操做符的语法:
这个操做符接受一个或多个表达式做为参数,将这些表达式相加。
接受两个表达式做为参数,用第一个表达式减去第二个表达式做为结果。
接受一个或者多个表达式,而且将它们相乘。
接受两个表达式,用第一个表达式除以第二个表达式的商做为结果。
接受两个表达式,将第一个表达式除以第二个表达式获得的余数做为结果。
许多聚合都是基于时间的:上周发生了什么?上个月发生了什么?过去一年间发生了什么?所以,聚合框架中包含了一些用于提取日期信息的表达式:"$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"}] ... } ... } ... })
也有一些基本的字符串操做可使用,它们的签名以下所示:
其中第一个参数expr必须是个字符串,这个操做会截取这个字符串的子串(从第startOffset字节开始的numToReturn字节,注意,是字节,不是字符。在多字节编码中尤为要注意这一点)expr必须是字符串。
将给定的表达式(或者字符串)链接在一块儿做为返回结果。
参数expr必须是个字符串值,这个操做返回expr的小写形式。
参数expr必须是个字符串值,这个操做返回expr的大写形式。
改变字符大小写的操做,只保证对罗马字符有效。
下面是一个生成 j.doe@example.com格式的email地址的例子。它提取"$firstname"的第一个字符,将其与多个常量字符串和"$lastname"链接成一个字符串:
> db.employees.aggregate( ... { ... "$project" : { ... "email" : { ... "$concat" : [ ... {"$substr" : ["$firstName", 0, 1]}, ... ".", ... "$lastName", ... "@example.com" ... ] ... } ... } ... })
有一些逻辑表达式能够用于控制语句。
下面是几个比较表达式。
比较expr1和expr2。若是expr1等于expr2,返回0;若是expr1 < expr2,返回一个负数;若是expr1 >expr2,返回一个正数。
比较string1和string2,区分大小写。只对罗马字符组成的字符串有效。
对expr1和expr2执行相应的比较操做,返回比较的结果(true或false)。
下面是几个布尔表达式。
若是全部表达式的值都是true,那就返回true,不然返回false。
只要有任意表达式的值为true,就返回true,不然返回false。
对expr取反。
还有两个控制语句。
若是booleanExpr的值是true,那就返回trueExpr,不然返回falseExpr。
若是expr是null,返回replacementExpr,不然返回expr。
经过这些操做符,就能够在聚合中使用更复杂的逻辑,能够对不一样数据执行不一样的代码,获得不一样的结果。
管道对于输入数据的形式有特定要求,因此这些操做符在传入数据时要特别注意。算术操做符必须接受数值,日期操做符必须接受日期,字符串操做符必须接受字符串,若是有字符缺失,这些操做符就会报错。若是你的数据集不一致,能够经过这个条件来检测缺失的值,而且进行填充。
假若有个教授想经过某种比较复杂的计算为学生打分:出勤率占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"函数的"_id"字段。对于上面的例子,相应的代码以下:
{"$group" : {"_id" : "$day"}} {"$group" : {"_id" : "$grade"}} {"$group" : {"_id" : {"state" : "$state", "city" : "$city"}}}
若是执行这些代码,结果集中每一个分组对应一个只有一个字段(分组键)的文档。例如,按学生分数等级进行分组的结果多是:{"result" : [{"_id" : "A+"}, {"_id" : "A"}, {"_id" : "A-"}, ..., {"_id" : "F"}], "ok" : 1}。经过上面这些代码,能够获得特定字段中每个不一样的值,可是全部例子都要求基于这些分组进行一些计算。所以,能够添加一些字段,使用分组操做符对每一个分组中的文档作一些计算。
这些分组操做符容许对每一个分组进行计算,获得相应的结果。7.1节介绍过"$sum"分组操做符的做用:分组中每出现一个文档,它就对计算结果加1,这样即可以获得每一个分组中的文档数量。
有两个操做符能够用于对数值类型字段的值进行计算:"$sum"和"$average"。
对于分组中的每个文档,将value与计算结果相加。注意,上面的例子中使用了一个字面量数字1,可是这里也可使用比较复杂的值。例如,若是有一个集合,其中的内容是各个国家的销售数据,使用下面的代码就能够获得每一个国家的总收入:
> db.sales.aggregate( ... { ... "$group" : { ... "_id" : "$country", ... "totalRevenue" : {"$sum" : "$revenue"} ... } ... })
返回每一个分组的平均值。
例如,下面的代码会返回每一个国家的平均收入,以及每一个国家的销量:
> db.sales.aggregate( ... { ... "$group" : { ... "_id" : "$country", ... "totalRevenue" : {"$avg" : "$revenue"}, ... "numSales" : {"$sum" : 1} ... } ... })
下面的四个操做符可用于获得数据集合中的“边缘”值。
返回分组内的最小值。
与"$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效率更高。
有两个操做符能够进行数组操做。
若是当前数组中不包含expr ,那就将它添加到数组中。在返回结果集中,每一个元素最多只出现一次,并且元素的顺序是不肯定的。
无论expr是什么值,都将它添加到数组中。返回包含全部值的数组。
有两个操做符不能用前面介绍的流式工做方式对文档进行处理,"$group"是其中之一。大部分操做符的工做方式都是流式的,只要有新文档进入,就能够对新文档进行处理,可是"$group"必需要等收到全部的文档以后,才能对文档进行分组,而后才能将各个分组发送给管道中的下一个操做符。这意味着,在分片的状况下,"$group"会先在每一个分片上执行,而后各个分片上的分组结果会被发送到mongos再进行最后的统一分组,剩余的管道工做也都是在mongos(而不是在分片)上运行的。
拆分(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"子文档,因此你可能但愿再作一次投射,以便让输出结果更优雅。
能够根据任何字段(或者多个字段)进行排序,与在普通查询中的语法相同。若是要对大量的文档进行排序,强烈建议在管道的第一阶段进行排序,这时的排序操做可使用索引。不然,排序过程就会比较慢,并且会占用大量内存。
能够在排序中使用文档中实际存在的字段,也可使用在投射时重命名的字段:
> db.employees.aggregate( ... { ... "$project" : { ... "compensation" : { ... "$add" : ["$salary", "$bonus"] ... }, ... "name" : 1 ... } ... }, ... { ... "$sort" : {"compensation" : -1, "name" : 1} ... })
这个例子会对员工排序,最终的结果是按照报酬从高到低,姓名从A到Z的顺序排列。
排序方向能够是1(升序)和-1(降序)。
与前面讲过的"$group"同样,"$sort"也是一个没法使用流式工做方式的操做符。"$sort"也必需要接收到全部文档以后才能进行排序。在分片环境下,先在各个分片上进行排序,而后将各个分片的排序结果发送到mongos作进一步处理。
$limit会接受一个数字n,返回结果集中的前n个文档。
$skip也是接受一个数字n,丢弃结果集中的前n个文档,将剩余文档做为结果返回。在“普通”查询中,若是须要跳过大量的数据,那么这个操做符的效率会很低。在聚合中也是如此,由于它必需要先匹配到全部须要跳过的文档,而后再将这些文档丢弃。
应该尽可能在管道的开始阶段(执行"$project"、"$group"或者"$unwind"操做以前)就将尽量多的文档和字段过滤掉。管道若是不是直接从原先的集合中使用数据,那就没法在筛选和排序中使用索引。若是可能,聚合管道会尝试对操做进行排序,以便可以有效使用索引。
MongoDB不容许单一的聚合操做占用过多的系统内存:若是MongoDB发现某个聚合操做占用了20%以上的内存,这个操做就会直接输出错误。容许将输出结果利用管道放入一个集合中是为了方便之后使用(这样能够将所需的内存减至最小)。
若是可以经过"$match"操做迅速减少结果集的大小,就可使用管道进行实时聚合。因为管道会不断包含更多的文档,会愈来愈复杂,因此几乎不可能实时获得管道的操做结果。
上一篇文章: MongoDB指南---1五、特殊的索引和集合:地理空间索引、使用GridFS存储文件
下一篇文章: MongoDB指南---1七、MapReduce