自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

MongoDB索引使用總結(jié)

開發(fā)
本文介紹一下 MongoDB 中的索引底層結(jié)構(gòu)、索引遍歷過程、建索引以及如何使用。

作者 | mingkai

MongoDB 是目前最流行的文檔型數(shù)據(jù)庫。MongoDB 的采用類 json 的存儲格式對開發(fā)者來說非常友好。本文梳理了 MongoDB 索引的底層結(jié)構(gòu)以及使用經(jīng)驗,不足之處歡迎大家指正。


一、背景

MongoDB 提供范圍廣泛的索引類型和功能以及特定于語言的排序順序,以支持對數(shù)據(jù)的復(fù)雜訪問模式。 MongoDB 索引可以按需創(chuàng)建和刪除來適應(yīng)不斷變化的應(yīng)用程序需求和查詢模式,并且可以在文檔中的任何字段上聲明,包括嵌套在數(shù)組中的字段。本文介紹一下 MongoDB 中的索引底層結(jié)構(gòu)、索引遍歷過程、建索引以及如何使用。

二、基本使用

1.分類

MongoDB 中的索引與其他數(shù)據(jù)庫系統(tǒng)中的索引類似。 MongoDB 在集合級別定義索引,并支持 MongoDB 集合中文檔的任何字段或子字段的索引。 常見的有以下類型: 鍵索引、復(fù)合索引、多鍵索引、地理空間索引、全文本索引和哈希索引。

2.創(chuàng)建/刪除/隱藏

(1) MongoDB 使用 createIndex() 方法來創(chuàng)建索引:

`db.collection.createIndex(keys, options)`

語法中 Key 值為你要創(chuàng)建的索引字段,1 為指定按升序創(chuàng)建索引,如果你想按降序來創(chuàng)建索引指定為 -1 即可。

`db.col.createIndex({"a":1})`

createIndex() 方法中你也可以設(shè)置使用多個字段創(chuàng)建索引(關(guān)系型數(shù)據(jù)庫中稱作復(fù)合索引)。

db.col.createIndex({"a":1,"b":-1})

(2) 刪除索引:

db.collection.dropIndex

刪除索引在底層直接刪除文件,然后修改元數(shù)據(jù)。

(3) 從 4.4 開始支持隱藏索引

db.collection.hideIndex(<index>)

在刪除索引前,可以先隱藏索引,查看集群是否異常后,才真正刪除索引, 可有效幫助業(yè)務(wù)判斷索引是否可以刪除。

三、數(shù)據(jù)結(jié)構(gòu)

1.底層文件存儲

MongoDB 底層是如何存儲數(shù)據(jù)的,一個 collection 一個文件嗎?索引在底層是如何組織的? 一個 collection 對應(yīng)到底層存儲引擎就是一個文件,另外每個索引也是單獨(dú)的文件,每個數(shù)據(jù)和索引文件的默認(rèn)結(jié)構(gòu)是 b 樹,用戶建表的時候也可以指定 lsm 結(jié)構(gòu),不過絕大多數(shù)用戶基本都是使用 b 樹結(jié)構(gòu),本文的討論主要圍繞 b 樹這種結(jié)構(gòu)來進(jìn)行。

比如用戶建一個普通的表,默認(rèn)會帶一個_id 索引,會產(chǎn)生倆個文件,一個文件存放數(shù)據(jù),一個存放_id 索引,這倆個文件通過 RecordId 來連接,用戶每插入一條數(shù)據(jù),mongo 會生成一條與之對應(yīng)的自增的 RecordId, 不過用戶不感知,RecordId 是與 mysql 中的自增主鍵類似。數(shù)據(jù)文件是 RecordId 到數(shù)據(jù)的映射, _id 索引文件是_id 到 RecordId 的映射,如果通過指定_id 查詢,會現(xiàn)在_id 索引文件中找到 RecordId, 然后再到數(shù)據(jù)文件中查詢數(shù)據(jù),如果用戶再新建索引,那么在 wt 就會再新建一個文件,同樣按 b 樹組織,該文件記錄了索引到 RecordId 的映射,用戶使用索引查詢時,同樣的如同_id 索引,先找到 RecordId, 然后再到數(shù)據(jù)文件中查詢數(shù)據(jù)。

可以通過以下方式查找數(shù)據(jù)對應(yīng)的RecordId
PRIMARY> db.coll.find().showRecordId()
{ "_id" : ObjectId("647861f72b531acaacf4afb2"), "a" : 1, "b" : 1, "$recordId" : NumberLong(1) }
{ "_id" : ObjectId("647861fa2b531acaacf4afb3"), "a" : 1, "b" : 2, "$recordId" : NumberLong(2) }

2.底層格式存儲

在 MongoDB 的 Schema-free 架構(gòu)下,索引字段可以存儲不同類型的值,在索引 b 樹中,有個基本的問題,實現(xiàn)不同類型的比較呢? MongoDB 通過 BSON 結(jié)構(gòu)來存儲數(shù)據(jù),具體結(jié)構(gòu)的解析可見BSON 結(jié)構(gòu)解析 ,并且規(guī)定了不同類型之間的大小關(guān)系。

1. MinKey (internal type)
2. Null
3. Numbers (ints, longs, doubles, decimals)
4. Symbol, String
5. Object
6. Array
7. BinData
8. ObjectId
9. Boolean
10. Date
11. Timestamp
12. Regular Expression
13. MaxKey (internal type)

在這個限制下, 就只需要對比同種類型的大小了,BSON 的基本比較流程如下:先比較類型,如果類型一樣才使用 BSONElement::compareElements 比較值。

但是對于索引如果直接使用上述方法去做大小比較,具有以下的倆個缺點(diǎn):

  • BSONElement::compareElements 的性能低,主要是 BSON 結(jié)構(gòu)的序列化和反序列化;
  • BSON 結(jié)構(gòu)是 MongoDB 定義的,對于 wt 引擎層是無法識別。

3.高效的二進(jìn)制比較方式-keystring

(1) 簡介

在 MongoDB 中設(shè)計了 KeyString 結(jié)構(gòu),將所有類型可以歸一化為 string, 然后使用 memcmp 進(jìn)行二進(jìn)制比較。 KeyString 的組成方式為:

