MongoDB權威指南(5)- 聚合
除了基本的查詢功能外,mongoDB還提供了聚合工具,從簡單的計數到使用MapReduce進行復雜數據的分析等。
1.count
最簡單的聚合工具就是count了,它返回document的數量
0
> db.foo.insert({ " x " : 1 })
> db.foo.count()
1
也可以傳遞一個查詢條件,計算符合條件的結果個數
> db.foo.count()
2
> db.foo.count({ " x " : 1 })
1
2.distinct
distinct命令返回指定的key的所有不同的值。你必須指定一個collection和一個key。
假設我們的collection里的document是這樣子的:
{ " name " : " Fred " , " age " : 35 }
{ " name " : " Susan " , " age " : 60 }
{ " name " : " Andy " , " age " : 35 }
那么返回的結果就是
{ " values " : [ 20 , 35 , 60 ], " ok " : 1 }
3.group
group提供了更加復雜的聚合功能,它跟SQL里邊的group by很類似,你需要指定一個group by的key,mongoDB按照這個key的值把collection分成不同的組,經過聚合后每個組都產生一個結果document。
假設我們有一個站點用來跟蹤股票價格,從上午10點到下午4點,每隔幾分鐘就會有最新的股票價格存儲進數據庫,作為報表程序的一部分,我們想找出過去30天的收盤價,使用group就可以很容易做到。
股票價格的collection里有成千上萬條紀錄,格式如下:
{ " day " : " 2010/10/04 " , " time " : " 10/4/2010 11:28:39 GMT-400 " , " price " : 4.27 }
{ " day " : " 2010/10/03 " , " time " : " 10/3/2010 05:00:23 GMT-400 " , " price " : 4.10 }
{ " day " : " 2010/10/06 " , " time " : " 10/6/2010 05:27:58 GMT-400 " , " price " : 4.30 }
{ " day " : " 2010/10/04 " , " time " : " 10/4/2010 08:34:50 GMT-400 " , " price " : 4.01 }
我們想要的是每天里邊最后成交的那個價錢,結果應該是像下邊這樣
{ " time " : " 10/3/2010 05:00:23 GMT-400 " , " price " : 4.10 },
{ " time " : " 10/4/2010 11:28:39 GMT-400 " , " price " : 4.27 },
{ " time " : " 10/6/2010 05:27:58 GMT-400 " , " price " : 4.30 }
]
那么我們就應該按day分組,找到每組里時間戳最新的記錄,把它放到結果集里
... " ns " : " stocks " ,
... " key " : " day " ,
... " initial " : { " time " : 0 },
... " $reduce " : function (doc, prev) {
... if (doc.time > prev.time) {
... prev.price = doc.price;
... prev.time = doc.time;
... }
... }}})
- "ns" : "stocks"
指定對哪個collection運行group命令 - "key" : "day"
指定按那個key進行分組 - "initial" : {"time" : 0}
累計器初始值,每個分組第一次調用reduce方法的時候傳遞給它的值,在一個分組里邊,始終使用同一個累計器,對累計器的修改會被保持下來。 - "$reduce" : function(doc, prev) { ... }
collection里的每個document,都要對之調用reduce方法,傳遞兩個參數給它,第一個是當前的document,第二個是累計器 document,累計器document就是到目前為止分組內的計算結果。(ps:不知道它為啥起個名字叫prev,使用total啊 accumulation之類的不是更容易理解些,我一眼看上去還以為是前一個document。)我們這個例子里,使用reduce方法來比較當前的 document和累計器document的時間,如果當前document的時間更靠后些的話,就是用當前document的值替換累計器 document的值。因為每個組都有各自的累計器,勿需擔心日期的不同對累計器的影響。
先前我們說的是取最近30天的價格,我們可以加一個條件,滿足條件的才會處理
... " ns " : " stocks " ,
... " key " : " day " ,
... " initial " : { " time " : 0 },
... " $reduce " : function (doc, prev) {
... if (doc.time > prev.time) {
... prev.price = doc.price;
... prev.time = doc.time;
... }},
... " condition " : { " day " : { " $gt " : " 2010/09/30 " }}
... }})
如果某些document沒有day這個鍵的話,它們就會被歸入到day:null這個組,你可以給condition加個條件"day" : {"$exists" : true}來排除這個組。
使用終結器(Finalizer)
終結器用于最小化從數據庫到用戶的數據,我們看一個博客的例子,每篇博客都有幾個標簽,我們想找出每天最流行的標簽是什么。那么我們按照日期進行分組,對每個標簽計數:
... " key " : { " tags " : true },
... " initial " : { " tags " : {}},
... " $reduce " : function (doc, prev) {
... for (i in doc.tags) {
... if (doc.tags[i] in prev.tags) {
... prev.tags[doc.tags[i]] ++ ;
... } else {
... prev.tags[doc.tags[i]] = 1 ;
... }
... }
... }})
返回的結果是下邊這個樣子
{ " day " : " 2010/01/12 " , " tags " : { " nosql " : 4 , " winter " : 10 , " sledding " : 2 }},
{ " day " : " 2010/01/13 " , " tags " : { " soda " : 5 , " php " : 2 }},
{ " day " : " 2010/01/14 " , " tags " : { " python " : 6 , " winter " : 4 , " nosql " : 15 }}
]
實際上我們需要的只是值最大的那個標簽,并不需要將整個tags返回給客戶端,這就是group命令里可選的鍵"finalize"存在的原因。 finalize指定一個函數,在結果返回給客戶端之前,每個分組都會執行一次這個函數。我們使用finalize來去掉不需要的部分。
... " ns " : " posts " ,
... " key " : { " tags " : true },
... " initial " : { " tags " : {}},
... " $reduce " : function (doc, prev) {
... for (i in doc.tags) {
... if (doc.tags[i] in prev.tags) {
... prev.tags[doc.tags[i]] ++ ;
... } else {
... prev.tags[doc.tags[i]] = 1 ;
... }
... },
... " finalize " : function (prev) {
... var mostPopular = 0 ;
... for (i in prev.tags) {
... if (prev.tags[i] > mostPopular) {
... prev.tag = i;
... mostPopular = prev.tags[i];
... }
... }
... delete prev.tags
... }}})
使用函數作為分組key
有些情況下,你可能需要更復雜的分組規則,不是一個簡單的key,那么你就可以用"$keyf"來定義一個分組函數。
... " $keyf " : function (x) { return x.category.toLowerCase(); },
... " initializer " : ... })
4.MapReduce
MapReduce可是聚合工具里的高級武器,其他工具能做的它能做,其他工具做不了的它也能做。MapReduce是一個在多個服務器間可以并行執行的聚合方法,它將問題分割成多個塊,發送給不同的機器,讓每個機器解決自己的部分,當所有的機器都完成之后,把所有的結果都合并起來。(ps:這說的貌似 MapReduce最原初的概念,感覺跟我們下邊的內容關系不大)
MapReduce分兩步完成,第一步是映射(Map),將document里的鍵值投射為一組其他的鍵值對,第二步是精簡(Reduce),將投射出來的鍵值對按照鍵合并,每個鍵最后只有一個值。(ps:這是我的理解,書上寫的太拗口)
使用MapReduce的代價是速度,group的速度就不咋地,MapReduce更慢,所以一般都是作為后臺任務執行,完成之后對其結果collection進行查詢。
例子1:找出collection里所有的key
使用MapReduce解決這個問題確實是殺雞用牛刀,我們主要是看看MapReduce是如何工作的。MongoDB是無結構的,所以它不會跟蹤 document里都有哪些key,我們在這個示例里對collection里的每個key的使用次數進行計數,不包括嵌入的document的key。
第一步,映射(Map)使用一個特殊的函數來返回值,這些值后邊接下來處理,這個特殊函數就是emit。emit給MapReduce一個key和一個 value,我們這個例子里,我們將document的每個key投射為一個記錄其出現次數的數量{count : 1},因為我們要分別記錄每個key的出現次數,所以就需要對每個key調用emit函數。
... for ( var key in this ) {
... emit(key, {count : 1 });
... }};
現在我們就有了很多的{count : 1},每個都和collection里的一個key關聯,相同key的這些{count : 1}構成一個數組被傳遞給reduce函數,reduce函數有兩個參數,第一個是key,就是emit的第一個參數,第二個是數組,包含了被投射在這個 key上的所有{count : 1} 。
... total = 0 ;
... for ( var i in emits) {
... total += emits[i].count;
... }
... return { " count " : total};
... }
對來自映射階段或者前邊的reduce階段的結果,reduce函數必須能夠對其重復調用,所以reduce返回的document必須能夠重新傳遞給reduce函數(作為第二個參數)。
MapReduce函數的調用結果如下:
{
" result " : " tmp.mr.mapreduce_1266787811_1 " ,
" timeMillis " : 12 ,
" counts " : {
" input " : 6
" emit " : 14
" output " : 5
},
" ok " : true
}
- "result" : "tmp.mr.mapreduce_1266787811_1"
存儲MapReduce結果的collection的名字,這是個臨時的collection,連接關閉后即被刪除。我們可以指定一個好聽點的名字,并將這個collection永久保存,稍后會講到。 - "timeMillis" : 12
操作花費的時間,單位毫秒 - "counts" : { ... }
"input" : 6 傳遞給map函數的document數量
"emit" : 14 map函數中調用emit函數的次數
"output" : 5 結果集collection中document的數量
對結果集collection執行查詢就可以看到所有的key和出現次數了
{ " _id " : " _id " , " value " : { " count " : 6 } }
{ " _id " : " a " , " value " : { " count " : 4 } }
{ " _id " : " b " , " value " : { " count " : 2 } }
{ " _id " : " x " , " value " : { " count " : 1 } }
{ " _id " : " y " , " value " : { " count " : 1 } }
例子2:對網頁分類
假設我們有個網站,用戶可以提交通向其他頁面的鏈接,用戶可以給鏈接添加一些標簽標明這個鏈接和特定的主題關聯,如"政治","極客”,"icanhascheezburger"等。(ps:icanhascheezburger是個網站,主題內容是些搞笑的貓咪圖片,配些文字說明)我們可以用MapReduce找出那些主題是最近流行的。
首先,我們需要一個map函數,根據流行程度和最新程度將標簽投射為一個值。
for ( var i in this .tags) {
var recency = 1 / (new Date() - this.date);
var score = recency * this .score;
emit( this .tags[i], { " urls " : [ this .url], " score " : score});
}
};
然后,我們將投射到每個標簽的值精簡為一個值
var total = {urls : [], score : 0 }
for ( var i in emits) {
emits[i].urls.forEach( function (url) {
total.urls.push(url);
}
total.score += emits[i].score;
}
return total;
};
這樣,結果集里就包含了每個標簽的一個url列表和一個標識其流行度的總得分。
ps:
我們和關系數據庫比較一下更容易看到它的關鍵之處,關鍵之處就在于emit函數的第一個參數,sql中使用"group by 字段"進行分組,字段的每個不同值就是一個組,而MapReduce中是使用emit為每個字段不同值創建一個key和一個值的數組。簡單說,sql的 group by用的是字段名,emit用的是字段的值。明顯MapReduce更加靈活強大一些。
mongoDB和MapReduce
使用MapReduce命令,除了指定mapreduce,map,reduce這三個必須的鍵之外,還有很多其他的可選的鍵。
- "finalize" : function
終結器函數,接受reduce的輸出 - "keeptemp" : boolean
連接關閉后是否保存臨時結果集collection - "output" : string
輸出collection的名字,使用此選項意味著keeptemp為true - "query" : document
查詢條件,過濾傳遞給map函數的document - "sort" : document
發送給map函數前對document進行排序,經常是和limit聯用 - "limit" : integer
發送給map函數的document的最大數量 - "scope" : document
在javascript代碼中可以使用的變量 - "verbose" : boolean
是否輸出更詳細的服務器日志
使用scope
如果在MapReduce中使用客戶端的值,那就必須使用scope選項了。你只需要傳遞給scope一個 變量名:值 格式的document就可以了,然后這個值在map,reduce以及finalize函數中就可以使用了。這個變量的值在各個函數中是只讀的。
比如,剛才我們第二個例子中計算頁面的最新性時使用的是1/(new Date() - this.date),如果我們想不使用new Date(),而是把當前日期傳遞進來的話,就可以定義個叫now的變量
" scope " : {now : new Date()}})
然后在map函數里就可以用1/(now - this.date)了。