MongoDB索引優化詳解

weixin_34185364發表於2019-02-23

索引基礎知識

什麼是索引

索引最常用的比喻就是書籍的目錄,查詢索引就像查詢一本書的目錄。本質上目錄是將書中一小部分內容資訊(比如題目)和內容的位置資訊(頁碼)共同構成,而由於資訊量小(只有題目),所以我們可以很快找到我們想要的資訊片段,再根據頁碼找到相應的內容。同樣索引也是隻保留某個域的一部分資訊(建立了索引的field的資訊),以及對應的文件的位置資訊。
假設我們有如下文件(每行的資料在MongoDB中是存在於一個Document當中)

姓名 id 部門 city score
張三 2 xxx Beijing 90
李四 1 xxx Shanghai 70
王五 3 xxx guangzhou 60

假如我們想找id為2的document(即張三的記錄),如果沒有索引,我們就需要掃描整個資料表,然後找出所有為2的document。當資料表中有大量documents的時候,這個時間就會非常長(從磁碟上查詢資料還涉及大量的IO操作)。建立索引後會有什麼變化呢?MongoDB會將id資料拿出來建立索引資料,如下

索引值 位置
1 pos2
2 pos1
3 pos3

這樣我們就可以通過掃描這個小表找到document對應的位置。

查詢過程示意圖如下:


3959253-7e2a31d0b5301c9f.png
圖片來源MongoDB官網

為什麼這樣速度會快呢?這主要有幾方面的因素

  1. 索引資料通過B+樹來儲存,從而使得搜尋的時間複雜度為O(logdN)級別的(d是B+樹的度, 通常d的值比較大,比如大於100),比原先O(N)的複雜度大幅下降。這個差距是驚人的,以一個實際例子來看,假設d=100,N=1億,那麼O(logdN) = 8, 而O(N)是1億。是的,這就是演算法的威力。
  2. 索引本身是在快取記憶體當中,相比磁碟IO操作會有大幅的效能提升。(需要注意的是,有的時候資料量非常大的時候,索引資料也會非常大,當大到超出記憶體容量的時候,會導致部分索引資料儲存在磁碟上,這會導致磁碟IO的開銷大幅增加,從而影響效能,所以務必要保證有足夠的記憶體能容下所有的索引資料)

當然,事物總有其兩面性,在提升查詢速度的同時,由於要建立索引,所以寫入操作時就需要額外的新增索引的操作,這必然會影響寫入的效能,所以當有大量寫操作而讀操作比較少的時候,且對讀操作效能不需要考慮的時候,就不適合建立索引。當然,目前大多數網聯網應用都是讀操作遠大於寫操作,因此建立索引很多時候是非常划算和必要的操作。

關於索引原理的詳細解釋可以參考文章MySQL索引背後的資料結構及演算法原理,雖然講得是MySQL但是原理相似。

MongoDB有哪些型別的索引

單欄位索引 (Single Field Index)

這個是最簡單最常用的索引型別,比如我們上邊的例子,為id建立一個單獨的索引就是此種型別。

 # 為id field建立索引,1表示升序,-1表示降序,沒有差別
db.employee.createIndex({'id': 1})

需要注意的是通常MongoDB會自動為我們的文件插入'_id' field,且已經按照升序進行索引,如果我們插入的文件中包含有'_id' field,則MongoDB就不會自動建立'_id' field,但是需要我們自己來保證唯一性從而唯一標識一個文件

複合索引 (Compound Index)

符合索引的原理如下圖所示:


3959253-6b00f39c08c49406.png
複合索引示意圖

上圖查詢索引的時候會先查詢userid,再查詢score,然後就可以找到對應的文件。
對於複合索引需要注意以下幾點:

索引field的先後順序很關鍵,影響有兩方面:

  1. MongoDB在複合索引中是根據prefix排序查詢,就是說排在前面的可以單獨使用。我們建立一個如下的索引
db.collection.createIndex({'id': 1, 'city': 1, 'score': 1}) 

我們如下的查詢可以利用索引

db.collection.find({'id': xxx})
db.collection.find({'id': xxx, 'city': xxx})
db.collection.find({'id': xxx, 'city':xxx, 'score': xxxx})

但是如下的查詢無法利用該索引

db.collection.find({'city': xxx})
db.collection.find({'city':xxx, 'score': xxxx})

還有一種特殊的情況,就是如下查詢:

db.collection.find({'id': xxx, 'score': xxxx})

這個查詢也可以利用索引的字首'id'來查詢,但是卻不能針對score進行查詢,你可以說是部分利用了索引,因此其效率可能不如如下索引:

db.collection.createIndex({'id': 1, 'score': 1}) 

