1. 程式人生 > >mongo資料庫索引原理

mongo資料庫索引原理

一、索引的本質

索引(Index)是幫助資料庫高效獲取資料的資料結構。提取句子主幹,就可以得到索引的本質:索引是資料結構。

現在的資料庫(mongo,mysql等)索引多采用B-Tree資料結構,不懂BTree的同學先自行去了解下,個人覺得這篇文章比較易懂一些,http://www.cnblogs.com/coder2012/p/5309197.html

為什麼使用B-Tree(B+Tree)

紅黑樹等資料結構也可以用來實現索引,但是檔案系統及資料庫系統普遍採用B-/+Tree作為索引結構,這一節將結合計算機組成原理相關知識討論B-/+Tree作為索引的理論基礎。

一般來說,索引本身也很大,不可能全部儲存在記憶體中,因此索引往往以索引檔案的形式儲存在 磁碟上。這樣的話,索引查詢過程中就要產生磁碟I/O消耗,相對於記憶體存取,I/O存取的消耗要高几個數量級,所以評價一個數據結構作為索引的優劣最重要的指標就是在查詢過程中磁碟I/O操作次數的漸進複雜度。換句話說,索引的結構組織要儘量減少查詢過程中磁碟I/O的存取次數。下面先介紹記憶體和磁碟存取原理,然後再結合這些原理分析B-/+Tree作為索引的效率。

主存存取原理

目前計算機使用的主存基本都是隨機讀寫儲存器(RAM),現代RAM的結構和存取原理比較複雜,這裡本文拋卻具體差別,抽象出一個十分簡單的存取模型來說明RAM的工作原理。

MySQL索引背後的資料結構及演算法原理

圖5

從抽象角度看,主存是一系列的儲存單元組成的矩陣,每個儲存單元儲存固定大小的資料。每個儲存單元有唯一的地址,現代主存的編址規則比較複雜,這裡將其簡化成一個二維地址:通過一個行地址和一個列地址可以唯一定位到一個儲存單元。圖5展示了一個4 x 4的主存模型。

主存的存取過程如下:

當系統需要讀取主存時,則將地址訊號放到地址總線上傳給主存,主存讀到地址訊號後,解析訊號並定位到指定儲存單元,然後將此儲存單元資料放到資料匯流排上,供其它部件讀取。

寫主存的過程類似,系統將要寫入單元地址和資料分別放在地址匯流排和資料匯流排上,主存讀取兩個匯流排的內容,做相應的寫操作。

這裡可以看出,主存存取的時間僅與存取次數呈線性關係,因為不存在機械操作,兩次存取的資料的“距離”不會對時間有任何影響,例如,先取A0再取A1和先取A0再取D3的時間消耗是一樣的。

磁碟存取原理

上文說過,索引一般以檔案形式儲存在磁碟上,索引檢索需要磁碟I/O操作。與主存不同,磁碟I/O存在機械運動耗費,因此磁碟I/O的時間消耗是巨大的。

圖6是磁碟的整體結構示意圖。

MySQL索引背後的資料結構及演算法原理

圖6

一個磁碟由大小相同且同軸的圓形碟片組成,磁碟可以轉動(各個磁碟必須同步轉動)。在磁碟的一側有磁頭支架,磁頭支架固定了一組磁頭,每個磁頭負責存取一個磁碟的內容。磁頭不能轉動,但是可以沿磁碟半徑方向運動(實際是斜切向運動),每個磁頭同一時刻也必須是同軸的,即從正上方向下看,所有磁頭任何時候都是重疊的(不過目前已經有多磁頭獨立技術,可不受此限制)。

圖7是磁碟結構的示意圖。

MySQL索引背後的資料結構及演算法原理

圖7

碟片被劃分成一系列同心環,圓心是碟片中心,每個同心環叫做一個磁軌,所有半徑相同的磁軌組成一個柱面。磁軌被沿半徑線劃分成一個個小的段,每個段叫做一個扇區,每個扇區是磁碟的最小儲存單元。為了簡單起見,我們下面假設磁碟只有一個碟片和一個磁頭。

當需要從磁碟讀取資料時,系統會將資料邏輯地址傳給磁碟,磁碟的控制電路按照定址邏輯將邏輯地址翻譯成實體地址,即確定要讀的資料在哪個磁軌,哪個扇區。為了讀取這個扇區的資料,需要將磁頭放到這個扇區上方,為了實現這一點,磁頭需要移動對準相應磁軌,這個過程叫做尋道,所耗費時間叫做尋道時間,然後磁碟旋轉將目標扇區旋轉到磁頭下,這個過程耗費的時間叫做旋轉時間。

