1. 程式人生 > 其它 >資料結構與演算法_48 _ B+樹:MySQL資料庫索引是如何實現的

資料結構與演算法_48 _ B+樹:MySQL資料庫索引是如何實現的

作為一個軟體開發工程師,你對資料庫肯定再熟悉不過了。作為主流的資料儲存系統,它在我們的業務開發中,有著舉足輕重的地位。在工作中,為了加速資料庫中資料的查詢速度,我們常用的處理思路是,對錶中資料建立索引。那你是否思考過,資料庫索引是如何實現的呢?底層使用的是什麼資料結構和演算法呢?

演算法解析

思考的過程比結論更重要。跟著我學習了這麼多節課,很多同學已經意識到這一點,比如Jerry銀銀同學。我感到很開心。所以,今天的講解,我會盡量還原這個解決方案的思考過程,讓你知其然,並且知其所以然。

1.解決問題的前提是定義清楚問題

如何定義清楚問題呢?除了對問題進行詳細的調研,還有一個辦法,那就是,通過對一些模糊的需求進行假設,來限定

解決的問題的範圍

如果你對資料庫的操作非常瞭解,針對我們現在這個問題,你就能把索引的需求定義得非常清楚。但是,對於大部分軟體工程師來說,我們可能只瞭解一小部分常用的SQL語句,所以,這裡我們假設要解決的問題,只包含這樣兩個常用的需求:

  • 根據某個值查詢資料,比如select * from user where id=1234;

  • 根據區間值來查詢某些資料,比如select * from user where id > 1234 and id < 2345。

除了這些功能性需求之外,這種問題往往還會涉及一些非功能性需求,比如安全、效能、使用者體驗等等。限於專欄要討論的主要是資料結構和演算法,對於非功能性需求,我們著重考慮效能方面

的需求。效能方面的需求,我們主要考察時間和空間兩方面,也就是執行效率和儲存空間

在執行效率方面,我們希望通過索引,查詢資料的效率儘可能地高;在儲存空間方面,我們希望索引不要消耗太多的記憶體空間。

2.嘗試用學過的資料結構解決這個問題

問題的需求大致定義清楚了,我們現在回想一下,能否利用已經學習過的資料結構解決這個問題呢?支援快速查詢、插入等操作的動態資料結構,我們已經學習過散列表、平衡二叉查詢樹、跳錶。

我們先來看散列表。散列表的查詢效能很好,時間複雜度是O(1)。但是,散列表不能支援按照區間快速查詢資料。所以,散列表不能滿足我們的需求。

我們再來看平衡二叉查詢樹。儘管平衡二叉查詢樹查詢的效能也很高,時間複雜度是O(logn)。而且,對樹進行中序遍歷,我們還可以得到一個從小到大有序的資料序列,但這仍然不足以支援按照區間快速查詢資料。

我們再來看跳錶。跳錶是在連結串列之上加上多層索引構成的。它支援快速地插入、查詢、刪除資料,對應的時間複雜度是O(logn)。並且,跳錶也支援按照區間快速地查詢資料。我們只需要定位到區間起點值對應在連結串列中的結點,然後從這個結點開始,順序遍歷連結串列,直到區間終點對應的結點為止,這期間遍歷得到的資料就是滿足區間值的資料。

這樣看來,跳錶是可以解決這個問題。實際上,資料庫索引所用到的資料結構跟跳錶非常相似,叫作B+樹。不過,它是通過二叉查詢樹演化過來的,而非跳錶。為了給你還原發明B+樹的整個思考過程,所以,接下來,我還要從二叉查詢樹講起,看它是如何一步一步被改造成B+樹的。

3.改造二叉查詢樹來解決這個問題

為了讓二叉查詢樹支援按照區間來查詢資料,我們可以對它進行這樣的改造:樹中的節點並不儲存資料本身,而是隻是作為索引。除此之外,我們把每個葉子節點串在一條連結串列上,連結串列中的資料是從小到大有序的。經過改造之後的二叉樹,就像圖中這樣,看起來是不是很像跳錶呢?

改造之後,如果我們要求某個區間的資料。我們只需要拿區間的起始值,在樹中進行查詢,當查詢到某個葉子節點之後,我們再順著連結串列往後遍歷,直到連結串列中的結點資料值大於區間的終止值為止。所有遍歷到的資料,就是符合區間值的所有資料。

但是,我們要為幾千萬、上億的資料構建索引,如果將索引儲存在記憶體中,儘管記憶體訪問的速度非常快,查詢的效率非常高,但是,佔用的記憶體會非常多。