`字段1類型 +  字段1二進(jìn)制 + 字段2類型  +  字段2二進(jìn)制 + ... + <discriminator> + 結(jié)尾標(biāo)識符(0x04) + <recordId>`

那 KeyString 是怎么轉(zhuǎn)的呢?類型之間有大小關(guān)系,那么 keystring 的前幾個字節(jié)必定與類型相關(guān),實際上使用第一個字節(jié)來存儲類型, 相關(guān)類型定義如下:

const uint8_t kMinKey = 10;
const uint8_t kUndefined = 15;
const uint8_t kNullish = 20;
const uint8_t kNumeric = 30;
const uint8_t kStringLike = 60;
const uint8_t kObject = 70;
const uint8_t kArray = 80;
const uint8_t kBinData = 90;
const uint8_t kOID = 100;
const uint8_t kBool = 110;
const uint8_t kDate = 120;
const uint8_t kTimestamp = 130;
const uint8_t kRegEx = 140;
const uint8_t kDBRef = 150;
const uint8_t kCode = 160;
const uint8_t kCodeWithScope = 170;
const uint8_t kMaxKey = 240;

(2) 字符串和數(shù)字轉(zhuǎn)換

下面通過常用的字符串以及數(shù)字類型來舉例說明, 如一條文檔{a:"abcd"} ,索引為{a:1}, 生成的 keystring 為:

各個字段的含義為:

  • 類型為 60,表示 string 類型;
  • 值為 97 98 99 100 0,對應(yīng)了“abcd”的 ASCII 碼, 最后的 0x00 表示字符串類型結(jié)束;
  • 整個 keyString 的結(jié)束符 kEnd 等于 4。 方便演示,將類型和值的轉(zhuǎn)換記作 ks, 結(jié)束符為 kEnd,即:
ks("abcd") + kEnd = 60 97 98 99 100 0 4

如果一條文檔{a:"abcd", b:"a" } ,索引為{a:1,b:1}, 那么生成的 keystring 為:

再看下整數(shù)類型: {a:"NumberInt(1)}, 對應(yīng)的 KeyString 為:

與 string 類型相比 43 要比 60 要小, 所以不同類型可以通過第一個字節(jié)快速比較大小。 同樣的 4 表示結(jié)束符, 43 表示類型, 2 表示 value, 這里有倆個問題 1) 為什么不使用類型值不是 kNumeric=30 呢? 2) value 為什么不是 1, 而是 2 呢? 帶著以上問題,接下來詳細(xì)分析下最復(fù)雜的數(shù)值類型轉(zhuǎn)換。

(3) 數(shù)值類型轉(zhuǎn)換

數(shù)字類型比較是最復(fù)雜的方法,個整性數(shù)可以大端存儲然后對比大小,但是涉及到實現(xiàn)整性數(shù)和浮點(diǎn)數(shù)的比較問題就復(fù)雜了。 為了解決上述問題, 按照數(shù)字所需要占用字節(jié)的大小和正負(fù)劃分了 22 種數(shù)字類型, 如下:

const uint8_t kNumericNaN = kNumeric + 0;
const uint8_t kNumericNegativeLargeMagnitude = kNumeric + 1;  // <= -2**63 including -Inf
const uint8_t kNumericNegative8ByteInt = kNumeric + 2;
const uint8_t kNumericNegative7ByteInt = kNumeric + 3;
const uint8_t kNumericNegative6ByteInt = kNumeric + 4;
const uint8_t kNumericNegative5ByteInt = kNumeric + 5;
const uint8_t kNumericNegative4ByteInt = kNumeric + 6;
const uint8_t kNumericNegative3ByteInt = kNumeric + 7;
const uint8_t kNumericNegative2ByteInt = kNumeric + 8;
const uint8_t kNumericNegative1ByteInt = kNumeric + 9;
const uint8_t kNumericNegativeSmallMagnitude = kNumeric + 10;  // between 0 and -1 exclusive
const uint8_t kNumericZero = kNumeric + 11;
const uint8_t kNumericPositiveSmallMagnitude = kNumeric + 12;  // between 0 and 1 exclusive
const uint8_t kNumericPositive1ByteInt = kNumeric + 13;
const uint8_t kNumericPositive2ByteInt = kNumeric + 14;
const uint8_t kNumericPositive3ByteInt = kNumeric + 15;
const uint8_t kNumericPositive4ByteInt = kNumeric + 16;
const uint8_t kNumericPositive5ByteInt = kNumeric + 17;
const uint8_t kNumericPositive6ByteInt = kNumeric + 18;
const uint8_t kNumericPositive7ByteInt = kNumeric + 19;
const uint8_t kNumericPositive8ByteInt = kNumeric + 20;
const uint8_t kNumericPositiveLargeMagnitude = kNumeric + 21;  // >= 2**63 including +Inf

那么劃分類型的依據(jù)是哪些呢?

  • 存儲時,只存絕對值,正負(fù)是不同的類型, 可以加速判斷,負(fù)數(shù)一定比整數(shù)?。?/li>
  • 根據(jù)數(shù)字整數(shù)部分所需要占用字節(jié)的大小來區(qū)分不同類型;
  • 特殊范圍的值 大數(shù)大于等于2**63包括+Inf , -小于等于2**63包括-Inf, (0,1.0) ,(-1.0,0)等,
  • 特殊值特殊處理,比如:0、NaN 等; 總的來說通過數(shù)字的不同范圍區(qū)分了不同的類型, 不僅可以節(jié)省空間,一定也能加速大小比較判斷,不同字節(jié)數(shù)的數(shù)字只需判斷類型即可。

特殊范圍的值 這些值只能是浮點(diǎn)數(shù)類型這些子類型的 Double 的編碼相對容易很多,只需要按照 Double 原有格式稍作處理,然后使用大端模式編碼即可;

