讀古今文學網 > MongoDB實戰 > 7.2 索引實踐 >

7.2 索引實踐

瞭解了這麼多理論之後,現在來細化一下MongoDB中索引的概念。然後,我們將深入討論索引管理的一些細節。

7.2.1 索引類型

MongoDB中的所有索引底層都使用相同的數據結構,但可以有很多不同的屬性。尤其是唯一性索引、稀疏索引和多鍵索引,它們都很常用,本節會詳細介紹它們。1

1. 注意,MongoDB還支持空間索引,但因為它的用途太專業了,我會在附錄E中單獨進行說明。

1. 唯一性索引

要創建唯一性索引,設置unique選項即可:

db.users.ensureIndex({username: 1}, {unique: true})
  

唯一性索引保證了集合中所有索引項的唯一性。如果要向本書示例應用程序的用戶集合users插入一個文檔,其中的用戶名已經被索引過了,那麼插入會失敗,拋出如下異常:

E11000 duplicate key error index:
  gardening.users.$username_1 dup key : { : \"kbanker\" }
  

如果使用驅動,那麼只有在使用驅動的安全模式執行插入時才會捕獲該異常。第3章中有對此的相關討論。

如果集合上需要唯一性索引,通常在插入數據前先創建索引會比較好。提前創建索引,能在一開始就保證唯一性約束。在已經包含數據的集合上創建唯一性索引時,會有失敗的風險,因為集合裡可能已經存在重複的鍵了。存在重複鍵時,創建索引會失敗。

如果真有需要在一個已經建好的集合上創建唯一性索引,你有幾個選擇。首先是不停地重複創建唯一性索引,根據失敗消息手動刪除包含重複鍵的文檔。如果數據不重要,還可以通過dropDups選項告訴數據庫自動刪除包含重複鍵的文檔。舉個例子,如果用戶集合users裡已經有數據了,而且你並不介意刪除包含重複鍵的文檔,可以像下面這樣發起索引創建命令:

db.users.ensureIndex({username: 1}, {unique: true, dropDups: true})
  

請注意,要保留哪個重複鍵的文檔是不確定的,因此在使用時要特別小心。

2. 稀疏索引

索引默認都是密集型的。也就是說,在一個有索引的集合裡,每個文檔都會有對應的索引項,哪怕文檔中沒有被索引鍵也是如此。例如,回想一下電子商務數據模型裡的產品集合,假設你在產品屬性category_ids上構建了一個索引。現在假設有些產品沒有分配給任何分類,對於每個無分類的產品,category_ids索引中仍會存在像這樣的一個null項。可以這樣查詢null值:

db.products.find({category_ids: null})
  

在查詢缺少分類的所有產品時,查詢優化器仍然能使用category_ids上的索引定位對應產品。

但是有兩種情況使用密集型索引會不太方便。一種是希望在並非出現在集合所有文檔內的字段上增加唯一性索引時。舉例來說,你明確希望在每個產品的sku字段上增加唯一性索引。但是出於某些原因,假設產品在還未分配sku時就加入系統了。如果sku字段上有唯一性索引,而你希望插入多個沒有sku的產品,那麼第一次插入會成功,但後續插入都會失敗,因為索引裡已經存在一個skunull的項了。這種情況下密集型索引並不適合,你所需要的是稀疏索引(sparse index)。

在稀疏索引裡,只會出現被索引鍵有值的文檔。如果想創建稀疏索引,指定{sparse: true}就可以了。例如,可以像下面這樣在sku上創建一個唯一性稀疏索引:

db.products.ensureIndex({sku: 1}, {unique: true, sparse: true})
  

另一種適用稀疏索引的情況:集合中大量文檔都不包含被索引鍵。例如,假設允許對電子商務網站進行匿名評論。這種情況下,半數評論都可能缺少user_id字段,如果那個字段上有索引,那麼該索引中一半的項都會是null。出於兩個原因,這種情況的效率會很差。第一,這會增加索引的大小。第二,在添加和刪除帶null值user_id字段的文檔時也要求更新索引。

如果很少(或不會)對匿名評論進行查詢,那麼可以選擇在user_id上構建一個稀疏索引。設置sparse選項同樣非常簡單:

db.reviews.ensureIndex({user_id: 1}, {sparse: true})
  

現在就只有那些通過user_id字段關聯了用戶的評論才會被索引。

3. 多鍵索引

在之前的幾章裡,你已經見過好多索引字段的值是數組的例子了。2正是名為多鍵索引(multikey index)的東西讓這些成為可能,它允許索引中的多個條目指向相同文檔。我們可以舉個簡單的例子說明一下,假設有一個產品文檔,包含幾個標籤:

2. 舉例來說,分類ID。

{ name: \"Wheelbarrow\",
  tags: [\"tools\", \"gardening\", \"soil\"]
}
  

如果在tags上創建索引,標籤數組裡的每個值都會出現在索引裡。也就是說,對數組中任意值的查詢都能用索引來定位文檔。多鍵索引背後的理念是這樣的:多個索引項或鍵最終指向同一個文檔。

MongoDB中的多鍵索引總是處於激活狀態。被索引字段只要包含數組,每個數組值都會在索引裡有自己的位置。

合理使用多鍵索引是正確設計MongoDB Schema時必不可少的一環,這在第4章到第6章的例子裡已經很明顯了;附錄B的設計模式部分還會提供更多的示例。

7.2.2 索引管理

要在MongoDB中管理索引,你現有的知識可能還稍有不足。本節我們將詳細介紹索引的創建和刪除,並討論與壓緊(compaction)和備份相關的問題。

1. 索引的創建與刪除

到目前為止,你已經創建了很多索引,因此對索引的創建語法應該並不陌生。在Shell或者所選語言裡簡單地調用索引創建輔助方法,會在特殊的system.indexes集合中添加一個文檔定義新的索引。

雖然通常情況下使用輔助方法創建索引會更方便一些,但也可以手工插入一個索引說明(輔助方法就是這麼做的)。你只需確保指定了以下這些最起碼的鍵:nskeynamens是命名空間,key是要索引的字段或字段的組合,name是用來指向索引的名字。此處還能指定一些額外選項,比方說sparse。例如,讓我們在users集合上創建一個索引:

spec = {ns: \"green.users\", key: {\'addresses.zip\': 1}, name: \'zip\'}
db.system.indexes.insert(spec, true)
  

如果插入操作沒有返回錯誤,那麼索引就創建完畢了,可以查詢system.indexes集合進行確認:

db.system.indexes.find
{ \"_id\" : ObjectId(\"4d2205c4051f853d46447e95\"), \"ns\" : \"green.users\",
  \"key\" : { \"addresses.zip\":1}, \"name\" : \"zip\", \"v\" : 1 }
  

如果你在使用MongoDB v2.0或後續版本,會看到一個額外的鍵v。這個版本字段能用於未來內部索引格式的變更,但應用程序開發者不用太在意它。

要刪除索引,你可能會覺得就是刪除system.indexes裡的索引文檔,但這個操作是被禁止的,你必須使用數據庫命令deleteIndexes刪除索引。和創建索引一樣,刪除索引也有輔助方法可用,如果希望直接運行該方法,也沒有問題。該命令接受一個文檔作為參數,其中包含集合名稱、要刪除的索引名稱或者用*來刪除所有索引。要手工刪除剛剛創建的索引,使用如下命令:

use green
db.runCommand({deleteIndexes: \"users\", index: \"zip\"})
  

大多數情況下,只需簡單地使用Shell裡的輔助方法創建和刪除索引:

use green
db.users.ensureIndex({zip: 1})
  

然後可通過getIndexSpecs方法來檢查索引說明:

> db.users.getIndexSpecs
[
    {
        \"v\":1,
        \"key\" : {
            \"_id\" : 1
        },
        \"ns\" : \"green.users\",
        \"name\" : \"_id_\"
    },
    {
        \"v\":1,
        \"key\" : {
            \"zip\" : 1
        },
        \"ns\" : \"green.users\",
       \"name\" : \"zip_1\"
    }
]
  

最後,可以使用dropIndex方法刪除索引。請注意,必須提供上述定義裡的索引名稱:

use green
db.users.dropIndex(\"zip_1\")
  

以上是基本的索引創建與刪除,想知道索引創建以後該做些什麼,請往下讀。

2. 索引的構建

大多數時候,你會在把應用程序正式投入使用之前添加索引,這允許隨著數據的插入增量地構建索引。但在兩種情況下,你可能會選擇相反的過程。第一種情況是在切換到生產環境之前需要導入大量數據。舉例來說,你想將應用程序遷移到MongoDB,需要從數據倉庫導入用戶信息。你可以事先在用戶數據上創建索引,但在數據導入之後再創建索引能從一開始就保證理想的平衡性和密集的索引,也能將構建索引的淨時間降到最低。

第二種(更顯而易見的)情況發生在為新查詢進行優化的時候。

無論為什麼要創建新索引,這個過程都很難讓人愉快起來。對於大數據集,構建索引可能要花好幾個小時,甚至好幾天。但你可以從MongoDB的日誌裡監控索引的構建過程。來看一個例子。先聲明要構建的索引:

db.values.ensureIndex({open: 1, close: 1})
  

聲明索引時要小心

由於這個步驟太容易了,所以也很容易在無意間觸發索引構建。如果數據集很大,構建會花很長時間。在生產環境裡,這簡直就是夢魘,因為沒辦法中止索引構建。如果發生了這種情況,你將不得不故障轉移到從節點上——如果有從節點的話。最明智的建議是將索引構建當做某類數據庫遷移來看待,確保應用程序的代碼不會自動聲明索引。

索引的構建分為兩步。第一步,對要索引的值排序。經過排序的數據集在插入到B樹時會更高效。注意,排序的進度會以已排序文檔數和總文檔數的比率來進行顯示:

[conn1] building new index on { open: 1.0, close: 1.0 } for stocks.values
    1000000/4308303 23%
    2000000/4308303 46%
    3000000/4308303 69%
    4000000/4308303 92%
    Tue Jan 4 09:59:13 [conn1] external sort used : 5 files in 55 secs
  

第二步,排序後的值被插入索引中。進度顯示方式與第一步相同,完成之後,完成索引構建所用的時間會顯示出來,作為插入system.indexes的耗時:

1200300/4308303 27%
    2227900/4308303 51%
    2837100/4308303 65%
    3278100/4308303 76%
    3783300/4308303 87%
    4075500/4308303 94%
Tue Jan 4 10:00:16 [conn1] done building bottom layer, going to commit
Tue Jan 4 10:00:16 [conn1] done for 4308303 records 118.942secs
Tue Jan 4 10:00:16 [conn1] insert stocks.system.indexes 118942ms
  

除了查看MongoDB的日誌,還可以通過Shell的currentOp方法檢查構建索引的進度:2

2. 注意,如果是在MongoDB Shell裡開始索引構建的,則必須打開一個新的Shell並發地運行currentOp。關於db.currentOp的更多內容,詳見第10章。

> db.currentOp
{
  \"inprog\" : [
    {
      \"opid\" : 58,
      \"active\" : true,
      \"lockType\" : \"write\",
      \"waitingForLock\" : false,
      \"secs_running\" : 55,
      \"op\" : \"insert\",
      \"ns\" : \"stocks.system.indexes\", 
      \"query\" : {
      },
      \"client\" : \"127.0.0.1:53421\",
      \"desc\" : \"conn\",
      \"msg\" : \"index: (1/3) external sort 3999999/4308303 92%\"
    }
  ]
}
  

最後一個字段msg描述了構建進度。還要注意lockType,它說明索引構建用了寫鎖,也就是說其他客戶端此時無法讀寫數據庫。如果發生在生產環境裡,這無疑是很糟糕的,這也是長時間索引構建讓人抓狂的原因。我們接下來會看到兩個可行的解決方案。

  • 後台索引

如果是在生產環境裡,經不住這樣暫停數據庫訪問的情況,可以指定在後台構建索引。雖然索引構建仍會佔用寫鎖,但構建任務會停下來允許其他讀寫操作訪問數據庫。如果應用程序大量使用MongoDB,後台索引會降低性能,但在某些情況下這是可接受的。例如,假設你知道可以在應用程序流量最低的時間窗口內完成索引的構建,那麼這時後台索引會是個不錯的選擇。

要在後台構建索引,聲明索引時需要指定{background: true}。可以像下面這樣在後台構建之前的索引:

db.values.ensureIndex({open: 1, close: 1}, {background: true})
  
  • 離線索引

如果生產數據集太大,無法在幾小時內完成索引,這時就需要其他方案了。通常這會涉及讓一個副本節點下線,在該節點上構建索引,隨後讓其上的數據與主節點同步。一旦完成數據同步,將該節點提升為主節點,再讓另一個從節點下線,構建它自己的索引。該策略假設你的複製oplog夠大,能避免離線節點的數據在索引構建過程中變得過舊。下一章會詳細討論複製,應該能幫你計劃這樣的遷移過程。

3. 備份

因為索引很難構建,所以你可能會希望為它們做備份,可惜並非所有備份方法都包含索引。舉例來說,你可能想使用mongodumpmongorestore,但這些工具僅保存了集合和索引聲明。也就是說,當運行mongorestore時,所備份的所有集合上聲明的索引都會被重新創建一遍。如果數據集很大,那麼構建索引所消耗的時間也是無法接受的。

因此,如果想要在備份中包含索引,需要直接備份MongoDB的數據文件。第10章裡有更具體的討論,以及常用的備份操作指南。

4. 壓緊

如果應用程序會大量更新現有數據,或者執行很多大規模刪除,其結果就是索引的碎片化程度很高。雖說B樹會自己合併,但這並非總能抵消大量刪除的影響。碎片過多的索引大小遠超你對指定數據集大小的預期,也會讓索引使用更多內存。這些情況下,你可能希望重建一個或多個索引:可以刪除並重新創建單個索引,或者運行reIndex命令(它會重建指定集合上的所有索引):

db.values.reIndex;
  

在重建索引時要小心:在重建過程中該命令會佔用寫鎖,讓你的MongoDB實例暫時無法使用。重建最好是在線下完成,就像之前提到的在從節點上構建索引一樣。請注意第10章裡將要介紹的compact命令,它也會重建集合上的索引。