上一篇文章: 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