問題是如何對比存在交集的值,比如整數(shù) 1 和浮點(diǎn)數(shù) 1.5, 先看下以上倆個值轉(zhuǎn)成 keyString:

  • 同樣的最后的 4 表示結(jié)束符;
  • 43 表示了 kNumericPositive1ByteInt 類型,并且需要空出一個 bit 來表示數(shù)字是否包含小數(shù)部分,如果沒有小數(shù)部分就將其設(shè)置位 0, 有小數(shù)部分就將其設(shè)置為 1,所以上述提到的{a:1} 對應(yīng)的值就為 1 左移 1 位再將最后一個 bit 標(biāo)識為 0,等于 2;{a:1.5}對應(yīng)的整數(shù)值為 1 左移 1 位再將最后一個 bit 標(biāo)識為 1, 結(jié)果就是 3, kNumericPositive1ByteInt 中的 1Byte 表現(xiàn)在一個數(shù)的整數(shù)左移一位所需要的 byte 數(shù)為 1, 如果浮點(diǎn)數(shù)沒有小數(shù)部分最后一個 bit 也要標(biāo)識為 0;
  • 128 0 0 0 0 0 0 表示小數(shù)部分,表示 0.5,整數(shù)部分加小數(shù)部分加起來一共 8 位,剩下 7 個 byte 來表示小數(shù)部分。

為什么需要用最后一個 1bit 位來表示是否含有小數(shù)部分呢?如果沒有這個 bit 位, 倆個數(shù)整數(shù)部分長一樣的話,Double 的小數(shù)部分要和 Int 后面的結(jié)束符 0x4 來比較了,避免比較出現(xiàn)的誤差,還能加快比較;double 本身是 8 位, 現(xiàn)在轉(zhuǎn)成 keyString 還是 8 位。雖然上面提到浪費(fèi)了一個 bit 來表示是否包含小數(shù)部分,現(xiàn)在 8 位只表示絕對值了,正負(fù)在類型體現(xiàn)了,不會有精度的丟失。對于小數(shù)部分為 0 的浮點(diǎn)數(shù), 生成的 keystring 與與之對應(yīng)的整數(shù)一樣。

(4) keyString 的優(yōu)點(diǎn)

  • 轉(zhuǎn)換成二進(jìn)制, 優(yōu)秀的比較性能;
  • 可以實現(xiàn)不同類型的快速比較;
  • 針對數(shù)值類型進(jìn)行細(xì)化,解決了整數(shù)類型和浮點(diǎn)數(shù)類型轉(zhuǎn)換的兼容性問題, 以及節(jié)省存儲成本。

(5) 在索引中的使用

MongoDB 中使用索引查詢數(shù)據(jù)會有 2 個階段:

  • 查索引,通過索引字段的 KeyString 找到對應(yīng)的 RecordId;
  • 查數(shù)據(jù), 根據(jù) RecordId 找到 BSON 文檔;

(/tencent/api/attachments/s3/url?attachmentid=2948416)

就是說普通索引在底層引擎中索引 b 樹中的 key:

ks(索引field對應(yīng)的值) + kEnd + RecordId

_id 索引在底層引擎中索引 b 樹中的 key:

ks(索引field對應(yīng)的值) + kEnd

① 普通索引的 key 包含 RecordId

非唯一普通索引的 key 包含 RecordId,是因為 WT 引擎中的 Key 是唯一的,但是索引中的 Key 是不唯一的。比如 {a: 1} 索引,很多條 BSON 文檔的 "a" 字段都能等于 1,因此在存儲索引的時候必須通過方法進(jìn)行區(qū)分。 在 MongoDB 中,由于每個文檔都有獨(dú)立的 RecordId,這樣將 RecordId 作為后綴就能保證唯一性。

② 唯一索引的 key 也包含 RecordId

普通的唯一索引,上述問題不存在, 但是為什么唯一索引的 key 也包含 RecordId 呢? 主要是為了解決主從同步的時,從節(jié)點(diǎn)的 oplog 并發(fā)回放的,回放時的亂序可能會臨時打破索引的唯一性質(zhì)。舉個例子,主上依次進(jìn)行以下三個操作: insert {_id:1, a:1} remove {_id:1} 和 insert {_id:2, a:1}。a 字段為唯一索引, 從上根據(jù)_id 做 hash 然后并發(fā)回放時 一個線程回放 insert {_id:1, a:1}和 remove {_id:1},另外一個回放 insert {_id:2, a:1}。 remove {_id:1} 和 insert {_id:2, a:1}的順序得不到保證,最終可能的一種順序為: insert {_id:1, a:1}, insert {_id:2, a:1}以及 remove {_id:1}, 對 a 索引進(jìn)行重復(fù)插入了{(lán)a:1}, 所以回放時就得暫時不檢查 a 索引的唯一性,需要按照上述的方式采用 RecordId 作為后綴。

但是使用 RecordId 后綴方式之后,主節(jié)點(diǎn)數(shù)據(jù)寫數(shù)據(jù)時索引唯一性的檢查邏輯會更復(fù)雜。

在數(shù)據(jù)插入前的檢測邏輯如下:需要在索引中要插入帶 RecordId 的 Key, 格式為: ks1+RecordId。首先會先將 RecordId 后綴去掉,插入 ks1,然后將其 Remove 掉,先插入后刪除 KeyString 的操作利用了 wt 引擎樂觀鎖的特性,構(gòu)成了寫沖突的條件; 假設(shè)此時來了另外一個的插入操作, KeyString 相同但是 RecordId 不同,也得需要先插入 ks1, 但是 wt 引擎檢查到 KeyString 已經(jīng)被修改,該操作拋出異常;所以先插入后刪除 KeyString 保證了對一個 KeyString 同時只能有一個索引唯一性的檢查; 最后就到了實際的重復(fù) key 檢查邏輯, 找到第一條大于等于 ks1 的數(shù)據(jù), 如果該數(shù)據(jù)的 key 前綴也正好 ks1,那么索引唯一性檢查失敗。

_id 也是(自帶的)唯一索引,為啥就能把 RecordId 放在 Value 部分呢?因為 oplog 回放首先會通過 _id 進(jìn)行 hash 分桶,然后多個桶之間并發(fā)回放。也就是說 _id 不會存在亂序回放的問題,因此將 RecordId 擋在 Value 部分是沒問題的,并且對于唯一索引的插入,沒有索引唯一性的檢查邏輯,性能也就沒有損耗。

四、建立索引

1.簡介

在使用數(shù)據(jù)庫過程中一般的建議是先建好索引,然后再進(jìn)行業(yè)務(wù)的操作, 不過也存在一些場景由于前期設(shè)計不合理, 在數(shù)據(jù)量比較大時需要給表增加索引,以 4.0 為例有以下倆種建索引的方式, 前臺建索引和后臺建索引倆種方式。