區域性性原理與磁碟預讀

由於儲存介質的特性,磁碟本身存取就比主存慢很多,再加上機械運動耗費,磁碟的存取速度往往是主存的幾百分之一,因此為了提高效率,要儘量減少磁碟I/O。為了達到這個目的,磁碟往往不是嚴格按需讀取,而是每次都會預讀,即使只需要一個位元組,磁碟也會從這個位置開始,順序向後讀取一定長度的資料放入記憶體。這樣做的理論依據是電腦科學中著名的區域性性原理:

當一個數據被用到時,其附近的資料也通常會馬上被使用。

程式執行期間所需要的資料通常比較集中。

由於磁碟順序讀取的效率很高(不需要尋道時間,只需很少的旋轉時間),因此對於具有區域性性的程式來說,預讀可以提高I/O效率。

預讀的長度一般為頁(page)的整倍數。頁是計算機管理儲存器的邏輯塊,硬體及作業系統往往將主存和磁碟儲存區分割為連續的大小相等的塊,每個儲存塊稱為一頁(在許多作業系統中,頁得大小通常為4k),主存和磁碟以頁為單位交換資料。當程式要讀取的資料不在主存中時,會觸發一個缺頁異常,此時系統會向磁碟發出讀盤訊號,磁碟會找到資料的起始位置並向後連續讀取一頁或幾頁載入記憶體中,然後異常返回,程式繼續執行。

B-/+Tree索引的效能分析

到這裡終於可以分析B-/+Tree索引的效能了。

上文說過一般使用磁碟I/O次數評價索引結構的優劣。先從B-Tree分析,根據B-Tree的定義,可知檢索一次最多需要訪問h(h為數高)個節點。資料庫系統的設計者巧妙利用了磁碟預讀原理,將一個節點的大小設為等於一個頁,這樣每個節點只需要一次I/O就可以完全載入。為了達到這個目的,在實際實現B-Tree還需要使用如下技巧:

每次新建節點時,直接申請一個頁的空間,這樣就保證一個節點物理上也儲存在一個頁裡,加之計算機儲存分配都是按頁對齊的,就實現了一個node只需一次I/O。

B-Tree中一次檢索最多需要h-1次I/O(根節點常駐記憶體),漸進複雜度為O(h)=O(logdN)。一般實際應用中,出度d是非常大的數字,通常超過100,因此h非常小(通常不超過3)。

綜上所述,用B-Tree作為索引結構效率是非常高的。

而紅黑樹這種結構,h明顯要深的多。由於邏輯上很近的節點(父子)物理上可能很遠,無法利用區域性性,所以紅黑樹的I/O漸進複雜度也為O(h),效率明顯比B-Tree差很多。

B+Tree更適合外存索引,原因和內節點出度d有關。從上面分析可以看到,d越大索引的效能越好,而出度的上限取決於節點內key和data的大小:

dmax = floor(pagesize / (keysize + datasize + pointsize))   (pagesize – dmax >= pointsize)

dmax = floor(pagesize / (keysize + datasize + pointsize)) – 1   (pagesize – dmax < pointsize)

floor表示向下取整。由於B+Tree內節點去掉了data域,因此可以擁有更大的出度,擁有更好的效能。

二、mongo中的索引

為什麼需要索引?

當你抱怨MongoDB集合查詢效率低的時候,可能你就需要考慮使用索引了,為了方便後續介紹,先科普下MongoDB裡的索引機制(同樣適用於其他的資料庫比如mysql)。

mongo-9552:PRIMARY&gt; db.person.find()
{ "_id" : ObjectId("571b5da31b0d530a03b3ce82"), "name" : "jack", "age" : 19 }
{ "_id" : ObjectId("571b5dae1b0d530a03b3ce83"), "name" : "rose", "age" : 20 }
{ "_id" : ObjectId("571b5db81b0d530a03b3ce84"), "name" : "jack", "age" : 18 }
{ "_id" : ObjectId("571b5dc21b0d530a03b3ce85"), "name" : "tony", "age" : 21 }
{ "_id" : ObjectId("571b5dc21b0d530a03b3ce86"), "name" : "adam", "age" : 18 }

當你往某各個集合插入多個文件後,每個文件在經過底層的儲存引擎持久化後,會有一個位置資訊,通過這個位置資訊,就能從儲存引擎裡讀出該文件。比如mmapv1引擎裡,位置資訊是『檔案id + 檔案內offset 』, 在wiredtiger儲存引擎(一個KV儲存引擎)裡,位置資訊是wiredtiger在儲存文件時生成的一個key,通過這個key能訪問到對應的文件;為方便介紹,統一用pos(position的縮寫)來代表位置資訊。