比如,我們給一億個資料構建二叉查詢樹索引,那索引中會包含大約1億個節點,每個節點假設佔用16個位元組,那就需要大約1GB的記憶體空間。給一張表建立索引,我們需要1GB的記憶體空間。如果我們要給10張表建立索引,那對記憶體的需求是無法滿足的。如何解決這個索引佔用太多記憶體的問題呢?

我們可以藉助時間換空間的思路,把索引儲存在硬碟中,而非記憶體中。我們都知道,硬碟是一個非常慢速的儲存裝置。通常記憶體的訪問速度是納秒級別的,而磁碟訪問的速度是毫秒級別的。讀取同樣大小的資料,從磁碟中讀取花費的時間,是從記憶體中讀取所花費時間的上萬倍,甚至幾十萬倍。

這種將索引儲存在硬碟中的方案,儘管減少了記憶體消耗,但是在資料查詢的過程中,需要讀取磁碟中的索引,因此資料查詢效率就相應降低很多。

二叉查詢樹,經過改造之後,支援區間查詢的功能就實現了。不過,為了節省記憶體,如果把樹儲存在硬碟中,那麼每個節點的讀取(或者訪問),都對應一次磁碟IO操作。樹的高度就等於每次查詢資料時磁碟IO操作的次數。

我們前面講到,比起記憶體讀寫操作,磁碟IO操作非常耗時,所以我們優化的重點就是儘量減少磁碟IO操作,也就是,儘量降低樹的高度。那如何降低樹的高度呢?

我們來看下,如果我們把索引構建成m叉樹,高度是不是比二叉樹要小呢?如圖所示,給16個數據構建二叉樹索引,樹的高度是4,查詢一個數據,就需要4個磁碟IO操作(如果根節點儲存在記憶體中,其他節點儲存在磁碟中),如果對16個數據構建五叉樹索引,那高度只有2,查詢一個數據,對應只需要2次磁碟操作。如果m叉樹中的m是100,那對一億個資料構建索引,樹的高度也只是3,最多隻要3次磁碟IO就能獲取到資料。磁碟IO變少了,查詢資料的效率也就提高了。

如果我們將m叉樹實現B+樹索引,用程式碼實現出來,就是下面這個樣子(假設我們給int型別的資料庫欄位新增索引,所以程式碼中的keywords是int型別的):

/**
 * 這是B+樹非葉子節點的定義。
 *
 * 假設keywords=[3, 5, 8, 10]
 * 4個鍵值將資料分為5個區間:(-INF,3), [3,5), [5,8), [8,10), [10,INF)
 * 5個區間分別對應:children[0]...children[4]
 *
 * m值是事先計算得到的,計算的依據是讓所有資訊的大小正好等於頁的大小:
 * PAGE_SIZE = (m-1)*4[keywordss大小]+m*8[children大小]
 */
 public class BPlusTreeNode {
    public static int m = 5; // 5叉樹
    public int[] keywords = new int[m-1]; // 鍵值,用來劃分資料區間
    public BPlusTreeNode[] children = new BPlusTreeNode[m];//儲存子節點指標
 }

/**

  • 這是B+樹中葉子節點的定義。
  • B+樹中的葉子節點跟內部節點是不一樣的,
  • 葉子節點儲存的是值,而非區間。
  • 這個定義裡,每個葉子節點儲存3個數據行的鍵值及地址資訊。
  • k值是事先計算得到的,計算的依據是讓所有資訊的大小正好等於頁的大小:
  • PAGE_SIZE = k4[keyw..大小]+k8[dataAd..大小]+8[prev大小]+8[next大小]
    */
    public class BPlusTreeLeafNode {
    public static int k = 3;
    public int[] keywords = new int[k]; // 資料的鍵值
    public long[] dataAddress = new long[k]; // 資料地址

public BPlusTreeLeafNode prev; // 這個結點在連結串列中的前驅結點
public BPlusTreeLeafNode next; // 這個結點在連結串列中的後繼結點
}

我稍微解釋一下這段程式碼。

對於相同個數的資料構建m叉樹索引,m叉樹中的m越大,那樹的高度就越小,那m叉樹中的m是不是越大越好呢?到底多大才最合適呢?

不管是記憶體中的資料,還是磁碟中的資料,作業系統都是按頁(一頁大小通常是4KB,這個值可以通過getconfig PAGE_SIZE命令檢視)來讀取的,一次會讀一頁的資料。如果要讀取的資料量超過一頁的大小,就會觸發多次IO操作。所以,我們在選擇m大小的時候,要儘量讓每個節點的大小等於一個頁的大小。讀取一個節點,只需要一次磁碟IO操作。

儘管索引可以提高資料庫的查詢效率,但是,作為一名開發工程師,你應該也知道,索引有利也有弊,它也會讓寫入資料的效率下降。這是為什麼呢?