2.前后臺建索引

默認(rèn)情況下,使用前臺建索引,不過因為持有 db 的寫鎖, 將阻塞 db 其他的所有操作。即該 db 上的集合的無法正常讀寫,直到索引創(chuàng)建完畢。 如果不想影響業(yè)務(wù)的使用,就需要指定參數(shù){background:1}, 使用后臺建索引的方式,但是耗費(fèi)的時間會更久。

為什么后臺建立索引時間更久呢? 先看看前臺建索引的大致流程,在整個流程中,一直持有 db 的寫鎖,禁止讀寫操作, 直到索引建立完成。

\1) 掃描整個數(shù)據(jù)文件,按照用戶所要求的字段以及 recordid 生成 keystring; \2) 將以上生成的 keystring 暫時存到內(nèi)存中, 如果所占用的內(nèi)存大小達(dá)到了 500MB 或者,就排序后落盤存放在臨時文件,如果數(shù)據(jù)量比較大,磁盤上會有多個 500MB 的文件,文件內(nèi) keystring 時有序的; \3) 對以上文件進(jìn)行歸并,將結(jié)果批量寫到索引 b 樹中(索引文件底層也是一顆 b 樹)。

從以上來看前臺建立索引會將數(shù)據(jù)在文件排好序, 然后批量寫入到索引 b 樹中, 要比后臺索引隨機(jī)寫入索引 b 樹性能要更高。 為什么后臺建立索引過程中允許寫入還能保證索引數(shù)據(jù)的一致性呢? 后臺建索引的是直接掃描整個數(shù)據(jù)文件,期間允許讀寫操作, 按照用戶所要求的字段以及 recordid 生成 keystring, 然后插入到索引 b 樹中,并且用戶的更新/插入/刪除操作也會對索引 b 樹進(jìn)行修改。 如何保證一遍全表掃,一邊更新/插入/刪除操作,保證最終數(shù)據(jù)和索引的一致性呢?可以分成三種情況:

  • 情形 1:更新/插入/刪除還沒掃到的數(shù)據(jù);情形 2:更新/插入/刪除已經(jīng)掃到的數(shù)據(jù);情形 3: 更新/插入/刪除和掃數(shù)據(jù)同時發(fā)生。
  • 對情形 1, 如后續(xù)掃到插入時,會產(chǎn)生 DuplicateKey 錯誤,直接忽略;對于情形 2, 如更新/刪除操作,會將之前的建立的索引刪除;
  • 情形 3比較難處理,需要依賴 wt 底層的事務(wù)寫重入機(jī)制
MongoDB引擎層使用樂觀鎖的機(jī)制, 多個事務(wù)對同一條文檔進(jìn)行操作時, 其他事務(wù)感知到文檔被修改后,就拋出寫沖突異常,MongoDB捕獲的,拋棄當(dāng)前的引擎層事務(wù), 會重新開啟事務(wù).

如用戶一次 update 操作將{a:1}更新為{a:2},對應(yīng)索引 b 樹的操作為 remove 了{(lán)a:1},然后 insert 一條 a=2 的數(shù)據(jù), 后臺建索引線程掃描時 剛好掃到了{(lán)a:1},也會在索引 b 樹 insert 一條{a:1} 的數(shù)據(jù),預(yù)期的最后結(jié)果是索引了只剩{a:2}, 以上的操作都在各自的 wt 事務(wù)內(nèi)完成。

雖然是同時操作的,但還是有先后順序:

  • 后臺建索引線程掃描后先插入了{(lán)a:1}, 但是還沒有提交, 等到用戶操作需要 remove 掉{a:1},就會觸發(fā) wt 內(nèi)部的寫沖突后,等待后臺建索引線程對應(yīng)的事務(wù)提交后,再來重試,重試時會將 a=1 這條 remove 掉,然后 insert 一條 a=2 的數(shù)據(jù), 最終結(jié)果就是一條{a:2}.
  • 用戶先 update 操作后未提交, remove 了{(lán)a:1}時通過, 因為索引 b 樹不存在 a=1 這條文檔, 然后插入{a:2}, 等后臺建索引線程掃描后會插入了{(lán)a:1}成功, 觸發(fā)不了寫沖突的邏輯, 最終會剩下{a:2}以及{a:1}倆條數(shù)據(jù),這時就會出現(xiàn)數(shù)據(jù)不一致的情況.

對于上述問題,其實官方早期版本中也存在, 如何進(jìn)行修復(fù)呢? 其實在 remove 掉{a:1}之前, 可以先插入{a:1}, 在引擎中留下{a:1}被修改的記錄, 等后臺建索引線程掃描后會插入了{(lán)a:1}時, 就能救出寫沖突, 拋棄當(dāng)前的引擎層事務(wù), 等用戶 update 操作對應(yīng)的 wt 事務(wù)提交后, 再重試時就無法掃描到{a:1}這條數(shù)據(jù)了, 最后只剩下{a:2}.

3.hybrid 建索引

那么是否有方法能夠結(jié)合以上倆者的優(yōu)點(diǎn)呢?速度又快,又不會影響客戶端的操作。 從 4.2 開始,默認(rèn)使用了第三種方式:hybrid 建索引

建索引的過程中,如前臺建立索引一樣, 也會掃描全表, 然后生成多個內(nèi)部數(shù)據(jù)有序的臨時文件,然后歸并排序好批量插入到索引 b 樹中,怎么處理在上述過程中的的 更新/插入/刪除操作呢?hybrid 建索引會將上述變更記錄下來,然后等批量寫入階段結(jié)束后進(jìn)行回放,為了保證數(shù)據(jù)的一致性,最終會在臨界區(qū)再回放一次回放過程中產(chǎn)生的變更,臨界區(qū)禁止用戶寫入。 所以 hybrid 建索引即擁有前臺建索引的速度又如后臺建索引不會卡住用戶的操作。

所以 4.0 及以下集群需要使用后臺建立索引需要指定{background:1}, hybrid 建索引在 4.2 是默認(rèn)開啟的,用戶不需要關(guān)心.

五、索引查詢過程

1.IXSCAN 和 FETCH 階段