比如上面的例子裡,person集合裡包含插入了4個文件,假設其儲存後位置資訊如下(為方便描述,文件省去_id欄位)

位置資訊 文件
pos1 {“name” : “jack”, “age” : 19 }
pos2 {“name” : “rose”, “age” : 20 }
pos3 {“name” : “jack”, “age” : 18 }
pos4 {“name” : “tony”, “age” : 21}
pos5 {“name” : “adam”, “age” : 18}

假設現在有個查詢 db.person.find( {age: 18} ), 查詢所有年齡為18歲的人,這時需要遍歷所有的文件(『全表掃描』),根據位置資訊讀出文件,對比age欄位是否為18。當然如果只有4個文件,全表掃描的開銷並不大,但如果集合文件數量到百萬、甚至千萬上億的時候,對集合進行全表掃描開銷是非常大的,一個查詢耗費數十秒甚至幾分鐘都有可能。

如果想加速 db.person.find( {age: 18} ),就可以考慮對person表的age欄位建立索引

db.person.createIndex( {age: 1} )  // 按age欄位建立升序索引

建立索引後,MongoDB會額外儲存一份按age欄位升序排序的索引資料,索引結構類似如下,索引通常採用類似btree的結構持久化儲存,以保證從索引裡快速(O(logN)的時間複雜度)找出某個age值對應的位置資訊,然後根據位置資訊就能讀取出對應的文件。

AGE 位置資訊
18 pos3
18 pos5
19 pos1
20 pos2
21 pos4

簡單的說,索引就是將文件按照某個(或某些)欄位順序組織起來,以便能根據該欄位高效的查詢。有了索引,至少能優化如下場景的效率:

  • 查詢,比如查詢年齡為18的所有人
  • 更新/刪除,將年齡為18的所有人的資訊更新或刪除,因為更新或刪除時,需要根據條件先查詢出所有符合條件的文件,所以本質上還是在優化查詢
  • 排序,將所有人的資訊按年齡排序,如果沒有索引,需要全表掃描文件,然後再對掃描的結果進行排序

眾所周知,MongoDB預設會為插入的文件生成_id欄位(如果應用本身沒有指定該欄位),_id是文件唯一的標識,為了保證能根據文件id快遞查詢文件,MongoDB預設會為集合建立_id欄位的索引。

mongo-9552:PRIMARY&gt; db.person.getIndexes() // 查詢集合的索引資訊
[
    {
        "ns" : "test.person",  // 集合名
        "v" : 1,               // 索引版本
        "key" : {              // 索引的欄位及排序方向
            "_id" : 1           // 根據_id欄位升序索引
        },
        "name" : "_id_"        // 索引的名稱
    }
]

MongoDB索引型別

MongoDB支援多種型別的索引,包括單欄位索引、複合索引、多key索引、文字索引等,每種型別的索引有不同的使用場合。

單欄位索引 (Single Field Index)

    db.person.createIndex( {age: 1} ) 

上述語句針對age建立了單欄位索引,其能加速對age欄位的各種查詢請求,是最常見的索引形式,MongoDB預設建立的id索引也是這種型別。

{age: 1} 代表升序索引,也可以通過{age: -1}來指定降序索引,對於單欄位索引,升序/降序效果是一樣的。

複合索引 (Compound Index)

複合索引是Single Field Index的升級版本,它針對多個欄位聯合建立索引,先按第一個欄位排序,第一個欄位相同的文件按第二個欄位排序,依次類推,如下針對age, name這2個欄位建立一個複合索引。

    db.person.createIndex( {age: 1, name: 1} ) 

上述索引對應的資料組織類似下表,與{age: 1}索引不同的時,當age欄位相同時,在根據name欄位進行排序,所以pos5對應的文件排在pos3之前。

AGE,NAME 位置資訊
18,adam pos5
18,jack pos3
19,jack pos1
20,rose pos2
21,tony pos4

複合索引能滿足的查詢場景比單欄位索引更豐富,不光能滿足多個欄位組合起來的查詢,比如db.person.find( {age: 18, name: "jack"} ),也能滿足所以能匹配符合索引字首的查詢,這裡{age: 1}即為{age: 1, name: 1}的字首,所以類似db.person.find( {age: 18} )的查詢也能通過該索引來加速;但db.person.find( {name: "jack"} )則無法使用該複合索引。如果經常需要根據『name欄位』以及『name和age欄位組合』來查詢,則應該建立如下的複合索引

