1. 程式人生 > >索引的實現原理

索引的實現原理

這篇文章是介紹MySQL資料庫中的索引是如何根據需求一步步演變最終成為B+樹結構的以及針對B+樹索引的查詢,插入,刪除,更新等操作的處理方法。Oracle和DB2資料庫索引的實現基本上也是大同小異的。文章寫得很通俗易懂,就轉在這了。關於B+樹和索引內部結構可以參考:《B 樹、B- 樹、B+ 樹和B* 樹》和《深入理解DB2索引(Index)》。

00 – 背景知識

- B-Tree & B+Tree

- 折半查詢(Binary Search)

- 資料庫的效能問題

  A. 磁碟IO效能非常低,嚴重的影響資料庫系統的效能。
  B. 磁碟順序讀寫比隨機讀寫的效能高很多。

- 資料的基本儲存結構

  A. 磁碟空間被劃分為許多大小相同的塊(Block)或者頁(Page).
  B. 一個表的這些資料塊以連結串列的方式串聯在一起。
  C. 資料是以行(Row)為單位一行一行的存放在磁碟上的塊中,如圖所示.
  D. 在訪問資料時,一次從磁碟中讀出或者寫入至少一個完整的Block。

                                              Fig. 1


01 – 資料基本操作的實現

  基本操作包括:INSERT、UPDATE、DELETE、SELECT。

- SELECT

  A. 定位資料
  B. 讀出資料所在的塊,對資料加工
  C. 返回資料給使用者

- UPDATE、DELETE

  A. 定位資料
  B. 讀出資料所在的塊,修改資料
  C. 寫回磁碟

- INSERT

  A. 定位資料要插入的頁(如果資料需要排序)
  B. 讀出要插入的資料頁,插入資料.
  C. 寫回磁碟

如何定位資料?
- 表掃描(Table Scan)

  A. 從磁碟中依次讀出所有的資料塊,一行一行的進行資料匹配。
  B. 時間複雜度 是O(n), 如果所有的資料佔用了100個塊。儘管只查詢一行資料,
     也需要讀出所有100個塊的資料。
  C. 需要大量的磁碟IO操作,極大的影響了資料定位的效能。

因為資料定位操作是所有資料操作必須的操作,資料定位操作的效率會直接影響所有的資料操作的效率。


因此我們開始思考,如何來減少磁碟的IO?
- 減少磁碟IO

  A. 減少資料佔用的磁碟空間
     壓縮演算法、優化資料儲存結構
  B. 減少訪問資料的總量
     讀出或寫入的資料中,有一部分是資料操作所必須的,這部分稱作有效資料。剩餘的
     部分則不是資料操作必須的資料,稱為無效資料。例如,查詢姓名是‘張三’的記錄。
     那麼這條記錄是有效記錄,其他記錄則是無效記錄。我們要努力減少無效資料的訪問。

02 – 索引的產生

- 鍵(Key)

  首先,我們發現在多數情況下,定位操作並不需要匹配整行資料。而是很規律的只匹配某一個
  或幾個列的值。 例如,圖中第1列就可以用來確定一條記錄。這些用來確定一條資料的列,統 
  稱為鍵(Key).

                    Fig. 2

- Dense Index

  根據減少無效資料訪問的原則,我們將鍵的值拿過來存放到獨立的塊中。並且為每一個鍵值添
  加一個指標, 指向原來的資料塊。如圖所示,


                            Fig. 3

  這就是‘索引’的祖先Dense Index. 當進行定位操作時,不再進行表掃描。而是進行
  索引掃描(Index Scan),依次讀出所有的索引塊,進行鍵值的匹配。當找到匹配的鍵值後,
  根據該行的指標直接讀取對應的資料塊,進行操作。假設一個塊中能儲存100行資料,
  10,000,000行的資料需要100,000個塊的儲存空間。假設鍵值列(+指標)佔用一行資料
  1/10的空間。那麼大約需要10,000個塊來儲存Dense索引。因此我們用大約1/10的額外儲存
  空間換來了大約全表掃描10倍的定位效率。

03 – 索引的進化

  在實際的應用中,這樣的定位效率仍然不能滿足需求。很多人可能已經想到了,通過排序和查詢
  演算法來減少IO的訪問。因此我們開始嘗試對Dense Index進行排序儲存,並且期望利用排序查
  找演算法來減少磁碟IO。