在使用索引查詢數(shù)據(jù)時, MongoDB 也使用火山模型, 其實比較常見的倆個階段 IXSCAN 和 FETCH。 IXSCAN 通過掃描索引 b 樹,返回 RecordId; FETCH 得到 RecordId 后從數(shù)據(jù) b 樹取出對應(yīng)的 BSON 文檔,直接提交給上層, 也有可能根據(jù)查詢條件的不同,進(jìn)行過濾后再提交給上層算子。

以用戶使用較多的等值查詢, 范圍查詢(和gt(e)等操作符)為例來介紹 MongoDB 是如何通過索引遍歷數(shù)據(jù)來查詢的。

2.操作符 以及gt(e)注意點(diǎn)

在此之前看在無索引的情況下使用以及 gt(e)等操作符的注意點(diǎn)。mongo 只會比較相同的類型。如下所示, 表內(nèi)有倆條數(shù)據(jù)

restore-test_0:PRIMARY> db.collb.find()
{ "_id" : ObjectId("647f204a2b531acaacf4afbe"), "a" : true }
{ "_id" : ObjectId("647f204d2b531acaacf4afbf"), "a" : 1 }

執(zhí)行以下查詢語句時, 只能找到 a 字段為數(shù)字類型的那條

restore-test_0:PRIMARY> db.collb.find({a:{$gte:1}})
{ "_id" : ObjectId("647f204d2b531acaacf4afbf"), "a" : 1 }
restore-test_0:PRIMARY> db.collb.find({a:{$lte:1}})
{ "_id" : ObjectId("647f204d2b531acaacf4afbf"), "a" : 1 }

如果業(yè)務(wù)的字段具有不同類型的值,需要注意這點(diǎn)

3.遍歷之前-區(qū)間轉(zhuǎn)換

MongoDB 會將用戶的查詢語句轉(zhuǎn)化為區(qū)間查詢。 對于等值查詢會將其轉(zhuǎn)化為 start 和 end 相等的左開右開區(qū)間, 比如{a:1}, 那么對應(yīng)的區(qū)間為[1.0, 1.0]。 對于范圍查詢?nèi)鐊a:{$gte:1}},就將其轉(zhuǎn)換為[1.0, inf.0], 這里的inf.0表示無窮大的正數(shù), 為什么不是MaxKey呢?,按上面的說明可以理解為MaxKey為無窮大,比任意值都要大。前面說過在無索引遍歷的情況下,使用操作符 以及gt(e) 只會比較同類型的數(shù)據(jù),同樣的索引會選擇該類型的最大值作為終止條件。比如對于bool類型來說{a:{$gte:false}}就會轉(zhuǎn)換成[false, true]這個區(qū)間。

但是對于一些無最大值的類型,比如 string 類型,{a:{$gte: "1" }}查詢條件如何轉(zhuǎn)換成區(qū)間呢? 既然 keystring 可以在不同類型間比較, 那么我們只要取下個類型的最小值即可, 即對應(yīng)的區(qū)間為["1", {}), {}表示 Object 類型,在類型順序中屬于 string 類型的下個類型。

所以雖然轉(zhuǎn)換成 keystring 后,能夠?qū)崿F(xiàn)不同類型的比較, 但是在實際使用范圍操作符遍歷索引時, MongoDB 內(nèi)部也會自動加上限制條件,保證只能取同類型數(shù)據(jù)。下面看下具體如何轉(zhuǎn)換成對應(yīng)的 KeyString 以及根據(jù)轉(zhuǎn)換后的區(qū)間遍歷過程。

(1) 遍歷之前-區(qū)間 keyString 轉(zhuǎn)換

上文中提到如{a:1}在作為普通索引在存儲時, 會轉(zhuǎn)換成以下格式:

key: ks(1) + kEnd + RecordId
value: typeBits

那么對應(yīng)的區(qū)間[1.0, inf.0]是如何轉(zhuǎn)換的呢? 首先并不知道 RecordId 對應(yīng)多少, 那么就不能通過 wt 引擎的等值查詢 search 接口直接查到對應(yīng)的文檔,需要使用 wt 引擎的 search_near 接口定位到第一條大于等于查詢 key 的接口, 對應(yīng)的查詢 key 為:

key: ks(1) + kEnd

如果是左邊是開區(qū)間的(1.0, inf.0]區(qū)間呢?這個時候如何還是使用 key: ks(1) + kEnd 作為查詢 key, 有可能查到多的數(shù)據(jù), 如果需要 MongoDB 上層再過濾下,不是一個很好的選擇。最好的是在編碼的時候搞定, MongoDB 實際才使用時在 ks(1) + kEnd 中間添加一個標(biāo)記為 kExclusiveAfter, kExclusiveAfter 標(biāo)識位的值位 254, 查詢 key 為:

key: ks(1) + kExclusiveAfte +  kEnd

這樣使用引擎的 search_near 接口(大于等于語義)時,就能跳過{a:1}的數(shù)據(jù)。 區(qū)間的開始和結(jié)束都轉(zhuǎn)換成 KeyString 后,并不是直接將開始和結(jié)束點(diǎn)的 KeyString 都傳給 wt 引擎,而是只傳開始的 key,然后 wt 引擎開始遍歷,同時 MongoDB 上層對所遍歷的 key 進(jìn)行判斷是否在區(qū)間內(nèi)。

查詢區(qū)間根據(jù)所命中的數(shù)據(jù)在索引 b 樹上是否連續(xù)可以分為單區(qū)間和多區(qū)間, 根據(jù)查詢區(qū)間的不同, MongoDB 上層判斷是否在區(qū)間的方式也不一樣。

(2) 單區(qū)間查詢遍歷

如果是單區(qū)間比如以{a:1, b:1, c:1}為索引, 指定了等值查詢?nèi)鐊a:10} 或者{a:10, b:20}等等, 或者單個范圍{a: {$lte:10}} 或者{a:10, b: {$lte:10}}等前綴全是等值判斷只有最后一項為范圍的查詢方式,單區(qū)間所命中的數(shù)據(jù)在索引 b 樹上是連續(xù)的,就能根據(jù)區(qū)間結(jié)束條件生成 KeyString,在遍歷過程中由 MongoDB 上層來進(jìn)行 KeyString 二進(jìn)制對比, 如果對比發(fā)現(xiàn)超范圍了,就結(jié)束遍歷。