db.person.createIndex( {name: 1, age: 1} ) 

除了查詢的需求能夠影響索引的順序,欄位的值分佈也是一個重要的考量因素,即使person集合所有的查詢都是『name和age欄位組合』(指定特定的name和age),欄位的順序也是有影響的。

age欄位的取值很有限,即擁有相同age欄位的文件會有很多;而name欄位的取值則豐富很多,擁有相同name欄位的文件很少;顯然先按name欄位查詢,再在相同name的文件裡查詢age欄位更為高效。

多key索引 (Multikey Index)

當索引的欄位為陣列時,創建出的索引稱為多key索引,多key索引會為陣列的每個元素建立一條索引,比如person表加入一個habbit欄位(陣列)用於描述興趣愛好,需要查詢有相同興趣愛好的人就可以利用habbit欄位的多key索引。

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

其他型別索引

雜湊索引(Hashed Index)是指按照某個欄位的hash值來建立索引,目前主要用於MongoDB Sharded Cluster的Hash分片,hash索引只能滿足欄位完全匹配的查詢,不能滿足範圍查詢等。

地理位置索引(Geospatial Index)能很好的解決O2O的應用場景,比如『查詢附近的美食』、『查詢某個區域內的車站』等。

文字索引(Text Index)能解決快速文字查詢的需求,比如有一個部落格文章集合,需要根據部落格的內容來快速查詢,則可以針對部落格內容建立文字索引。

索引額外屬性

MongoDB除了支援多種不同型別的索引,還能對索引定製一些特殊的屬性。

  • 唯一索引 (unique index):保證索引對應的欄位不會出現相同的值,比如_id索引就是唯一索引
  • TTL索引:可以針對某個時間欄位,指定文件的過期時間(經過指定時間後過期 或 在某個時間點過期)
  • 部分索引 (partial index): 只針對符合某個特定條件的文件建立索引,3.2版本才支援該特性
  • 稀疏索引(sparse index): 只針對存在索引欄位的文件建立索引,可看做是部分索引的一種特殊情況

查詢計劃

索引已經建立了,但查詢還是很慢怎麼破?這時就得深入的分析下索引的使用情況了,可通過檢視下詳細的查詢計劃來決定如何優化。通過執行計劃可以看出如下問題

  1. 根據某個/些欄位查詢,但沒有建立索引
  2. 根據某個/些欄位查詢,但建立了多個索引,執行查詢時沒有使用預期的索引。

建立索引前,db.person.find( {age: 18} )必須執行COLLSCAN,即全表掃描。

mongo-9552:PRIMARY&gt; db.person.find({age: 18}).explain()
{
    "queryPlanner" : {
        "plannerVersion" : 1,
        "namespace" : "test.person",
        "indexFilterSet" : false,
        "parsedQuery" : {
            "age" : {
                "$eq" : 18
            }
        },
        "winningPlan" : {
            "stage" : "COLLSCAN",
            "filter" : {
                "age" : {
                    "$eq" : 18
                }
            },
            "direction" : "forward"
        },
        "rejectedPlans" : [ ]
    },
    "serverInfo" : {
        "host" : "localhost",
        "port" : 9552,
        "version" : "3.2.3",
        "gitVersion" : "b326ba837cf6f49d65c2f85e1b70f6f31ece7937"
    },
    "ok" : 1
}

