上篇我們學習了mongoDB的文檔相關操作,了解了mongo的查詢機制,以及支持的幾種常見查詢方式,本篇我們從應用的角度學習mongoDB中的索引機制
我們知道如果沒有索引,MongoDB在讀取數(shù)據(jù)時必須掃描集合中的每個文件并選取那些符合查詢條件的記錄。這種掃描全集合的查詢效率是非常低的,特別在處理大量的數(shù)據(jù)時,查詢可以要花費幾十秒甚至幾分鐘,這是任何網(wǎng)站都無法接受的,而索引通常能夠極大的提高查詢的效率,所以索引機制成為了必不可少的一部分。
索引說明
如果是熟悉關系型數(shù)據(jù)庫開發(fā),例如mysql,都知道在數(shù)據(jù)庫查詢的時候我們可以使用explain來查看數(shù)據(jù)庫語句的執(zhí)行情況, 可以看到掃描的行數(shù),索引的選擇以及預計的時間等,而在mongoDB中也存在explain函數(shù),可以幫助我們查看mongoDB索引的執(zhí)行過程,如下:
db.set.find().explain()
可以看到輸出了mongoDB的執(zhí)行計劃:
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "set.set",
"indexFilterSet" : false,
"parsedQuery" : {
},
"queryHash" : "8B3D4AB8",
"planCacheKey" : "8B3D4AB8",
"winningPlan" : {
"stage" : "COLLSCAN",
"direction" : "forward"
},
"rejectedPlans" : [ ]
},
"serverInfo" : {
"host" : "localhost.localdomain",
"port" : 27017,
"version" : "4.2.8",
"gitVersion" : "43d25964249164d76d5e04dd6cf38f6111e21f5f"
},
"ok" : 1
}
粗略一看輸出的信息很多,我們暫時只需要看幾個信息,如queryPlanner屬性代表查詢計劃的內容,serverInfo代表當前mongo客戶端的信息。如果是mongoDB3.2及以前的版本的話,我們使用explain得到的結果和當前是不一樣的,大致如下:
{
"cursor" : "BasicCursor",
"nscanned" : 102,
"nscannedObjects" : 102,
"n" : 1,
"millis" : 2,
"nYields" : 0,
"nChunkSkips" : 0,
"isMultiKey" : false,
"indexOnly" : false,
"indexBounds" : {
}
}
而這里的話,我們可以關注 nscanned 屬性,這個屬性代表當前掃描的行數(shù),而 millis 屬性則代表了這個查詢大概需要的毫秒數(shù),除此之外還有個 n 屬性,這個屬性代表著查詢返回的結果條數(shù)。了解了這些后,我們來給url上添加一個索引試試,添加索引需要使用createIndex函數(shù)來完成:
db.set.createIndex({"url":1})
這里需要注意,如果是mongoDB3.2及以前的版本,創(chuàng)建索引的函數(shù)則是ensureIndex,而在mongoDB3.2以上版本中,修改為了createIndex函數(shù),不過如果我們使用ensureIndex函數(shù)依然可以創(chuàng)建索引,只是ensureIndex函數(shù)成為了 createIndex() 的別名。
接著我們來看看 createIndex函數(shù)中的參數(shù),第一個參數(shù)是需要設置索引的鍵,而第二個參數(shù)與以往查詢函數(shù)中的1不同,這里的1則是代表了索引按照升序進行創(chuàng)建,而-1則是代表按照降序進行創(chuàng)建。
在調用了createIndex函數(shù)后,如果該集合數(shù)據(jù)較多的話,我們會發(fā)現(xiàn)此命令會阻塞一段時間,由于每個機器性能不一樣,如果我們想要查詢當前創(chuàng)建索引的進度,可以選擇在開啟一個客戶端連接窗口,使用如下方式查詢當前創(chuàng)建索引的進度,或者選擇讀取mongoDB日志的方式查看:
db.currentOp() //查看數(shù)據(jù)庫創(chuàng)建索引進度
在索引創(chuàng)建完畢后,我們再次使用url屬性進行文檔查詢和掃描操作,這個時候我們會發(fā)現(xiàn),效率明顯提升,幾乎在瞬間就返回了查詢的結果。然而,索引雖然好用,但是我們創(chuàng)建索引是需要代價的,第一索引會導致占用的磁盤增大,數(shù)據(jù)越多,索引越多,磁盤占用則越大,第二,對于每一個索引,每次進行寫操作
(插入、更新、刪除)都將耗費更多的時間,因為每次更新文檔時,還要同步更新文檔對應的索引數(shù)據(jù),因此mongoDB規(guī)定,每個集合無論復雜度如何,最多同時存在64個索引,在實踐過程中,幾乎很少會給集合創(chuàng)建超過5個索引,因此我們最好在設計集合和選擇索引組合上下一點心思。
復合索引
如果我們需要在兩個及以上的條件上進行查詢,甚至有時候可能會讓索引的鍵方向不同,例如我們需要根據(jù)count屬性從小到大,但是url則是從Z到A的順序排序,這個時候如果我們單獨給這兩個屬性設置兩個獨立的索引,查詢則不會變的很高效, 因為這兩個屬性都是按照指定的方向進行排序的,如果僅僅是查詢一個屬性,mongo的所以可以很容易的進行逆序操作,但是當多個索引列的時候就無法自動完成快速的逆序操作了,這個時候我們就需要為這幾個屬性建立多方向的復合索引。例如,我們需要按照url倒序,count正序的方式查詢,只要按照同樣的排序方式創(chuàng)建這兩個屬性的復合索引,如果需要按照url正序,count也正序的這種排序查詢方式,我們還需要按照這種方式設置一個復合索引,但是我們需要注意的是,如果只有一個條件,索引排序的順序是相反的也可以直接逆序查詢,但是如果是多個條件一起查詢,部分屬性的索引排序方向是相反的,則會無法觸發(fā)索引,因此在多條件復合查詢的時候,索引的方向顯得尤為重要。
覆蓋索引
正常情況下我們查詢需要的數(shù)據(jù),可能只有一部分,每次查詢如果都要將整個文檔都查詢出來,即使觸發(fā)了索引條件,但是通過索引鍵還會再去查找對應的文檔。如果查詢中只需要查找索引中包含的字段,這個時候就際的文檔。如果你的查詢只需要查找索引中包含的字段,那就可以使用排除鍵的方式,指定返回的文檔數(shù)據(jù)中只有需要的索引鍵的數(shù)據(jù),這樣的話,查找到索引數(shù)據(jù)后會直接返回,而不需要二次追朔文檔數(shù)據(jù)。因此在實際中,應該優(yōu)先使用覆蓋索引,而不是去獲取實際的文檔數(shù)據(jù)。這樣可以保證工作集比較
小,但是需要注意的一點是,覆蓋索引僅僅針對常規(guī)的鍵-值數(shù)據(jù)有效,如果鍵是數(shù)組數(shù)據(jù),那么無論怎么觸發(fā),都不會觸發(fā)覆蓋索引,即使我們選擇將數(shù)組數(shù)據(jù)剔除,也無法觸發(fā)覆蓋索引,因此在開發(fā)設計過程中需要格外注意特殊類型的鍵設置為索引和查詢性能平衡的問題。
隱式索引
熟悉關系型數(shù)據(jù)庫的可能都知道,在mysql中,如果給幾個鍵設置了聯(lián)合索引,除了自身的組合之外,還具有隱式索引的功能,例如,我們給url和count設置了聯(lián)合索引,除了我們在查詢的時候按照url + count條件進行查詢可以觸發(fā)索引以外,如果我們僅僅查詢url一個鍵,會發(fā)現(xiàn)依然觸發(fā)了索引機制,但是需要注意的是,AB鍵聯(lián)合索引,只有AB順序和A作為條件才能觸發(fā)索引,但是如果我們查詢順序是BA或者僅僅有B條件,是無法觸發(fā)索引的,這個就是聯(lián)合索引的隱式索引規(guī)則。
同樣的,在mongoDB中也存在類似的索引機制原則,不過除了需要鍵順序以外,還要考慮鍵的索引方向問題,并且聯(lián)合索引的性能提升理論上是大于多個鍵的獨立索引帶來的優(yōu)勢,因此,在開發(fā)設計階段,如果可以,可以盡量設計聯(lián)合索引,來帶來更多隱式索引的性能優(yōu)勢,以減少單獨鍵索引帶來的額外空間開銷。
$操作符與索引嵌套文檔
前面我們有學習mongoDB自帶的一些查詢操作符,但是需要知道的是,有部分操作符是無法利用索引機制的,會導致在大數(shù)據(jù)量下查詢緩慢,同時也是我們不推薦且不常用的操作符。
無法利用索引的操作符
如$where和$exists操作符,我們用來查詢和檢查一個鍵是否存在,假設文檔中有一個屬性X,我們來查詢不存在X鍵的文檔,一般寫法如下:
{"x":{"$exists":false}}
但是在索引中,不存在的字段和null的方式存儲的方式是一樣的,必須遍歷所有的文檔,檢查在該文檔中是否真的存在或者為null,如果是稀疏索引,使用這類操作符直接會導致報錯。除此之外,我們有時候也會使用$not和$nin操作符來取反,而取反操作符在mongoDB中效率比較低,理論上說$ne操作符查詢,還是有可能會觸發(fā)索引的,但是因為我們往往需要查看所有的索引里的數(shù)據(jù),導致很多時候索引根本不會被利用。例如下面的查詢語句:
db.set.find({"count":{"$ne":3}});
這個查詢如果換成普通的查詢,即:
db.set.find({"count":{"$gt":3,"$lt":3}})
會查找所有的大于3和小于3的索引數(shù)據(jù),如果查詢的第一個條件能過濾的數(shù)據(jù)比較多,這個時候還是會觸發(fā)索引,相對來說還是比較有效的,但是如果數(shù)據(jù)很少,那么這個時候往往不會再去觸發(fā)索引機制了,而$nin操作符則基本上不會觸發(fā)到設置的索引了。
范圍查詢/or查詢
除了無法利用索引機制的操作符以外,我們來看一組常見的可以利用索引機制的查詢--范圍查詢和or查詢,假設我們現(xiàn)在需要查詢count大于7,以及count小于15的文檔數(shù)據(jù),這個時候我們往往會利用count鍵的索引進行快速查詢,但是我們需要知道是,如果大于7篩選掉的數(shù)據(jù)比小于15篩選掉的數(shù)據(jù)集更大,我們將大于7放在查詢條件前部和放在后部,查詢效率上能差很多,這也是我們推薦,盡量把篩選數(shù)據(jù)更多的鍵放在條件前部的原因。但我們需要注意的是,如果存在多個索引的情況下,mongoDB并不會和mysql等數(shù)據(jù)庫一樣,只要按照順序的鍵都存在索引,可以連續(xù)觸發(fā)索引,在mongoDB中正常的查詢,如果存在多個鍵都有索引的情況下,mongoDB會根據(jù)執(zhí)行計劃,分析較優(yōu)的索引,選擇該索引進行數(shù)據(jù)查詢優(yōu)化!但是我們會發(fā)現(xiàn)$or操作符是個例外,使用$or操作符進行執(zhí)行計劃查看,會發(fā)現(xiàn)$or前后的鍵都可以觸發(fā)索引,但是需要注意的是$or操作符實際上是把or前后的條件拆開,分別進行一次索引查詢進行數(shù)據(jù)過濾,最后再將多次查詢的結果合并在一起,將重復的數(shù)據(jù)和不符合的數(shù)據(jù)進行剔除。了解了$or操作符的原理后,我們也能想到,這樣的查詢機制肯定會比單個索引查詢來的更慢,因此在利用$or操作符的場景下,我們可以盡可能使用例如$in操作符來避免多次索引查詢,盡可能提升查詢的效率。
嵌套文檔/嵌套數(shù)組
mongoDB允許深入文檔內部,對嵌套字段和嵌套數(shù)組上建立對應的索引,例如有如下的文檔:
{
"username" : "sid",
"lock" : {
"ip" : "117.89.135.01",
"city" : "nanjing",
"state" : "NY"
}
}
我們現(xiàn)在給lock屬性上某個字段,例如city字段設置索引,以便于我們查詢的時候進行優(yōu)化:
db.userInfo.ensureIndex({"lock.city" : 1})
不過需要注意的一點是,在嵌套文檔內部建立索引和在文檔的鍵設置索引是完全不同的,對嵌套文檔建立的索引,只有在查詢到嵌套文檔層才會觸發(fā)索引,例如:
db.userInfo.find({"lock":{"city":"nanjing"}})
而嵌套數(shù)組也可以建立索引,與之不同的是嵌套數(shù)組的索引是建立在每個元素的對應字段上的,以我們的set集合為例,其中有一個ip_array字段,這個字段里面存放了每個訪問ip的信息,現(xiàn)在我們給其中的ip字段設置索引:
db.set.ensureIndex({"ip_array.ip" : 1})
另外嵌套數(shù)組的索引,每個元素都會標記一個索引字段,因此實際上數(shù)組有多少條數(shù)據(jù),就會在索引中存在多少個條目,這樣會導致維護數(shù)組的時候成本比一般的索引要高的多,每一次的插入,修改都會重新維護索引的信息和條目順序。并且,一個文檔的單個索引中最多存在一個數(shù)組字段,為了避免在多鍵索引中索引條目爆炸性增長,每一對可能性的元素都會被索引,因此會導致假設文檔有n條數(shù)據(jù),而每個文檔中的數(shù)組會存放m個元素,因此一個集合中索引條目的實際數(shù)量是:nm 個,而不是文檔數(shù)據(jù)的n條,因此在一個索引中,最多存在一個數(shù)組索引*。
索引原則和散列基數(shù)
創(chuàng)建索引的一個關鍵性原則是索引鍵的不同值的數(shù)量和比例,比如,我們有一個集合,存儲的是用戶的信息,如果我們將gender字段設置索引列,因為性別可能只有兩種,如果用戶比較均勻的話,可能會導致散列基數(shù)接近50%,如果性別分布不均勻,男性或者女性較多,這樣就導致某一性別的用戶的散列基數(shù)低于40%,而通常一個字段上的散列基數(shù)越高,說明不一樣的數(shù)據(jù)越多,而索引就能過濾更多的數(shù)據(jù)條件,效率也就會越高。因此我們在設計索引鍵的時候,還需要考慮一下散列基數(shù)的問題,盡量在基數(shù)較大的字段設計索引。
索引類型
在創(chuàng)建索引的時候可以指定一些選項,使用不同選項建立的索引會有不同的行為。其中常見的幾種索引類型如下:
唯一索引
唯一索引可以確保集合的每一個文檔的指定鍵都有唯一值。如果想要保證整個文檔中的url的值一定是不同的,那么就可以給url創(chuàng)建一個唯一索引,如下:
db.set.ensureIndex({"url": 1}, {unique: true});
接著我們嘗試插入兩個url一樣的數(shù)據(jù),會發(fā)現(xiàn)mongo報了如下的錯誤:
db.set.insert({ "url" : "www.baidu.com", "count" : 5, "update_time" : "2020-08-13 12:00:00", "ip_array":[{ "ip":"192.168.1.3"}, {"ip":"192.168.1.4"}]});
//結果
> db.set.insert({ "url" : "www.baidu.com", "count" : 5, "update_time" : "2020-08-13 12:00:00", "ip_array" : [ { "ip" : "192.168.1.3" }, { "ip" : "192.168.1.4" } ] })
WriteResult({
"nInserted" : 0,
"writeError" : {
"code" : 11000,
"errmsg" : "E11000 duplicate key error collection: set.set index: url_1 dup key: { url: \"www.baidu.com\" }"
}
})
這個時候我們會發(fā)現(xiàn)url為www.baidu.com的數(shù)據(jù)只有第一次插入成功,除了我們自定義設置的唯一索引鍵以外,還有一個默認的唯一索引鍵,我想大概猜到了--_id鍵索引!沒錯,__id是mongo中默認給每個集合文檔設置的索引鍵,并且這個索引是無法被刪除的。
復合唯一索引
除了唯一索引以外,也可以創(chuàng)建復合的唯一索引。創(chuàng)建復合唯一索引時,單個鍵的值可以相同,但所有鍵的組合值必須是唯一的。假設現(xiàn)在我們有一個url和count聯(lián)合創(chuàng)建的索引,如下:
db.set.ensureIndex({"url": 1,"count":1}, {unique: true});
我們再次插入上述的數(shù)據(jù):
db.set.insert({ "url" : "www.baidu.com", "count" : 5, "update_time" : "2020-08-13 12:00:00", "ip_array":[{ "ip":"192.168.1.3"}, {"ip":"192.168.1.4"}]});
這個時候發(fā)現(xiàn)能夠插入成功,因為雖然url存在一樣的數(shù)據(jù),但是count不一樣,這個時候復合唯一索引就不會管控插入行為,但是我們再次插入一條一樣的數(shù)據(jù),就會發(fā)現(xiàn)報了E11000 duplicate key error collection: set.set index 錯誤。
如果在創(chuàng)建唯一索引的過程中,發(fā)現(xiàn)創(chuàng)建失敗,因為該集合的文檔中可能已經(jīng)存在鍵相同的重復數(shù)據(jù)了,那么這個時候我們需要先把重復數(shù)據(jù)清理以后再次創(chuàng)建唯一索引,但是我們在很多情況下,查找所有的重復數(shù)據(jù),并且清理一部分是很困難或者是很耗時的一件事,有木有什么辦法可以直接幫我們去重,并且建立索引呢?這個時候我們就需要使用dropDups參數(shù)了,啟用該參數(shù)會強制創(chuàng)建唯一索引,并且如果唯一索引鍵遇到重復數(shù)據(jù),會保留第一條數(shù)據(jù)。其他的數(shù)據(jù)都會被刪除,但是這里我們需要注意,刪除了哪些數(shù)據(jù)我們無法控制,因此如果數(shù)據(jù)比較重要,千萬不要使用dropDups強制創(chuàng)建唯一索引。
稀疏索引
前面我們說過唯一索引,會保整個集合中指定鍵的值不會重復,其中也包括不存在這個鍵的數(shù)據(jù),以及null的數(shù)據(jù),這類數(shù)據(jù)也是只能存在一條,因此當我們存入的數(shù)據(jù),不確定唯一索引的鍵數(shù)據(jù)是否一定存在的時候,再次插入不存在或者null的數(shù)據(jù),會導致插入失敗,這個時候我們可能想要唯一索引只對包含相應鍵的文檔生效。如果有一個可能存在也可能不存在的字段,但是當它存在時,它必須是唯一的,這時就可以將unique和sparse選項組合在一起使用,用于創(chuàng)建稀疏索引。
當然,熟悉mysql等關系型數(shù)據(jù)庫的知道在mysql中也存在稀疏索引的說法,不過mongo的稀疏索引和mysql完全不是一個概念,mongoDB中的稀疏索引只是不需要將每個文檔都作為索引條目。
沒有稀疏索引前,我們針對url字段進行查詢:
db.set.find({"count":{"$ne":2}})
返回結果為,可以看到其中沒有url字段的數(shù)據(jù)也被查詢出來了:
{ "_id" : ObjectId("5f5e6a73cf72b68c1d21c471"), "url" : "www.baidu.com", "count" : 7, "update_time" : "2020-08-13 12:00:00", "ip_array" : [ { "ip" : "192.168.1.3" }, { "ip" : "192.168.1.4" } ] }
{ "_id" : ObjectId("5f91d6f1fba71470f3d5b2a8"),"update_time" : "2020-08-13 12:00:00", "ip_array" : [ { "ip" : "192.168.1.3" }, { "ip" : "192.168.1.4" } ] }
{ "_id" : ObjectId("5f91d701fba71470f3d5b2a9"), "update_time" : "2020-08-13 12:00:00", "ip_array" : [ { "ip" : "192.168.1.3" }, { "ip" : "192.168.1.4" } ] }
這個時候我們來給url字段設置稀疏索引:
db.set.ensureIndex({"url":1},{"sparse":true})
當我們再次去查詢url不存在的數(shù)據(jù)的時候,可以看到已經(jīng)將沒有url字段的數(shù)據(jù)排除在外了:
{ "_id" : ObjectId("5f5e6a73cf72b68c1d21c471"), "url" : "www.baidu.com", "count" : 7, "update_time" : "2020-08-13 12:00:00", "ip_array" : [ { "ip" : "192.168.1.3" }, { "ip" : "192.168.1.4" } ] }
唯一稀疏索引
有時候我們需要的場景比較特殊,即,需要某個字段可以不存在,但是要求存在的話,這個字段的值是不允許重復的,如果是使用唯一性索引,那么這個字段必須存在,否則null的情況只能有一條,但是如果使用稀疏索引的話,無法保證唯一性,這個時候我們就可以選擇將兩個索引合并設計,即唯一性稀疏索引
db.set.ensureIndex({"count": 1}, {"unique": true,"sparse":true});
這個時候我們再去執(zhí)行查詢,會發(fā)現(xiàn)如果針對count字段查詢,會自動將沒有count字段的數(shù)據(jù)過濾,而我們插入數(shù)據(jù)的時候,如果count字段存在的話,會校驗唯一性,僅允許插入一條count值不存在的數(shù)據(jù)
索引管理
在mongodb中,每個集合中同樣的索引只能建立一次,重復創(chuàng)建雖然也會提示ok,但是也會提示在集合中已經(jīng)全部存在,并且需要注意的是,所有的索引信息都保存在system.indexes集合中,這是個系統(tǒng)保留集合,不可以進行任何文檔新增和刪除操作,只能通過
ensureIndex或者dropIndexes/getIndexes對其進行操作。
查看集合的索引信息
當我們創(chuàng)建了索引以后,如果我們想要查看當前集合中存在哪些索引,我們可以使用getIndexes函數(shù)查看:
db.set.getIndexes();
//輸出
[
{
"v" : 2,
"key" : {
"_id" : 1
},
"name" : "_id_",
"ns" : "set.set"
},
{
"v" : 2,
"key" : {
"url" : 1
},
"name" : "url_1",
"ns" : "set.set",
"sparse" : true
}
]
這里有幾個比較關鍵的字段,key字段代表是哪些列一起組合設置的索引,name代表是索引的名稱,v代表當前索引的版本,對索引進行改動修改等都會修改v的值,ns代表是當前索引是哪個db中的哪個集合中創(chuàng)建的,而sparse字段為true,則代表當前的索引是稀疏索引。
除了查看詳情以外,我們有時候需要知道當前索引的大小,這個時候就可以使用totalIndexSize函數(shù)來查看索引大小:
//不指定參數(shù)或者傳遞''查詢整個集合的索引大小
db.set.totalIndexSize();
//輸出
77824
//隨便指定任何值,或者{}進行查詢,會列出來當前集合中每個索引的大小以及總大小
db.set.totalIndexSize({})
//輸出
_id_ 36864
url_1 20480
url_sort 20480
77824
指定索引名稱
前面我們每次創(chuàng)建索引的時候都是指定了索引的策略,而我們查看了索引詳情知道每個索引都有一個唯一的名稱,事實上我們不指定索引名稱的情況下,mongoDB有默認的索引名稱規(guī)則,即為:
key_name1_dir1_keyname2_dir2_...
其中key_name代表每個索引列的名稱,而dir則代表當前列的索引方向,1和-1,如果我們創(chuàng)建的索引有多個索引列的情況下,這個默認的命名會比較長,不過我們可以在創(chuàng)建索引的時候指定名稱,例如:
db.set.ensureIndex({"count":1},{"name":"count_desc"});
修改/刪除索引
隨著文檔結構的變更,以及數(shù)據(jù)量的積累,我們的數(shù)據(jù)查詢方式或者條件可能會隨著產(chǎn)生變化,這個時候我們可能需要重構新的索引,這個時候我們可以選擇的做法是將原來的索引刪除以后,重新建立索引,而刪除索引有兩種方式,第一種是根據(jù)name進行刪除,還有一種是將整個集合的索引除了_id以外全部刪除:
//根據(jù)name刪除
db.set.dropIndex("url_1");
//刪除當前集合全部索引
db.set.dropIndexes();
當我們刪除索引以后可以再次創(chuàng)建對應的索引,但是由于數(shù)據(jù)集變大,創(chuàng)建索引的時候往往需要較長的時間,這個過程會阻塞,對我們使用影響較大,這個時候我們可以在創(chuàng)建索引的時候指定background選項,這樣就會在后臺默默創(chuàng)建索引,不會阻塞當前業(yè)務的執(zhí)行,如果遇到數(shù)據(jù)庫操作的時候,會先處理操作再去繼續(xù)創(chuàng)建索引,但是這種創(chuàng)建索引的方式比起直接阻塞創(chuàng)建索引會導致性能下降,而且創(chuàng)建索引的時間也會變得很長,例如:
db.set.ensureIndex({"url":1},{"unique": true,"sparse":true,"background":true})