- 折半塊查詢

  A. 對Dense Index排序
  B. 需要一個數組按順序儲存索引塊地址。以塊為單位,不儲存所有的行的地址。
  C. 這個索引塊地址陣列,也要儲存到磁碟上。將其單獨存放在一個塊鏈中,如下圖所示。
  D. 折半查詢的時間複雜度是O(log 2(N))。在上面的列子中,dense索引總共有10,000個塊。假設1個塊
     能儲存2000個指標,需要5個塊來儲存這個陣列。通過折半塊查詢,我們最多隻需要讀取
     5(陣列塊)+ 14(索引塊log 2(10000))+1(資料塊)=20個塊。


                                                                Fig. 4

 - Sparse Index

  實現基於塊的折半查詢時發現,讀出每個塊後只需要和第一行的鍵值匹配,就可以決定下一個塊
  的位置(方向)。 因此有效資料是每個塊(最後一個塊除外)的第一行的資料。還是根據減少無
  效資料IO的原則,將每一個塊的第一行的資料單獨拿出來,和索引陣列的地址放到一起。這樣就
  可以直接在這個陣列上進行折半查找了。如下圖所示,這個陣列就進化成了Sparse Index


                                                        Fig. 5

  因為Sparse Index和Dense Index的儲存結構是相同的,所以佔用的空間也相同。大約需
  要10個塊來儲存10000個Dense Index塊的地址和首行鍵值。通過Sparse索引,僅需要讀
  取10(Sparse塊)+1(Dense塊)+1(資料塊)=12個塊.

- 多層Sparse Index

  因為Sparse Index本身是有序的,所以可以為Sparse Index再建sparse Index。通過
  這個方法,一層一層的建立 Sparse Indexes,直到最上層的Sparse Index只佔用一個塊
  為止,如下圖所示.


                                       Fig. 6

  A. 這個最上層的Sparse Index稱作整個索引樹的根(root).
  B. 每次進行定位操作時,都從根開始查詢。
  C. 每層索引只需要讀出一個塊。
  D. 最底層的Dense Index或資料稱作葉子(leaf).
  E. 每次查詢都必須要搜尋到葉子節點,才能定位到資料。
  F. 索引的層數稱作索引樹的高度(height).
  G. 索引的IO效能和索引樹的高度密切相關。索引樹越高,磁碟IO越多。

  在我們的例子中的Sparse Index,只有10個塊,因此我們只需要再建立一個Sparse Index.
  通過兩層Sparse Index和一層Dense Index查詢時,只需讀取1+1+1+1=4個塊。

- Dense Index和Sparse Index的區別

  A. Dense Index包含所有資料的鍵值,但是Sparse Index僅包含部分鍵值。
     Sparse Index佔用更少的磁碟空間。
  B. Dense Index指向的資料可以是無序的,但是Sparse Index的資料必須是有序的。
  C. Sparse Index 可以用來做索引的索引,但是Dense Index不可以。
  D. 在資料是有序的時候,Sparse Index更有效。因此Dense Index僅用於無序的資料。
  E. 索引掃描(Index Scan)實際上是對Dense Index層進行遍歷。

- 簇索引(Clustered Index)和輔助索引(Secondary Index)

  如果資料本身是基於某個Key來排序的,那麼可以直接在資料上建立sparse索引,
  而不需要建立一個dense索引層(可以認為資料就是dense索引層)。 如下圖所示:


                                                Fig. 7

  這個索引就是我們常說的“Clustered Index”,而用來排序資料的鍵叫做主鍵Primary Key.

  A. 一個表只能有一個Clustered Index,因為資料只能根據一個鍵排序.
  B. 用其他的鍵來建立索引樹時,必須要先建立一個dense索引層,在dense索引層上對此鍵的值
     進行排序。這樣的索引樹稱作Secondary Index.
  C. 一個表上可以有多個Secondary Index.
  D. 對簇索引進行遍歷,實際上就是對資料進行遍歷。因此簇索引的遍歷效率比輔組索引低。
     如SELECT count(*) 操作,使用輔組索引遍歷的效率更高。