建立索引後,通過查詢計劃可以看出,先進行[IXSCAN]((https://docs.mongodb.org/manual/reference/explain-results/#queryplanner)(從索引中查詢),然後FETCH,讀取出滿足條件的文件。

mongo-9552:PRIMARY&gt; db.person.find({age: 18}).explain()
{
    "queryPlanner" : {
        "plannerVersion" : 1,
        "namespace" : "test.person",
        "indexFilterSet" : false,
        "parsedQuery" : {
            "age" : {
                "$eq" : 18
            }
        },
        "winningPlan" : {
            "stage" : "FETCH",
            "inputStage" : {
                "stage" : "IXSCAN",
                "keyPattern" : {
                    "age" : 1
                },
                "indexName" : "age_1",
                "isMultiKey" : false,
                "isUnique" : false,
                "isSparse" : false,
                "isPartial" : false,
                "indexVersion" : 1,
                "direction" : "forward",
                "indexBounds" : {
                    "age" : [
                        "[18.0, 18.0]"
                    ]
                }
            }
        },
        "rejectedPlans" : [ ]
    },
    "serverInfo" : {
        "host" : "localhost",
        "port" : 9552,
        "version" : "3.2.3",
        "gitVersion" : "b326ba837cf6f49d65c2f85e1b70f6f31ece7937"
    },
    "ok" : 1
}

三、注意事項

既然索引可以加快查詢速度,那麼是不是隻要是查詢語句需要,就建上索引?答案是否定的。因為索引雖然加快了查詢速度,但索引也是有代價的:索引檔案本身要消耗儲存空間,同時索引會加重插入、刪除和修改記錄時的負擔,另外,資料庫在執行時也要消耗資源維護索引,因此索引並不是越多越好。一般兩種情況下不建議建索引。

第一種情況是表記錄比較少,例如一兩千條甚至只有幾百條記錄的表,沒必要建索引,讓查詢做全表掃描就好了。至於多少條記錄才算多,這個個人有個人的看法,我個人的經驗是以2000作為分界線,記錄數不超過 2000可以考慮不建索引,超過2000條可以酌情考慮索引。

另一種不建議建索引的情況是索引的選擇性較低。所謂索引的選擇性(Selectivity),是指不重複的索引值(也叫基數,Cardinality)與表記錄數(#T)的比值:

Index Selectivity = Cardinality / #T

常見慢查詢:

1.不等於和不包含查詢

2.萬用字元在前面的模糊查詢, like '%xxx'

3.無索引的count 查詢 和 排序(複合索引順序不匹配)

4.多個範圍查詢(範圍列可以用到索引(必須是最左字首),但是範圍列後面的列無法用到索引

5.skip跳過過多的行數(優化方案:我們第一頁可以用db.article.find().limit(articles_of_each_page),並記錄最後一片文章的_id(或者其他排序值),之後查詢db.article.find({_id:{$lt:_id_stored}}).limit(articles_of_each_page)來查詢下一頁或者類似的,上一頁的文章,可以避免大量計數.

四、正確建立索引

在沒有建立索引的情況下,對Mongodb資料表進行查詢操作的時候,需要把資料都載入到記憶體。當資料的數量達到幾十萬乃至上百萬的時候,這樣的載入過程會對系統造成較大的衝擊,並影響到其他請求的處理過程。

索引是對資料庫表中一列或多列的值進行排序的一種結構,建立索引以後,對索引欄位進行查詢時,僅會載入索引資料,並能提高查詢速度。

1、建立合適的索引

為每一個查詢建立合適的索引。

組合索引是建立的索引由多個欄位組成,例如:

db.test.ensureIndex({"username":1, "age":-1}) #1是按升序排列,-1是按降序排列 

交叉索引是每個欄位單獨建立索引,但是在查詢的時候組合查詢,例如:

db.test.ensureIndex({"username":1}) db.test.ensureIndex({"age":-1}) db.test.find({"username":"kaka", "age": 30}) 

交叉索引的查詢效率較低,在使用時,當查詢使用到多個欄位的時候,儘量使用組合索引,而不是交叉索引。

2、組合索引的欄位排列順序

當我們的組合索引內容包含匹配條件以及範圍條件的時候,比如包含使用者名稱(匹配條件)以及年齡(範圍條件),那麼匹配條件應該放在範圍條件之前。

比如需要查詢:

db.test.find({"username":"kaka", "age": {$gt: 10}}) 

那麼組合索引應該這樣建立:

db.test.ensureIndex({"username":1, "age":-1}) 

3、查詢時儘可能僅查詢出索引欄位

有時候僅需要查詢少部分的欄位內容,而且這部分內容剛好都建立了索引,那麼儘可能只查詢出這些索引內容,需要用到的欄位顯式宣告(_id欄位需要顯式忽略!)。因為這些資料需要把原始資料文件從磁碟讀入記憶體,造成一定的損耗。

比如說我們的表有三個欄位:

username, age, mobile 

索引是這樣建立的:

db.test.ensureIndex({"username":1,"age":-1}) 

我們僅需要查到某個使用者的年齡(age),那可以這樣寫:

db.test.find({"username":"kaka"}, {"_id":0, "age":1}) 

注意到上面的語句,我們除了”age”:1外,還加了”_id”:0,因為預設情況下,_id都是會被一併查詢出來的,當不需要_id的時候記得直接忽略,避免不必要的磁碟操作。

4、對現有的資料大表建立索引的時候,採用後臺執行方式

在對資料集合建立索引的過程中,資料庫會停止該集合的所有讀寫操作,因此如果建立索引的資料量大,建立過程慢的情況下,建議採用後臺執行的方式,避免影響正常業務流程。

db.test.ensureIndex({"username":1,"age":-1},{"background":true}) #預設情況下background是false。

參考文章