MongoDB指南---1六、聚合

上一篇文章: MongoDB指南---1五、特殊的索引和集合:地理空間索引、使用GridFS存儲文件
下一篇文章: MongoDB指南---1七、MapReduce

若是你有數據存儲在MongoDB中,你想作的可能就不只僅是將數據提取出來那麼簡單了;你可能但願對數據進行分析並加以利用。本章介紹MongoDB提供的聚合工具:express

  • 聚合框架;
  • MapReduce;
  • 幾個簡單聚合命令:count、distinct和group。

 聚合框架

使用聚合框架能夠對集合中的文檔進行變換和組合。基本上,能夠用多個構件建立一個管道(pipeline),用於對一連串的文檔進行處理。這些構件包括篩選(filtering)、投射(projecting)、分組(grouping)、排序(sorting)、限制(limiting)和跳過(skipping)。
例如,有一個保存着雜誌文章的集合,你可能但願找出發表文章最多的那個做者。假設每篇文章被保存爲MongoDB中的一個文檔,能夠按照以下步驟建立管道。segmentfault

  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"建立的一個新字段。
執行完這一步以後,結果集中的每一個文檔會是這樣的結構:ide

{"_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個做者。post

若是管道沒有給出預期的結果,就須要進行調試,調試時,能夠先只指定第一個管道操做符。若是這時獲得了預期結果,那就再指定第二個管道操做符。之前面的例子來講,首先要試着只使用"$project"操做符進行聚合;若是這個操做符的結果是有效的,就再添加"$group"操做符;若是結果仍是有效的,就再添加"$sort";最後再添加"$limit"操做符。這樣就能夠逐步定位到形成問題的操做符。

本書寫做時,聚合框架還不能對集合進行寫入操做,所以全部結果必須返回給客戶端。因此,聚合的結果必需要限制在16 MB之內(MongoDB支持的最大響應消息大小)。ui

管道操做符

每一個操做符都會接受一連串的文檔,對這些文檔作一些類型轉換,最後將轉換後的文檔做爲結果傳遞給下一個操做符(對於最後一個管道操做符,是將結果返回給客戶端)。
不一樣的管道操做符能夠按任意順序組合在一塊兒使用,並且能夠被重複任意屢次。例如,能夠先作"$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指南---1五、特殊的索引和集合:地理空間索引、使用GridFS存儲文件
下一篇文章: MongoDB指南---1七、MapReduce
相關文章
相關標籤/搜索