- 範圍搜尋(Range Search)

  由於鍵值是有序的,因此可以進行範圍查詢。只需要將資料塊、Dense Index塊分別以雙向連結串列
  的方式進行連線, 就可以實現高效的範圍查詢。如下圖所示:


                                                Fig. 8   範圍查詢的過程:   A. 選擇一個合適的邊界值,定位該值資料所在的塊   B. 然後選擇合適的方向,在資料塊(或Dense Index塊)鏈中進行遍歷。   C. 直到資料不滿足另一個邊界值,結束範圍查詢。 是不是看著這個索引樹很眼熟?換個角度看看這個圖吧!


    Fig. 9

這分明就是傳說中的B+Tree.
- 索引上的操作
  A. 插入鍵值
  B. 刪除鍵值
  C. 分裂一個節點
  D. 合併兩個節點
這些操作在教科書上都有介紹,這裡就不介紹了。
先寫到這吧,實在寫不動了,想明白容易,寫明白就難了。下一篇裡,打算談談標準B+Tree的幾個問題,以及在
實現過程中,B+Tree的一些變形。

教科書上的B+Tree是一個簡化了的,方便於研究和教學的B+Tree。然而在資料庫實現時,為了
更好的效能或者降低實現的難度,都會在細節上進行一定的變化。下面以InnoDB為例,來說說
這些變化。

04 - Sparse Index中的資料指標

  在“由淺入深理解索引的實現(1)”中提到,Sparse Index中的每個鍵值都有一個指標指向
  所在的資料頁。這樣每個B+Tree都有指標指向資料頁。如圖Fig.10所示:


Fig.10

  如果資料頁進行了拆分或合併操作,那麼所有的B+Tree都需要修改相應的頁指標。特別是
  Secondary B+Tree(輔助索引對應的B+Tree), 要對很多個不連續的頁進行修改。同時也需要對
  這些頁加鎖,這會降低併發性。

  為了降低難度和增加更新(分裂和合並B+Tree節點)的效能,InnoDB 將 Secondary B+Tree中
  的指標替換成了主鍵的鍵值。如圖Fig.11所示:


Fig.11

  這樣就去除了Secondary B+Tree對資料頁的依賴,而資料就變成了Clustered B+Tree(簇
  索引對應的B+Tree)獨佔的了。對資料頁的拆分及合併操作,僅影響Clustered B+Tree. 因此
  InnoDB的資料檔案中儲存的實際上就是多個孤立B+Tree。

  一個有趣的問題,當用戶顯式的把主鍵定義到了二級索引中時,還需要額外的主鍵來做二級索引的
  資料嗎(即儲存2份主鍵)? 很顯然是不需要的。InnoDB在建立二級索引的時候,會判斷主鍵的欄位
  是否已經被包含在了要建立的索引中。

  接下來看一下資料操作在B+Tree上的基本實現。

- 用主鍵查詢

  直接在Clustered B+Tree上查詢。

- 用輔助索引查詢
  A. 在Secondary B+Tree上查詢到主鍵。
  B. 用主鍵在Clustered B+Tree

可以看出,在使用主鍵值替換頁指標後,輔助索引的查詢效率降低了。
  A. 儘量使用主鍵來查詢資料(索引遍歷操作除外).
  B. 可以通過快取來彌補效能,因此所有的鍵列,都應該儘量的小。

- INSERT
  A. 在Clustered B+Tree上插入資料
  B. 在所有其他Secondary B+Tree上插入主鍵。

- DELETE
  A. 在Clustered B+Tree上刪除資料。
  B. 在所有其他Secondary B+Tree上刪除主鍵。

- UPDATE 非鍵列
  A. 在Clustered B+Tree上更新資料。

- UPDATE 主鍵列
  A. 在Clustered B+Tree刪除原有的記錄(只是標記為DELETED,並不真正刪除)。
  B. 在Clustered B+Tree插入新的記錄。
  C. 在每一個Secondary B+Tree上刪除原有的資料。(有疑問,看下一節。)
  D. 在每一個Secondary B+Tree上插入原有的資料。

- UPDATE 輔助索引的鍵值
  A. 在Clustered B+Tree上更新資料。
  B. 在每一個Secondary B+Tree上刪除原有的主鍵。
  C. 在每一個Secondary B+Tree上插入原有的主鍵。

更新鍵列時,需要更新多個頁,效率比較低。
  A. 儘量不用對主鍵列進行UPDATE操作。
  B. 更新很多時,儘量少建索引。