資料的寫入過程,會涉及索引的更新,這是索引導致寫入變慢的主要原因。

對於一個B+樹來說,m值是根據頁的大小事先計算好的,也就是說,每個節點最多隻能有m個子節點。在往資料庫中寫入資料的過程中,這樣就有可能使索引中某些節點的子節點個數超過m,這個節點的大小超過了一個頁的大小,讀取這樣一個節點,就會導致多次磁碟IO操作。我們該如何解決這個問題呢?

實際上,處理思路並不複雜。我們只需要將這個節點分裂成兩個節點。但是,節點分裂之後,其上層父節點的子節點個數就有可能超過m個。不過這也沒關係,我們可以用同樣的方法,將父節點也分裂成兩個節點。這種級聯反應會從下往上,一直影響到根節點。這個分裂過程,你可以結合著下面這個圖一塊看,會更容易理解(圖中的B+樹是一個三叉樹。我們限定葉子節點中,資料的個數超過2個就分裂節點;非葉子節點中,子節點的個數超過3個就分裂節點)。

正是因為要時刻保證B+樹索引是一個m叉樹,所以,索引的存在會導致資料庫寫入的速度降低。實際上,不光寫入資料會變慢,刪除資料也會變慢。這是為什麼呢?

我們在刪除某個資料的時候,也要對應地更新索引節點。這個處理思路有點類似跳錶中刪除資料的處理思路。頻繁的資料刪除,就會導致某些節點中,子節點的個數變得非常少,長此以往,如果每個節點的子節點都比較少,勢必會影響索引的效率。

我們可以設定一個閾值。在B+樹中,這個閾值等於m/2。如果某個節點的子節點個數小於m/2,我們就將它跟相鄰的兄弟節點合併。不過,合併之後節點的子節點個數有可能會超過m。針對這種情況,我們可以藉助插入資料時候的處理方法,再分裂節點。

文字描述不是很直觀,我舉了一個刪除操作的例子,你可以對比著看下(圖中的B+樹是一個五叉樹。我們限定葉子節點中,資料的個數少於2個就合併節點;非葉子節點中,子節點的個數少於3個就合併節點。)。

資料庫索引以及B+樹的由來,到此就講完了。你有沒有發現,B+樹的結構和操作,跟跳錶非常類似。理論上講,對跳錶稍加改造,也可以替代B+樹,作為資料庫的索引實現的。

B+樹發明於1972年,跳錶發明於1989年,我們可以大膽猜想下,跳錶的作者有可能就是受了B+樹的啟發,才發明出跳表來的。不過,這個也無從考證了。

總結引申

今天,我們講解了資料庫索引實現,依賴的底層資料結構,B+樹。它通過儲存在磁碟的多叉樹結構,做到了時間、空間的平衡,既保證了執行效率,又節省了記憶體。

前面的講解中,為了一步一步詳細地給你介紹B+樹的由來,內容看起來比較零散。為了方便你掌握和記憶,我這裡再總結一下B+樹的特點:

  • 每個節點中子節點的個數不能超過m,也不能小於m/2;

  • 根節點的子節點個數可以不超過m/2,這是一個例外;

  • m叉樹只儲存索引,並不真正儲存資料,這個有點兒類似跳錶;

  • 通過連結串列將葉子節點串聯在一起,這樣可以方便按區間查詢;

  • 一般情況,根節點會被儲存在記憶體中,其他節點儲存在磁碟中。

除了B+樹,你可能還聽說過B樹、B-樹,我這裡簡單提一下。實際上,B-樹就是B樹,英文翻譯都是B-Tree,這裡的“-”並不是相對B+樹中的“+”,而只是一個連線符。這個很容易誤解,所以我強調下。

而B樹實際上是低階版的B+樹,或者說B+樹是B樹的改進版。B樹跟B+樹的不同點主要集中在這幾個地方:

  • B+樹中的節點不儲存資料,只是索引,而B樹中的節點儲存資料;

  • B樹中的葉子節點並不需要連結串列來串聯。

也就是說,B樹只是一個每個節點的子節點個數不能小於m/2的m叉樹。

課後思考

  1. B+樹中,將葉子節點串起來的連結串列,是單鏈表還是雙向連結串列?為什麼?

  2. 我們對平衡二叉查詢樹進行改造,將葉子節點串在連結串列中,就支援了按照區間來查詢資料。我們在散列表(下)講到,散列表也經常跟連結串列一塊使用,如果我們把散列表中的結點,也用連結串列串起來,能否支援按照區間查詢資料呢?

歡迎留言和我分享,也歡迎點選“請朋友讀”,把今天的內容分享給你的好友,和他一起討論、學習。