2.過濾出的document越少的field越應該放在前面,比如此例中id如果是唯一的,那麼就應該放在最前面,因為這樣通過id就可以鎖定唯一一個文件。而如果通過city或者score過濾完成後還是會有大量文件,這就會影響最終的效能。

索引的排序順序不同

複合索引最末尾的field,其排序順序不同對於MongoDB的查詢排序操作是有影響的。
比如:

db.events.createIndex( { username: 1, date: -1 } )

這種情況下, 如下的query可以利用索引:

db.events.find().sort( { username: 1, date: -1 } )

但是如下query則無法利用index進行排序

db.events.find().sort( { username: 1, date: 1 } )

多key索引 (Multikey Index)

這個主要是針對資料型別為陣列的型別,如下示例:

{"name" : "jack", "age" : 19, habbit: ["football, runnning"]}
db.person.createIndex( {habbit: 1} )  // 自動建立多key索引
db.person.find( {habbit: "football"} )

其它型別索引

另外,MongoDB中還有其它如雜湊索引,地理位置索引以及文字索引,主要用於一些特定場景,具體可以參考官網,在此不再詳解

索引屬性

索引主要有以下幾個屬性:

  • unique:這個非常常用,用於限制索引的field是否具有唯一性屬性,即保證該field的值唯一
  • partial:很有用,在索引的時候只針對符合特定條件的文件來建立索引,如下
db.restaurants.createIndex(
   { cuisine: 1, name: 1 },
   { partialFilterExpression: { rating: { $gt: 5 } } } //只有當rating大於5時才會建立索引
)

這樣做的好處是,我們可以只為部分資料建立索引,從而可以減少索引資料的量,除節省空間外,其檢索效能也會因為較少的資料量而得到提升。

  • sparse:可以認為是partial索引的一種特殊情況,由於MongoDB3.2之後已經支援partial屬性,所以建議直接使用partial屬性。
  • TTL。 可以用於設定文件有效期,有效期到自動刪除對應的文件。

通過explain結果來分析效能

我們往往會通過打點資料來分析業務的效能瓶頸,這時,我們會發現很多瓶頸都是出現在資料庫相關的操作上,這時由於資料庫的查詢和存取都涉及大量的IO操作,而且有時由於使用不當,會導致IO操作的大幅度增長,從而導致了產生效能問題。而MongoDB提供了一個explain工具來用於分析資料庫的操作。直接拿官網的示例來做說明:

假設我們在inventory collection中有如下文件:

{ "_id" : 1, "item" : "f1", type: "food", quantity: 500 }
{ "_id" : 2, "item" : "f2", type: "food", quantity: 100 }
{ "_id" : 3, "item" : "p1", type: "paper", quantity: 200 }
{ "_id" : 4, "item" : "p2", type: "paper", quantity: 150 }
{ "_id" : 5, "item" : "f3", type: "food", quantity: 300 }
{ "_id" : 6, "item" : "t1", type: "toys", quantity: 500 }
{ "_id" : 7, "item" : "a1", type: "apparel", quantity: 250 }
{ "_id" : 8, "item" : "a2", type: "apparel", quantity: 400 }
{ "_id" : 9, "item" : "t2", type: "toys", quantity: 50 }
{ "_id" : 10, "item" : "f4", type: "food", quantity: 75 }

假設此時沒有建立索引,做如下查詢:

db.inventory.find( { quantity: { $gte: 100, $lte: 200 } } )

返回結果如下:

{ "_id" : 2, "item" : "f2", "type" : "food", "quantity" : 100 }
{ "_id" : 3, "item" : "p1", "type" : "paper", "quantity" : 200 }
{ "_id" : 4, "item" : "p2", "type" : "paper", "quantity" : 150 }

這是我們可以通過explain來分析整個查詢的過程:

# explain 有三種模式: "queryPlanner", "executionStats", and "allPlansExecution".
# 其中最常用的就是第二種"executionStats",它會返回具體執行的時候的統計資料
db.inventory.find(
   { quantity: { $gte: 100, $lte: 200 } }
).explain("executionStats")

explain的結果如下:

{
   "queryPlanner" : {
         "plannerVersion" : 1,
         ...
         "winningPlan" : {
            "stage" : "COLLSCAN",
            ...
         }
   },
   "executionStats" : {
      "executionSuccess" : true,
      "nReturned" : 3,  # 查詢返回的document數量
      "executionTimeMillis" : 0, # 執行查詢所用的時間
      "totalKeysExamined" : 0, # 總共查詢了多少個key,由於沒有使用索引,因此這裡為0
      "totalDocsExamined" : 10, # 總共在磁碟查詢了多少個document,由於是全表掃描,我們總共有10個documents,因此,這裡為10
      "executionStages" : {
         "stage" : "COLLSCAN",  # 注意這裡,"COLLSCAN"意味著全表掃描
         ...
      },
      ...
   },
   ...
}