05 – 非唯一鍵索引

  教科書上的B+Tree操作,通常都假設”鍵值是唯一的“。但是在實際的應用中Secondary Index是允
  許鍵值重複的。在極端的情況下,所有的鍵值都一樣,該如何來處理呢?
  InnoDB 的 Secondary B+Tree中,主鍵也是此鍵的一部分。
  Secondary Key = 使用者定義的KEY + 主鍵。如圖Fig.12所示:


Fig.12

  注意主鍵不僅做為資料出現在葉子節點,同時也作為鍵的一部分出現非葉子節點。對於非唯一鍵來說,
  因為主鍵是唯一的,Secondary Key也是唯一的。當然,在插入資料時,還是會根據使用者定義的Key,
  來判斷唯一性。按理說,如果輔助索引是唯一的(並且所有欄位不能為空),就不需要這樣做。可是,
  InnoDB對所有的Secondary B+Tree都這樣建立。

還沒弄明白有什麼特殊的用途?有知道的朋友可以幫忙解答一下。
也許是為了降低程式碼的複雜性,這是我想到的唯一理由。

弄清楚了,即便是非空唯一鍵,在二級索引的B+Tree中也可能重複,因此必須要將主鍵加入到非葉子節點。

06 – <Key, Pointer>對

  標準的B+Tree的每個節點有K個鍵值和K+1個指標,指向K+1個子節點。如圖Fig.13:


Fig.13(圖片來自於WikiPedia)

  而在“由淺入深理解索引的實現(1)”中Fig.9的B+Tree上,每個節點有K個鍵值和K個指標。
  InnoDB的B+Tree也是如此。如圖Fig.14所示:


Fig.14

  這樣做的好處在於,鍵值和指標一一對應。我們可以將一個<Key,Pointer>對看作一條記錄。
  這樣就可以用資料塊的儲存格式來儲存索引塊。因為不需要為索引塊定義單獨的儲存格式,就
  降低了實現的難度。

- 插入最小值

  當考慮在變形後的B+Tree上進行INSERT操作時,發現了一個有趣的問題。如果插入的資料的健
  值比B+Tree的最小鍵值小時,就無法定位到一個適當的資料塊上去(<Key,Pointer>中的Key
  代表了子節點上的鍵值是>=Key的)。例如,在Fig.5的B+Tree中插入鍵值為0的資料時,無法
  定位到任何節點。

  在標準的B+Tree上,這樣的鍵值會被定位到最左側的節點上去。這個做法,對於Fig.5中的
  B+Tree也是合理的。Innodb的做法是,將每一層(葉子層除外)的最左側節點的第一條記錄標
  記為最小記錄(MIN_REC).在進行定位操作時,任何鍵值都比標記為MIN_REC的鍵值大。因此0
  會被插入到最左側的記錄節點上。如Fig.15所示:


                                               Fig.15

07 – 順序插入資料

  Fig.16是B-Tree的插入和分裂過程,我們看看有沒有什麼問題?


Fig.16(圖片來自於WikiPedia)

  標準的B-Tree分裂時,將一半的鍵值和資料移動到新的節點上去。原有節點和新節點都保留一半
  的空間,用於以後的插入操作。當按照鍵值的順序插入資料時,左側的節點不可能再有新的資料插入。
  因此,會浪費約一半的儲存空間。

  解決這個問題的基本思路是:分裂順序插入的B-Tree時,將原有的資料都保留在原有的節點上。
  建立一個新的節點,用來儲存新的資料。順序插入時的分裂過程如Fig.17所示:


Fig.17

  以上是以B-Tree為例,B+Tree的分裂過程類似。InnoDB的實現以這個思路為基礎,不過要複雜
  一些。因為順序插入是有方向性的,可能是從小到大,也可能是從大到小的插入資料。所以要區
  分不同的情況。如果要了解細節,可參考以下函式的程式碼。
    btr_page_split_and_insert();
    btr_page_get_split_rec_to_right();
    btr_page_get_split_rec_to_right();

InnoDB的程式碼太複雜了,有時候也不敢肯定自己的理解是對的。因此寫了一個小指令碼,來列印InnoDB數 據檔案中B+Tree。這樣可以直觀的來觀察B+Tree的結構,驗證自己的理解是否正確。 很多知識來自於下面這兩本書。 “Database Systems: The Complete Book (2nd Edition) ”