單區(qū)間遍歷比較直觀,也比較好理解。如果業(yè)務(wù)查詢條件比較復(fù)雜,非單區(qū)間遍歷,那么 MongoDB 是怎么遍歷的呢?

(3) 多區(qū)間查詢遍歷

如{a:{$in:[10, 20, 30]}) 以及使用了{(lán)a:1, b:1, c:1},查詢時按照{(diào)a:10 , c:{$lte:10}}等等相對較復(fù)雜的方式,所命中的數(shù)據(jù)在索引 b 樹上不是連續(xù)的,MongoDB 就不是通過 KeyString 來判斷, 而是每查詢一條,都得根據(jù) KeyString 反解生成對應(yīng)的 BSON 文檔, 然后再進(jìn)行 BSON 比較判斷是否在目標(biāo)區(qū)間內(nèi), 否則就需要重定位到下一個區(qū)間, 當(dāng)然如果已經(jīng)在最后一個區(qū)間了, 就結(jié)束遍歷。

下面用一個常見場景來分析下,假設(shè)有以下的數(shù)據(jù)集, 然后索引為{a:1, b:1, c:1}

數(shù)據(jù)集:
{a:1, b:1, c:1}
{a:1, b:1, c:2}
{a:1, b:1, c:3}
{a:1, b:1, c:4}
{a:1, b:2, c:5}
{a:1, b:2, c:1}
{a:1, b:2, c:2}
{a:1, b:2, c:3}
{a:1, b:2, c:4}
{a:1, b:2, c:5}

對于該語句 db.collection.find({a:"1", c:{$lte:1}}).sort({b:1, c:1})必定走索引遍歷,不會有內(nèi)存排序,但是索引數(shù)據(jù)在 b 樹上也不是連續(xù)分布的,那么現(xiàn)在的問題是遍歷過程中, 是否會將這十條數(shù)據(jù)全部遍歷呢?

在遍歷前通過分析,會確定 a、b 和 c 的取值范圍, b 沒有指定范圍,所以時 MinKey 到 MaxKey, c 指定了是比較數(shù)字,所以左區(qū)間為-inf.0。

      "a" :  [ "[1.0, 1.0]"   ],
      "b" :  [ "[MinKey, MaxKey]"  ],
      "c" :  [ "[-inf.0, 1.0]"   ]

按上面的分析,這是一個多區(qū)間查詢,遍歷過程中會發(fā)生重定位的行為, 我們詳細(xì)來看下遍歷的過程: 1) 跳轉(zhuǎn)(seek)到第一條大于等于 {a:1.0, b:MinKey, c:-inf.0}的數(shù)據(jù), 即第一條數(shù)據(jù), 判斷在范圍內(nèi), 滿足要求; 2) 遍歷到第二條數(shù)據(jù)時, 發(fā)現(xiàn) c 字段不在范圍內(nèi), 不滿足要求, 這里會觸發(fā)跳轉(zhuǎn)的邏輯,不會再判斷第三條, 3) 既然當(dāng)前 a 和 b 是在范圍內(nèi),跳轉(zhuǎn)的邏輯時找到大于{a:1, b:1}的第一條即可,即跳轉(zhuǎn)到{a:1, b:2, c:1},滿足要求 4) 然后重復(fù)上述的邏輯,判斷{a:1, b:2, c:3}不符合要求后,需要查找大于{a:1, b:2}的數(shù)據(jù), 就直接跳轉(zhuǎn)到最后. 以上查詢過程會跳轉(zhuǎn)(seek)三次, 并不會掃描全部索引數(shù)據(jù),而是掃描到了 4 條索引數(shù)據(jù)(keysExamined=4),最后判斷有倆條數(shù)據(jù)時滿足要求的。

所以 MongoDB 針對多區(qū)間查詢的場景, 為了提交查詢效率, 中間會穿插跳轉(zhuǎn)(seek)的情況,減少遍歷的 key 值。 但是以上述查詢?yōu)槔?**跳轉(zhuǎn)(seek)的次數(shù)跟 b 字段的值范圍多少有關(guān), 在滿足該種查詢的情況下, 建議 b 字段的值是枚舉類型,值空間是固定,避免出現(xiàn)較多的跳轉(zhuǎn)(seek)**。并且從以上流程可以看到單區(qū)間遍歷是通過 KeyString 二進(jìn)制比較來判斷終止查詢是否終止, 而多區(qū)間查詢?yōu)榱四軌驅(qū)崿F(xiàn)跳轉(zhuǎn)(seek),每次比較需要把 KeyString 反解成 BSON 文檔來比較, 效率比 KeyString 二進(jìn)制略低。

六、使用建議

1.遵從 ESR 原則

對于復(fù)合索引,此經(jīng)驗法則有助于確定索引中字段的順序: 首先,添加運(yùn)行 等值 查詢的那些字段, 下一個要索引的字段應(yīng)該反映查詢的排序順序, 最后的字段表示要訪問的數(shù)據(jù)范圍。 如{a:1, b:1, c:1} 索引, 按照以上原則設(shè)計, 在查詢時,就能按索引遍歷數(shù)據(jù), 避免內(nèi)存 sort 流程。

2.執(zhí)行計劃確認(rèn)

explain 是 MongoDB 的查詢計劃工具,和 MySql 的 explain 功能相同,都是用來分析一條語句的索引使用情況、影響行數(shù)、執(zhí)行時間等。 explain 有三種參數(shù)分別對應(yīng)結(jié)果輸出的三部分?jǐn)?shù)據(jù):

  • queryPlanner: MongoDB 運(yùn)行查詢優(yōu)化器對當(dāng)前的查詢進(jìn)行評估并選擇一個最佳的查詢計劃。
  • exectionStats:mongoDB 運(yùn)行查詢優(yōu)化器對當(dāng)前的查詢進(jìn)行評估并選擇一個最佳的查詢計劃進(jìn)行執(zhí)行。在執(zhí)行完畢后返回這個最佳執(zhí)行計劃執(zhí)行完成時的相關(guān)統(tǒng)計信息。
  • allPlansExecution:即按照最佳的執(zhí)行計劃執(zhí)行以及列出統(tǒng)計信息,如果有多個查詢計劃,還會列出這些非最佳執(zhí)行計劃部分的統(tǒng)計信息。

