A. Jesse Jiryu Davis —— 10gen工程師,從事MongoDB、Python及Tornado。在Dzone上分享了MongoDB中組合索引的最佳建立方法以及索引中字段的最優順序。並通過explain()輸出的結果來驗證實際性能,同時還分析了MongoDB的查詢優化器的索引選擇機制。
項目背景
預想中的項目是在MongoDB上建立一個類Disqus的評論系統(雖然Disqus使用的是Postgres,但是不影響我們討論)。這裏儲存的評論可能是上萬條,但是我們先從簡單的4條談起。每條評論都擁有時間戳(timestamp)、匿名(發送)與否(anonymous)以及質量評價(rating)這三個屬性:
- { timestamp: 1, anonymous: false, rating: 3 }
- { timestamp: 2, anonymous: false, rating: 5 }
- { timestamp: 3, anonymous: true, rating: 1 }
- { timestamp: 4, anonymous: false, rating: 2 }
這裏需要查詢的是anonymous = false而且timestamp在2 – 4之間的評論,查詢結果通過rating進行排序。我們將分3步完成查詢的優化並且通過MongoDB的explain()對索引進行考量。
範圍查詢
首先從簡單的查詢開始 —— timestamps範圍在2-4的評論:
- > db.comments.find( { timestamp: { $gte: 2, $lte: 4 } } )
查詢的結果很顯然是3條。然而這裏的重點是通過explain()看MongoDB是如何去實現查詢的:
- > db.comments.find( { timestamp: { $gte: 2, $lte: 4 } } ).explain()
- {
- "cursor" : "BasicCursor",
- "n" : 3,
- "nscannedObjects" : 4,
- "nscanned" : 4,
- "scanAndOrder" : false
- // ... snipped output ...
- }
先看一下如何讀MongoDB的查詢計劃:首先看cursor的類型。“BasicCursor”可以稱得上一個警告標誌:它意味着MongoDB將對數據集做一個完全的掃描。當數據集裏包含上千萬條信息時,這完全是行不通的。所以這裏需要在timestamp上加一個索引:
- > db.comments.createIndex( { timestamp: 1 } )
現在再看explain()的輸出結果:
- > db.comments.find( { timestamp: { $gte: 2, $lte: 4 } } ).explain()
- {
- "cursor" : "BtreeCursor timestamp_1",
- "n" : 3,
- "nscannedObjects" : 3,
- "nscanned" : 3,
- "scanAndOrder" : false
- }
現在cursor的類型明顯變成了“BtreeCuor timestamp_1”(timestamp_1爲之前定義的索引名稱)。nscanned從4降到了3,因爲這裏Mongo使用了索引跳過了範圍外的文檔直接指向了需要查詢的文檔。
對於定義了索引的查詢:nscanned體現了Mongo掃描字段索引的條數,而nscannedObjects則爲最終結果中查詢過的文檔數目。n則表示了返回文檔的數目。nscannedObjects至少包含了所有的返回文檔,即使Mongo明確了可以通過查看絕對匹配文件的索引。因此可以得出nscanned >= nscannedObjects >= n。對於簡單查詢你可能期望3個數字是相等的。這意味着你做出了MongoDB使用的完美索引。
範圍查詢的基礎上添加等值查詢
然而什麼情況下nscanned會大於n ?很顯然當Mongo需要檢驗一些指向不匹配查詢的文檔的字段索引。舉個例子,我需要過濾出anonymous = true的文檔:
- > db.comments.find(
- ... { timestamp: { $gte: 2, $lte: 4 }, anonymous: false }
- ... ).explain()
- {
- "cursor" : "BtreeCursor timestamp_1",
- "n" : 2,
- "nscannedObjects" : 3,
- "nscanned" : 3,
- "scanAndOrder" : false
- }
從explain()輸出結果上來看:雖然n從3降到了2,但是nscanned和nscannedObjects的值仍然爲3。Mongo掃描了timestamp從2到4的索引,這就包含了anonymous = true/false的所有情況。在文件檢查完之前,更不會去濾掉下一個。
那麼如何才能回到完美的nscanned = nscannedObjects = n 上來?這裏嘗試一個在timestamp和anonymous上的組合索引:
- > db.comments.createIndex( { timestamp:1, anonymous:1 } )
- > db.comments.find(
- ... { timestamp: { $gte: 2, $lte: 4 }, anonymous: false }
- ... ).explain()
- {
- "cursor" : "BtreeCursor timestamp_1_anonymous_1",
- "n" : 2,
- "nscannedObjects" : 2,
- "nscanned" : 3,
- "scanAndOrder" : false
- }
這次的情況好了一點:nscannedObjects從3降到了2。但是nscanned仍然爲3!Mongo還是做了timestamp 2到4上索引的全掃描。當然當檢查anonymous索引發現其值爲true時,Mongo選擇了直接跳過而沒有進行文檔掃描。因此這也是爲什麼只有nscanned的值仍爲2的原因。
那麼是否可以改善這個情況讓nscanned也降到2?你可能已經注意到這點了:定義索引的次序存在問題。是的,這裏應該是“anonymous,timestamp”而不是“timestamp,anonymous”:
- > db.comments.createIndex( { anonymous:1, timestamp:1 } )
- > db.comments.find(
- ... { timestamp: { $gte: 2, $lte: 4 }, anonymous: false }
- ... ).explain()
- {
- "cursor" : "BtreeCursor anonymous_1_timestamp_1",
- "n" : 2,
- "nscannedObjects" : 2,
- "nscanned" : 2,
- "scanAndOrder" : false
- }
對於MongoDB組合索引的關鍵字順序問題和其他數據庫都是一樣的。假如使用anonymous作爲索引的第一個關鍵字,Mongo則會直接調至anonymous = false文檔做timestamp 2到4的範圍掃描。
這裏結束了探索的第一部分,簡單的瞭解了一下MongoDB組合索引的優化思想。然而事實上這種情況只存在於理想之中。
不防設想一下索引中包含“anonymous”是否物有所值。打個比方:我們現在的系統擁上千萬條的評論並且天查詢量也上千萬,那麼縮減nscanned必將大幅度的提升系統的吞吐量。但是如果anonymous部分在索引中很少用到,那麼顯而易見的可以把它從索引中剔除爲經常用到的字段節省空間。另一方面:雙字段索引肯定比單字段索引佔更多的內存,因此單字段的索引在內存的開銷上無疑也是更勝一籌。而在這裏的情況就是:只有anounymous = true佔很大比重的時候纔會在全方面中得利。既然要全面考慮,那麼我們還必須看一下MongoDB索引的選擇機制。
MongoDB的索引選擇機制
首先來看一個比較有趣的事情:在先前的例子中我們並沒有刪除索引,這樣的話在我們建立的3個索引中MongoDB總是會擇優而取。爲什麼會出現這種情況?
MongoDB的優化程序會在對比中選擇更優秀的索引。首先,它會給查詢做一個初步的“最佳索引”;其次,假如這個最佳索引不存在它會做嘗試來選出表現最好的索引;最後優化器還會記住所有類似查詢的選擇(只到大規模文件變動或者索引上的變動)。
那麼優化器是如何定義查詢的“最佳索引”。最佳索引必須包含查詢中所有可以做過濾及需要排序的字段。此外任何用於範圍掃描的字段以及排序字段都必須排在做等值查詢的字段之後。如果存在不同的最佳索引,那麼Mongo將隨機選擇。在這個例子中“anonymous,timestamp”明顯是最佳索引,所以很迅速的就做出了選擇。
鑑於這樣表述很蒼白,下面來詳細的看一下第二部分是如何工作的。當優化器需要在一堆沒有特別優勢的索引中選擇一個時,它會收集所有相關的索引進行相關的查詢,並選出最先完成的索引。
舉個例子下面是個查詢語句:
- db.comments.find({ timestamp: { $gte: 2, $lte: 4 }, anonymous: false })
全部的3個索引都是相關的,所以MongoDB將3條索引以任意的順序連接起來並標註了每條索引依次進入的入口:
所有索性都返回瞭如下結果:
- { timestamp: 2, anonymous: false, rating: 5 }
首先。在第二步,左邊和中間的索引都返回了:
- { timestamp: 3, anonymous: true, rating: 1 }
而右邊的索引明顯勝於其他的兩條索引:
- { timestamp: 4, anonymous: false, rating: 2 }
在這個競賽中,在右方的索引明顯比其他的兩個先完成查詢。那麼在下一次比賽前,它會一直作爲最佳索引存在。簡而言之:存在多條索引的情況下,MongoDB首選nscanned值最低的索引。
等值、範圍查詢及排序
既然我們擁有了timestamps在2到4之間的完美索引,那麼我們的最後一步是進行排序。先從降序開始:
- > db.comments.find(
- ... { timestamp: { $gte: 2, $lte: 4 }, anonymous: false }
- ... ).sort( { rating: -1 } ).explain()
- {
- "cursor" : "BtreeCursor anonymous_1_timestamp_1",
- "n" : 2,
- "nscannedObjects" : 2,
- "nscanned" : 2,
- "scanAndOrder" : true
- }
在之前通常都是這麼做的,現在同樣很好:nscanned = nscannedObjects = n。但是千萬別忽略這條:scanAndOrder = true。這就意味着MongoDB會把所有查詢出來的結果放進內存,然後進行排序,接着一次性輸出結果。然而我們必須考慮:這將佔用服務器大量的CPU和RAM。取代將結果分批次的輸出,Mongo把他們全部放進內存並一起輸出將大量爭用應用程序服務器的資源。最終Mongo會強行給數據做一個32MB的限制,然後在內存裏給他們排序。雖然我們現在討論中只有4條評論,但是我們設計的是上千萬條的系統!
那這裏該如何處理scanAndOrder = true這個情況?我們需要加一個索引,讓Mongo可以直接轉到anonyous = false部分,並且要求的順序掃描這個部分:
- > db.comments.createIndex( { anonymous: 1, rating: 1 } )
Mongo會使用這個索引嗎?當然不會,因爲這條索引在比賽中贏不了擁有最小nscanned的索引。優化器無法識別哪條索引會有益於排序。
所以需要使用hint來強制Mongo的選擇:
- > db.comments.find(
- ... { timestamp: { $gte: 2, $lte: 4 }, anonymous: false }
- ... ).sort( { rating: -1 }
- ... ).hint( { anonymous: 1, rating: 1 } ).explain()
- {
- "cursor" : "BtreeCursor anonymous_1_rating_1 reverse",
- "n" : 2,
- "nscannedObjects" : 3,
- "nscanned" : 3,
- "scanAndOrder" : false
- }
語句hint中存在爭議和CreateIndex是差不多的。現在nscanned = 3但是scanAndOrder = false。現在Mongo將反過來查詢“anonymous,rating”索引,獲得擁有正確順序的評論,然後再檢查每個文件的timestamp是否在範圍內。
這也是優化器爲什麼不會選擇這條索引的而去執行這個擁有低nscanned但是完全在內存排序的舊“anonymous,timestamp”索引的原因。
我們以犧牲nscanned的代價解決了scanAndOrder = true的問題;既然nscanned已不可減少,那麼我們是否可以減少nscannedObjects?我們向索引中添加timestamp,這樣一來Mongo就不用去從每個文件中獲取了:
- > db.comments.createIndex( { anonymous: 1, rating: 1, timestamp: 1 } )
同樣優化器不會贊成這條索引我們必須hint它:
- > db.comments.find(
- ... { timestamp: { $gte: 2, $lte: 4 }, anonymous: false }
- ... ).sort( { rating: -1 }
- ... ).hint( { anonymous: 1, rating: 1, timestamp: 1 } ).explain()
- {
- "cursor" : "BtreeCursor anonymous_1_rating_1_timestamp_1 reverse",
- "n" : 2,
- "nscannedObjects" : 2,
- "nscanned" : 3,
- "scanAndOrder" : false,
- }
終於盡善盡美了。Mongo遵循了類似之前的計劃,並且nscannedObjects也降到了2。
當然必須得考慮給索引加入timestamp是否是值得的,因爲timestamp給內存帶來的附加空間可能會讓你得不償失。
最終方案
最後綜合一下給出包含了等值測試、排序及範圍過濾查詢的索引建立方法:
1. 等值測試
在索引中加入所有需要做等值測試的字段,任意順序。
2. 排序字段(多排序字段的升/降序問題 )
根據查詢的順序有序的向索引中添加字段。
3. 範圍過濾
以字段的基數(Collection中字段的不同值的數量)從低到高的向索引中添加範圍過濾字段。
當然這裏還有一個規則:如果索引中的等值或者範圍查詢字段不能過濾出Collection中90%以上的文檔,那麼把它移除索引估計會更好一些。並且如果你在一個Collection上有多個索引,那麼必須hint Mongos。
對於組合索引的建立,有很多的因素去決定。雖然本文不能讓你直接確定出一個最優的索引,但是無疑可以讓你縮小索引建立時的選擇。
原文鏈接:Optimizing MongoDB Compound Indexes (編譯/仲浩 審校/王旭東)
如果你想感受大數據的魅力,11月30日-12月1日,北京新雲南皇冠假日酒店,業內將迎來國內大數據領域最純粹的技術盛會——HBTC 2012(Hadoop&BigData Technology Conference 2012)。Hadoop及雲計算生態系統的力量齊聚北京,歡迎熱愛開源的朋友們加入!