B+樹:MySQL數據庫索引的數據結構
1.理清需求
對于數據庫兩個最基本的查詢需求:
- 根據某個值查找數據,比如select * from user where id = 1234;
- 根據區間值來查找某些數據,比如select * from user where id > 1234 and id < 2345
即單值查找和區間查找
2.嘗試用已知的數據結構解決這個問題
支持快速查詢、插入等操作的動態數據結構有散列表、平衡二叉查找樹、跳表。
先看散列表,散列表的查詢性能的時間復雜度是O(1),但散列表不能支持按照區間快速查找數據,所以散列表不能滿足需求。
再看平衡二叉樹,查詢性能的時間復雜度是O(logn)。對樹進行中序遍歷可以得到一個從小到大有序的數據序列,但不支持按照區間快速查找數據。
最后看跳表。跳表是再鏈表上加上多層索引構成的。它支持快速地插入、查找、刪除數據,對應的時間復雜度是O(logn)。并且,跳表也支持按照區間快速地查找數據。只需要定位到區間起始值對應在鏈表中的節點,然后從這個節點開始,順序遍歷鏈表,直到區間終點對應的節點為止,這期間遍歷得到的數據就是滿足區間值的數據。
3.改造二叉查找樹來解決這個問題
結合平衡二叉查找樹和跳表的優點進行改造:樹中的節點并不存儲數據本身,而是只是作為索引。另外,把每個葉子節點串在一條鏈表上,鏈表中的數據是從小到大有序的。
如下圖:
為了讓二叉查找樹支持按照區間來查找數據,我們可以對它進行這樣的改造:樹種的節點并不存儲數據本身,而是只是作為索引。除此之外,我們把每個葉子節點串在一條鏈表上,鏈表種的數據是從小到大有序的。經過改造之后的二叉樹,就像圖中這樣,看起來是不是很像跳表呢?
改造之后要求某個區間的數據,只需要拿區間的起始值,在樹中進行查找,當查找到某個葉子節點之后,我們再順著鏈表往后遍歷,直到鏈表中的節點數據值大于區間的終止值為止。所有遍歷到的數據,就是符合區間值的所有數據。
索引的內存占用可能比較高,比如給一億個數據構建二叉查找樹索引,那索引中包含大于1億個節點,每個節點假設占用16個字節,那就需要大約1GB的內存空間。給一張表建立索引,需要1GB的內存空間。如果要給10張表建立索引,內存就可能超過了單臺機器的承受極限。
4.如何優化減少索引的內存占用呢?
可以借助時間換空間的思路,把索引存儲在硬盤中,而非內存中。但硬盤是一個非常慢速的存儲設備。通常內存的訪問速度是納秒級別的,而磁盤的訪問速度是毫秒級別的。讀取同樣大小的數據,從磁盤中讀取花費的時間,是從內存中讀取所花費時間的上萬倍,甚至幾十萬倍。
把樹結構的索引存儲在硬盤中,在數據查找的過程中需讀取n個樹節點(n表示樹的高度),每個節點的讀取都對應一次磁盤io操作,即每次查詢數據時磁盤IO操作的次數就等于樹的高度。
那么只有降低樹的高度就可以減少磁盤IO次數。
5.如何降低樹的高度呢?
如果把索引構建成m叉樹,高度是不是比二叉樹要小呢?
比如下圖,給16個數據構建二叉樹索引,樹的高度是4。如果構建構建五叉樹索引,那高度只有2.
如果m叉樹中的m是100,那對一億個數據構建索引,樹的高度也只是3。
m叉樹實現B+樹索引的java代碼描述:
/**
* 這是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[children 大小] + m * 8[children 大小]
*/
public class BPlusTreeNode{
public static int m = 5; //五叉樹
public int[] keywords = new int[m - 1]; // 鍵值,用來劃分數據區間
public BPlusTreeNode[] children = new BPlusTreeNode[m]; // 保存子節點指針
}
/**
* 這是B+樹中葉子節點的定義
*
* B+樹種葉子節點跟內部節點是不一樣的
* 葉子節點存儲的值,而非區間
* 這個定義里,每個葉子節點存儲3個數據行的鍵值及地址信息
*
* k值事先計算得到的,計算的依據是讓所有信息的大小正好等于頁的大小
* PAGE_SIZE = k * 4[keyw.. 大小] + k * 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; // 這個結點在鏈表中的后繼結點
}
6.構建m叉樹索引m多大最合適呢?
不管是內存中的數據,還是磁盤中的數據,操作系統都是按頁(一頁大小通常是4kb,這個值可以通過getconfig PAGE_SIZE命令查看)來讀取的,一次會讀一頁的數據。如果要讀取的數據量超過一頁的大小,就會觸發多次IO操作。所以,在選擇m大小的時候,要盡量讓每個結點的大小等于一個頁的大小。讀取一個結點,只需要一次磁盤IO操作。
B+樹的插入和刪除操作
插入操作
對于一個B+樹來說,m值是根據頁的大小事先計算好的,也就是說,每個節點最多只能有m個子節點。
在寫入數據的過程中,有可能使索引中某些節點的子節點個數超過m,這個節點的大小超過一個頁的大小,讀取這樣一個節點,就會導致多次IO操作。這時只需要將這個節點分裂成兩個節點。但是,節點分裂后,其上層父節點的子節點個數就有可能超過m個,需要再將父節點也分裂成兩個節點。這種級聯反應會從下往上,一直影響到根節點。
插入數據的分裂過程:
(圖中的B+樹是一個三叉樹。限定葉子節點中,數據的個數超過2個就分裂節點;非葉子節點中,子節點的個數超過3個就分裂節點)
刪除操作
刪除數據時的索引更新:
刪除某個數據的時候,也要對應的更新索引節點。這個處理思路有點類似跳表中刪除數據的處理思路。頻繁的數據刪除,就會導致某些節點中,子節點的個數變得非常少,長此以往,如果每個節點的子節點都比較少,勢必會影響索引效率。
可以設置一個閾值。在B+樹中,這個閾值等于m / 2。如果某個節點的個數小于m / 2,就將它跟相鄰的兄弟節點合并。不過,合并之后節點的子節點個數有可能會超過m。針對這種情況,可以借助插入數據時的處理方法,再分裂節點。
刪除操作的合并過程:
(圖中的B+樹是一個五叉樹。我們限定葉子節點中,數據的個數小于2個就合并節點;非葉子節點中,子節點的個數小于3個就合并節點)。
B+樹的特點
- 每個節點中子節點的個數不能超過m,也不能小于m / 2
- 根節點的子節點個數可以不超過m / 2,這是一個例外
- m叉樹之存儲索引,并不真正存儲數據,這個有點類似跳表
- 通過鏈表將葉子節點串聯在一起,這樣可以方便按區間查找
- 一般情況,根節點會被存儲在內存中,其他節點存儲在磁盤中
B樹&B-樹
B-樹就是B樹,英文翻譯都是B-Tree。
B樹實際上是低級版的B+樹,或者說B+樹是B樹的改進版。B樹跟B+樹的不同點主要集中在這幾方面:
- B+樹種節點不存儲數據,只是索引,而B樹中的節點存儲數據
- B樹種的葉子節點并不需要串聯
也就是說,B樹只是一個每個節點的子節點個數不能小于m / 2的m叉樹。