建議在一個數(shù)據(jù)量較大的庫上開發(fā)功能時,使用 explain 分析一下自己的語句和索引是否合理,避免在項目上線之后出現(xiàn)問題。MongoDB 也是采用火山模型來進(jìn)行查找, 火山模型是數(shù)據(jù)庫界已經(jīng)很成熟的解釋計算模型,該計算模型將關(guān)系代數(shù)中每一種操作抽象為一個 Operator,將整個 SQL 構(gòu)建成一個 Operator 樹,從根節(jié)點(diǎn)到葉子結(jié)點(diǎn)自上而下地遞歸調(diào)用 next() 函數(shù)。

常用 stage 解析:

  • COLLSCAN:全表掃, 該階段會掃描表的全部數(shù)據(jù),從數(shù)據(jù) b-tree 開始掃描,應(yīng)當(dāng)避免該 stage 的出現(xiàn);
  • IXSCAN:根據(jù)分析 sql 生成的索引范圍來掃描索引 b-tree,在該 stage 中, 應(yīng)關(guān)注掃描的條數(shù)是否合理;
  • SORT_KEY_GENERATOR: 根據(jù)需要排序的字段生成 keystring,一般與 SORT stage 一起出現(xiàn);
  • SORT: 內(nèi)存排序階段,占用內(nèi)存,應(yīng)當(dāng)設(shè)計合適的索引來避免該階段;
  • FETCH:回表操作,獲取到 RecordId 后,在數(shù)據(jù) b-tree 中查找對應(yīng)的文檔;
  • PROJECTION: 選擇需要返回給的字段。

3.避免回表

通俗的講就是,如果索引的列在所需獲得的列中(因為索引是根據(jù)索引列的值進(jìn)行排序的,所以索引節(jié)點(diǎn)中存在該列中的部分值)或者根據(jù)一次索引查詢就能獲得記錄就不需要回表,mongo 默認(rèn)的查詢過程中, 在索引 b 樹上找到對應(yīng)的 RecorId 上, 還需在數(shù)據(jù) b 樹找對應(yīng)的文檔,然后進(jìn)行 bson 解析。 實際上如果用戶所需要的信息在索引 b 樹的 key 內(nèi)已經(jīng)包括了,后面的回表操作是多余的,尤其是在大文檔的條件下, BSON 解析比較消耗性能。

那么 MongoDB 如何去避免回表呢?

我們可以在 MongoDB 中可以通過使用 project 來選擇需要返回的字段,保證所需字段在 project 條件內(nèi),特別注意 MongoDB 默認(rèn)_id 字段是需要返回的,如果確定不需要_id 字段,需要顯式的指定_id 為 0,可以通過判斷 explain 內(nèi)是否包括 FETCH 階段來判斷是否發(fā)生了回表行為。 以某業(yè)務(wù) cpu 使用率為例:

該業(yè)務(wù)每條文檔都比較大, 業(yè)務(wù)實際在使用的過程中指定 project 優(yōu)化后, cpu 性能提升比較明顯。

4.減少索引的使用

在線上的業(yè)務(wù)中,發(fā)現(xiàn)有很多業(yè)務(wù)存儲使用多余索引的情況,

同個表有相同前綴的索引: 比如{a:1, b:1, c:1} 和 {a:1, b:1} 索引,在這種情況對寫入性能會有影響, 每次插入/修改/刪除都不可避免的對多余的索引進(jìn)行操作,這種情況應(yīng)當(dāng)及時清理多余的索引;

如果業(yè)務(wù)剛好需要建立一個唯一索引,那么可以考慮使用_id 索引,從上面的分析可知,_id 索引可認(rèn)為是一個 MongoDB 默認(rèn)系統(tǒng)自帶的索引, 如果用好, 就能減少一個索引。

5.徹底了解 multiKey

所謂 multikey 是指如果一個字段的值是數(shù)組,那么為該字段創(chuàng)建索引時為數(shù)組中的每個元素創(chuàng)建一個索引鍵,這些多鍵索引支持對數(shù)組字段的有效查詢。 比如文檔{_id:0, a:[1, 2, 3]},對應(yīng)的 RecordId 為 0 , 對 a 字段建立索引時, 在索引 b 樹上會插入三條數(shù)據(jù),它們的 key 為:

ks(1) + RecordId(0)
ks(2) + RecordId(0)
ks(3) + RecordId(0)

用戶通過{a:1}, {a:2} 以及{a:3}等查詢時,都能通過索引找到 recordId 為 0 ,然后找到數(shù)據(jù), 這種查詢方式比較符合用戶的使用習(xí)慣。 當(dāng)然能范圍查詢?nèi)鐊a:{$lte:3}},也會走索引,但是這會帶來一個問題,在 IXSCAN 階段三條索引數(shù)據(jù)都滿足要求,那么 IXSCAN 階段是否會返回三條同樣的 RecordId, 接下來的 FETCH 階段是否查詢?nèi)文兀?實際上對于 multiKey 索引在遍歷第一條索引數(shù)據(jù)時, MongoDB 就有有個 set記錄了已經(jīng)遍歷了的 recordId, 當(dāng)遍歷第二條 ks(2) + RecordId(0) 以及第三條數(shù)據(jù)時就可以判斷 RecordId(0) 這條 bson 文檔已經(jīng)被遍歷過了,不需要將對應(yīng)的 RecordId 提交給上層的 FETCH 階段了。

如果對{a:1}字段建唯一索引呢? 如果我們想繼續(xù)插入如 {a:[3, 4, 5]} 對應(yīng)的 RecordId 為 0,是會失敗的,由于已經(jīng)在索引 b 樹中插入了三條數(shù)據(jù) ks(1) + RecordId(0) 、 ks(2) + RecordId(0)和 ks(3) + RecordId(0),新插入的 ks(3) + RecordId(1) 與 已插入的 ks(3) + RecordId(0)不同通過唯一性檢查,所以這里需要注意唯一 multiKey 索引,是要求數(shù)組內(nèi)的值唯一, 而不是整個數(shù)組唯一。

再看下面一個例子,表中有個倆條文檔, 以{a:1}建索引:

{_id:0, a:[1, 4]} => RecorId(0)
{_id:1, a:[2, 3]} => RecorId(1)

先對 a 字段進(jìn)行排序查詢:

