索引在查詢中占的地位無疑是重中之重,因此建立一個好的索引對查詢性能的影響也是立竿見影。來自10gen工程師A. Jesse Jiryu
Davis帶來的MongoDB上索引的優(yōu)化方法以及MongoDB索引的選擇機制,幫助大家縮小索引的選擇空間。
A. Jesse Jiryu Davis —— 10gen工程師,從事MongoDB、Python及Tornado。在Dzone上分享了MongoDB中組合索引的最佳建立方法以及索引中字段的最優(yōu)順序。并通過explain()輸出的結(jié)果來驗證實際性能,同時還分析了MongoDB的查詢優(yōu)化器的索引選擇機制。
項目背景
預(yù)想中的項目是在MongoDB上建立一個類Disqus的評論系統(tǒng)(雖然Disqus使用的是Postgres,但是不影響我們討論)。這里儲存的評論可能是上萬條,但是我們先從簡單的4條談起。每條評論都擁有時間戳(timestamp)、匿名(發(fā)送)與否(anonymous)以及質(zhì)量評價(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之間的評論,查詢結(jié)果通過rating進行排序。我們將分3步完成查詢的優(yōu)化并且通過MongoDB的explain()對索引進行考量。
范圍查詢
首先從簡單的查詢開始 —— timestamps范圍在2-4的評論:
> db.comments.find( { timestamp: { $gte: 2, $lte: 4 } } )
查詢的結(jié)果很顯然是3條。然而這里的重點是通過explain()看MongoDB是如何去實現(xiàn)查詢的:
> db.comments.find( { timestamp: { $gte: 2, $lte: 4 } } ).explain() { "cursor" : "BasicCursor", "n" : 3, "nscannedObjects" : 4, "nscanned" : 4, "scanAndOrder" : false // ... snipped output ... }
先看一下如何讀MongoDB的查詢計劃:首先看cursor的類型?!癇asicCursor”可以稱得上一個警告標(biāo)志:它意味著MongoDB將對數(shù)據(jù)集做一個完全的掃描。當(dāng)數(shù)據(jù)集里包含上千萬條信息時,這完全是行不通的。所以這里需要在timestamp上加一個索引:
> db.comments.createIndex( { timestamp: 1 } )
現(xiàn)在再看explain()的輸出結(jié)果:
> db.comments.find( { timestamp: { $gte: 2, $lte: 4 } } ).explain() { "cursor" : "BtreeCursor timestamp_1", "n" : 3, "nscannedObjects" : 3, "nscanned" : 3, "scanAndOrder" : false }
現(xiàn)在cursor的類型明顯變成了“BtreeCuor timestamp_1”(timestamp_1為之前定義的索引名稱)。nscanned從4降到了3,因為這里Mongo使用了索引跳過了范圍外的文檔直接指向了需要查詢的文檔。
對于定義了索引的查詢:nscanned體現(xiàn)了Mongo掃描字段索引的條數(shù),而nscannedObjects則為最終結(jié)果中查詢過的文檔數(shù)目。n則表示了返回文檔的數(shù)目。nscannedObjects至少包含了所有的返回文檔,即使Mongo明確了可以通過查看絕對匹配文件的索引。因此可以得出nscanned >= nscannedObjects >= n。對于簡單查詢你可能期望3個數(shù)字是相等的。這意味著你做出了MongoDB使用的完美索引。
范圍查詢的基礎(chǔ)上添加等值查詢
然而什么情況下nscanned會大于n ?很顯然當(dāng)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()輸出結(jié)果上來看:雖然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上索引的全掃描。當(dāng)然當(dāng)檢查anonymous索引發(fā)現(xiàn)其值為true時,Mongo選擇了直接跳過而沒有進行文檔掃描。因此這也是為什么只有nscanned的值仍為2的原因。
那么是否可以改善這個情況讓nscanned也降到2?你可能已經(jīng)注意到這點了:定義索引的次序存在問題。是的,這里應(yīng)該是“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組合索引的關(guān)鍵字順序問題和其他數(shù)據(jù)庫都是一樣的。假如使用anonymous作為索引的第一個關(guān)鍵字,Mongo則會直接調(diào)至anonymous = false文檔做timestamp 2到4的范圍掃描。
這里結(jié)束了探索的第一部分,簡單的了解了一下MongoDB組合索引的優(yōu)化思想。然而事實上這種情況只存在于理想之中。
不防設(shè)想一下索引中包含“anonymous”是否物有所值。打個比方:我們現(xiàn)在的系統(tǒng)擁上千萬條的評論并且天查詢量也上千萬,那么縮減nscanned必將大幅度的提升系統(tǒng)的吞吐量。但是如果anonymous部分在索引中很少用到,那么顯而易見的可以把它從索引中剔除為經(jīng)常用到的字段節(jié)省空間。另一方面:雙字段索引肯定比單字段索引占更多的內(nèi)存,因此單字段的索引在內(nèi)存的開銷上無疑也是更勝一籌。而在這里的情況就是:只有anounymous = true占很大比重的時候才會在全方面中得利。既然要全面考慮,那么我們還必須看一下MongoDB索引的選擇機制。
MongoDB的索引選擇機制
首先來看一個比較有趣的事情:在先前的例子中我們并沒有刪除索引,這樣的話在我們建立的3個索引中MongoDB總是會擇優(yōu)而取。為什么會出現(xiàn)這種情況?
MongoDB的優(yōu)化程序會在對比中選擇更優(yōu)秀的索引。首先,它會給查詢做一個初步的“最佳索引”;其次,假如這個最佳索引不存在它會做嘗試來選出表現(xiàn)最好的索引;最后優(yōu)化器還會記住所有類似查詢的選擇(只到大規(guī)模文件變動或者索引上的變動)。
那么優(yōu)化器是如何定義查詢的“最佳索引”。最佳索引必須包含查詢中所有可以做過濾及需要排序的字段。此外任何用于范圍掃描的字段以及排序字段都必須排在做等值查詢的字段之后。如果存在不同的最佳索引,那么Mongo將隨機選擇。在這個例子中“anonymous,timestamp”明顯是最佳索引,所以很迅速的就做出了選擇。
鑒于這樣表述很蒼白,下面來詳細的看一下第二部分是如何工作的。當(dāng)優(yōu)化器需要在一堆沒有特別優(yōu)勢的索引中選擇一個時,它會收集所有相關(guān)的索引進行相關(guān)的查詢,并選出最先完成的索引。
舉個例子下面是個查詢語句:
db.comments.find({ timestamp: { $gte: 2, $lte: 4 }, anonymous: false })
全部的3個索引都是相關(guān)的,所以MongoDB將3條索引以任意的順序連接起來并標(biāo)注了每條索引依次進入的入口:
所有索性都返回了如下結(jié)果:
{ 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 }
在之前通常都是這么做的,現(xiàn)在同樣很好:nscanned = nscannedObjects = n。但是千萬別忽略這條:scanAndOrder = true。這就意味著MongoDB會把所有查詢出來的結(jié)果放進內(nèi)存,然后進行排序,接著一次性輸出結(jié)果。然而我們必須考慮:這將占用服務(wù)器大量的CPU和RAM。取代將結(jié)果分批次的輸出,Mongo把他們?nèi)糠胚M內(nèi)存并一起輸出將大量爭用應(yīng)用程序服務(wù)器的資源。最終Mongo會強行給數(shù)據(jù)做一個32MB的限制,然后在內(nèi)存里給他們排序。雖然我們現(xiàn)在討論中只有4條評論,但是我們設(shè)計的是上千萬條的系統(tǒng)!
那這里該如何處理scanAndOrder = true這個情況?我們需要加一個索引,讓Mongo可以直接轉(zhuǎn)到anonyous = false部分,并且要求的順序掃描這個部分:
> db.comments.createIndex( { anonymous: 1, rating: 1 } )
Mongo會使用這個索引嗎?當(dāng)然不會,因為這條索引在比賽中贏不了擁有最小nscanned的索引。優(yōu)化器無法識別哪條索引會有益于排序。
所以需要使用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是差不多的。現(xiàn)在nscanned = 3但是scanAndOrder = false?,F(xiàn)在Mongo將反過來查詢“anonymous,rating”索引,獲得擁有正確順序的評論,然后再檢查每個文件的timestamp是否在范圍內(nèi)。
這也是優(yōu)化器為什么不會選擇這條索引的而去執(zhí)行這個擁有低nscanned但是完全在內(nèi)存排序的舊“anonymous,timestamp”索引的原因。
我們以犧牲nscanned的代價解決了scanAndOrder = true的問題;既然nscanned已不可減少,那么我們是否可以減少nscannedObjects?我們向索引中添加timestamp,這樣一來Mongo就不用去從每個文件中獲取了:
> db.comments.createIndex( { anonymous: 1, rating: 1, timestamp: 1 } )
同樣優(yōu)化器不會贊成這條索引我們必須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。
當(dāng)然必須得考慮給索引加入timestamp是否是值得的,因為timestamp給內(nèi)存帶來的附加空間可能會讓你得不償失。
最終方案
最后綜合一下給出包含了等值測試、排序及范圍過濾查詢的索引建立方法:
等值測試
在索引中加入所有需要做等值測試的字段,任意順序。
排序字段(多排序字段的升/降序問題 )
根據(jù)查詢的順序有序的向索引中添加字段。
范圍過濾
以字段的基數(shù)(Collection中字段的不同值的數(shù)量)從低到高的向索引中添加范圍過濾字段。
當(dāng)然這里還有一個規(guī)則:如果索引中的等值或者范圍查詢字段不能過濾出Collection中90%以上的文檔,那么把它移除索引估計會更好一些。并且如果你在一個Collection上有多個索引,那么必須hint Mongos。
對于組合索引的建立,有很多的因素去決定。雖然本文不能讓你直接確定出一個最優(yōu)的索引,但是無疑可以讓你縮小索引建立時的選擇。
本文轉(zhuǎn)自:CSDN - 10gen工程師談MongoDB組合索引的優(yōu)化
聯(lián)系客服