上面的結果中有一個"stage"欄位,上例中stage為"COLLSCAN",而MongoDB總共有如下幾種stage:

  • COLLSCAN – Collection scan
  • IXSCAN – Scan of data in index keys
  • FETCH – Retrieving documents
  • SHARD_MERGE – Merging results from shards
  • SORT – Explicit sort rather than using index order

現在我們來建立一個索引:

db.inventory.createIndex( { quantity: 1 } )

再來看下explain的結果

db.inventory.find(
   { quantity: { $gte: 100, $lte: 200 } }
).explain("executionStats")

結果如下:

{
   "queryPlanner" : {
         "plannerVersion" : 1,
         ...
         "winningPlan" : {
               "stage" : "FETCH",
               "inputStage" : {
                  "stage" : "IXSCAN",  # 這裡"IXSCAN"意味著索引掃描
                  "keyPattern" : {
                     "quantity" : 1
                  },
                  ...
               }
         },
         "rejectedPlans" : [ ]
   },
   "executionStats" : {
         "executionSuccess" : true,
         "nReturned" : 3,
         "executionTimeMillis" : 0,
         "totalKeysExamined" : 3,  # 這裡nReturned、totalKeysExamined和totalDocsExamined相等說明索引沒有問題,因為我們通過索引快速查詢到了三個文件,且從磁碟上也是去取這三個文件,並返回三個文件。
         "totalDocsExamined" : 3,
         "executionStages" : {
            ...
         },
         ...
   },
   ...
}

再來看下如何通過explain來比較compound index的效能,之前我們在介紹複合索引的時候已經說過field的順序會影響查詢的效率。有時這種順序並不太好確定(比如field的值都不是unique的),那麼怎麼判斷哪種順序的複合索引的效率高呢,這就像需要explain結合hint來進行分析。
比如我們要做如下查詢:

db.inventory.find( {
   quantity: {
      $gte: 100, $lte: 300
   },
   type: "food"
} )

會返回如下文件:

{ "_id" : 2, "item" : "f2", "type" : "food", "quantity" : 100 }
{ "_id" : 5, "item" : "f3", "type" : "food", "quantity" : 300 }

現在我們要比較如下兩種複合索引

db.inventory.createIndex( { quantity: 1, type: 1 } )
db.inventory.createIndex( { type: 1, quantity: 1 } )

分析索引 { quantity: 1, type: 1 }的情況

# 結合hint和explain來進行分析
db.inventory.find(
   { quantity: { $gte: 100, $lte: 300 }, type: "food" }
).hint({ quantity: 1, type: 1 }).explain("executionStats") # 這裡使用hint會強制資料庫使用索引 { quantity: 1, type: 1 }

explain結果

{
   "queryPlanner" : {
      ...
      "winningPlan" : {
         "stage" : "FETCH",
         "inputStage" : {
            "stage" : "IXSCAN",
            "keyPattern" : {
               "quantity" : 1,
               "type" : 1
            },
            ...
            }
         }
      },
      "rejectedPlans" : [ ]
   },
   "executionStats" : {
      "executionSuccess" : true,
      "nReturned" : 2,
      "executionTimeMillis" : 0,
      "totalKeysExamined" : 5,  # 這裡是5與totalDocsExamined、nReturned都不相等
      "totalDocsExamined" : 2,
      "executionStages" : {
      ...
      }
   },
   ...
}

再來看下索引 { type: 1, quantity: 1 } 的分析

db.inventory.find(
   { quantity: { $gte: 100, $lte: 300 }, type: "food" }
).hint({ type: 1, quantity: 1 }).explain("executionStats")

結果如下:

{
   "queryPlanner" : {
      ...
      "winningPlan" : {
         "stage" : "FETCH",
         "inputStage" : {
            "stage" : "IXSCAN",
            "keyPattern" : {
               "type" : 1,
               "quantity" : 1
            },
            ...
         }
      },
      "rejectedPlans" : [ ]
   },
   "executionStats" : {
      "executionSuccess" : true,
      "nReturned" : 2,
      "executionTimeMillis" : 0,
      "totalKeysExamined" : 2, # 這裡是2,與totalDocsExamined、nReturned相同
      "totalDocsExamined" : 2,
      "executionStages" : {
         ...
      }
   },
   ...
}

可以看出後一種索引的totalKeysExamined返回是2,相比前一種索引的5,顯然更有效率。

References

相關文章