PRIMARY> db.collection.find().sort({a:1})
{ "_id" : 0, "a" : [ 1, 4 ] }
{ "_id" : 1, "a" : [ 2, 3 ] }

然后逆序查詢輸出下:

PRIMARY> db.collection.find().sort({a:-1})
{ "_id" : 0, "a" : [ 1, 4 ] }
{ "_id" : 1, "a" : [ 2, 3 ] }

居然順序和逆序查詢結(jié)果是一樣的?。?!按照我們上述分析的可以仔細(xì)分析出來, 這個倆條文檔需要在索引 b 樹中生成 4 個 kv 對,他們的 k 如下:

ks(1) + Record(0)
ks(2) + Record(1)
ks(3) + Record(1)
ks(4) + Record(0)

我們執(zhí)行的查詢語句會走索引,所以不管是順序還是逆序都會先查詢到 RecordId 為 0 的那條。

6.空字段的索引

“我們的表很大,現(xiàn)在需要對一個不存在的字段建索引,速度會不會快很多?”,我們在線上的運(yùn)營過程中遇到過以上疑問,因為建索引可以簡化成倆個步驟:掃表和往索引 b 樹插入數(shù)據(jù)。掃表是避免不了的,但是對應(yīng)的字段為空,是不是就不會往索引 b 樹中插入數(shù)據(jù)了呢?

首先我們可以看下如果索引字段為空,對應(yīng)的索引 b 樹中也沒有對應(yīng)的記錄會有什么后果。用戶常使用 操作符來判斷字段是否存在,如果索引樹中也沒有對應(yīng)的對的話,那么exists 操作符在查詢是就不能走索引了,只能通過全表掃描的方式,這樣的效率是不能接受的。

索引 b 樹中需要特殊標(biāo)識下字段為空的情況, 實際上在建立索引時如果字段為空, 就會認(rèn)為該字段的類型為特殊的 null 類型(前文中已經(jīng)提到過),db.collection.find({a:{$exists:false}}),對應(yīng)的查詢區(qū)間為[null, null]。所以對于開頭的問題,對一個不存在的字段建索引,速度并不會快。

可以通過稀疏索引(或者稱間隙索引)就是只包含有索引字段的文檔的條目,即使索引字段包含一個空值。也就是說間隙索引可以跳過那些索引鍵不存在的文檔。

7.慢日志分析

{
    "timestamp": "Thu Apr  2 07:51:50.985" ,
    "severityLevel": "I"
    "components": "COMMAND"
    "namespace": "animal.MongoUser_58"
    "operation": "find"
    "command": { find: "MongoUser_58", filter: { $and: [ { lld: { $gte: 18351 } }, { fc: { $lt: 120 } }, { _id: { $nin: [1244093274 ] } }, { $or: [ { rc: { $exists: false } }, { rc: { $lte: 1835400100 } } ] }, { lv: { $gte: 69 } }, { lv: { $lte: 99 } }, { cc: { $in: [ 440512, 440513, 440514, 440500, 440515, 440511, 440523, 440507 ] } } ] }, limit: 30 } //具體的操作命令細(xì)節(jié)
    "planSummary": "IXSCAN { lv: -1 }", // 命令執(zhí)行計劃的簡要說明,當(dāng)前使用了 lv 這個字段的索引。如果是全表掃描,則是COLLSCAN
    "keysExamined": 20856, // 該項表明為了找出最終結(jié)果MongoDB搜索了索引中的多少個key
    "docsExamined": 20856, // 該項表明為了找出最終結(jié)果MongoDB搜索了多少個文檔
    "cursorExhausted": 1, // 該項表明本次查詢中游標(biāo)耗盡的次數(shù)
    "keyUpdates":0,
    "writeConflicts":0, // 寫沖突發(fā)生的數(shù)量,例如update一個正在被別的update操作的文檔
    "numYields":6801, //
    "nreturned":0, //
    "reslen":110, //
    "locks": { // 在
        Global: { acquireCount: { r: 13604 } },
        Database: { acquireCount: { r: 6802 } },
        Collection: { acquireCount: { r: 6802 } }
    },
    "protocol": "op_command",
    "millis" : 69132, // 從 MongoDB 操作開始到結(jié)束耗費(fèi)的時間,單位為ms
}

與索引相關(guān)的重要字段:

  • planSummary: 命令執(zhí)行計劃的簡要說明,當(dāng)前使用了 lv 這個字段的索引,如果是全表掃描,則是 COLLSCAN,則需要重點(diǎn)注意了,是否設(shè)計不合理;
  • keysExamined: 該項表明為了找出最終結(jié)果 MongoDB 搜索了索引 b 樹中的多少個 key;
  • docsExamined: , 該項表明為了找出最終結(jié)果 MongoDB 搜索了多少個文檔, 就是根據(jù) RecordId 往數(shù)據(jù) b 樹中查詢了多少次;
  • nreturned: 該操作最終返回文檔的數(shù)量。
責(zé)任編輯:趙寧寧 來源: 騰訊技術(shù)工程
相關(guān)推薦

2011-07-20 09:16:02

MongoDB索引稀疏索引

2013-11-19 10:08:06

MongoDB

2019-12-18 08:00:09

MySQL數(shù)據(jù)庫ORDER BY

2012-09-20 10:13:04

MongoDB

2020-02-12 19:01:22

索引B-樹B+樹

2011-03-28 13:29:22

MongoDB索引用法效率分析

2011-08-08 15:43:01

MySQL索引

2014-07-18 10:00:41

AFNetworkin

2021-07-29 10:08:15

NumPy索引切片

2017-11-20 11:05:23

數(shù)據(jù)庫MongoDB索引

2020-12-09 08:59:59

MongoDB復(fù)合索事故

2021-09-06 13:15:16

golang chan技巧語言

2022-11-01 08:02:04

2023-12-14 12:56:00

MongoDB數(shù)據(jù)庫優(yōu)化

2012-04-19 10:04:20

ibmdw

2021-07-27 05:05:46

MongoDB存儲Hangfire

2016-12-12 13:07:57

數(shù)據(jù)庫優(yōu)化SQL

2021-12-29 07:01:53

Mysql復(fù)合索引

2010-10-26 17:41:05

Oracle索引

2024-01-19 09:42:23

數(shù)據(jù)庫索引
點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號