1. 程式人生 > >拜託,別再問我什麼是 B+ 樹了

拜託,別再問我什麼是 B+ 樹了

前言

每當我們執行某個 SQL 發現很慢時,都會下意識地反應是否加了索引,那麼大家是否有想過加了索引為啥會使資料查詢更快呢,索引的底層一般又是用什麼結構儲存的呢,相信大家看了標題已經有答案了,沒錯!B+樹!那麼它相對於一般的連結串列,雜湊等有何不同,為何多數儲存引擎都使用它呢,今天我就來揭開 B+ 樹的面紗,相信看了此文,B+ 樹不再神祕,對你理解以下高頻面試題會大有幫助!

  • 為啥索引常用 B+ 樹作為底層的資料結構
  • 除了 B+ 樹索引,你還知道什麼索引
  • 為啥推薦自增 id 作為主鍵,自建主鍵不行嗎
  • 什麼是頁分裂,頁合併
  • 怎麼根據索引查詢行記錄

本文將會從以下幾個方面來講解 B+ 樹

  1. 定義問題
  2. 幾種常見的資料結構對比
  3. 建立索引有哪些需要考慮的問題,怎樣更高效地建立索引

定義問題

要知道索引底層為啥使用 B+ 樹,得看它解決了什麼問題,我們可以想想,日常我們用到的比較多的 SQL 有哪些呢。

假設我們有一張以下的使用者表:

CREATE TABLE `user` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(20) DEFAULT NULL COMMENT '姓名',
  `idcard` varchar(20) DEFAULT NULL COMMENT '身份證號碼',
  `age` tinyint(10) DEFAULT NULL COMMENT '年齡',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='使用者資訊';

一般我們會有如下需求:

1、根據使用者 id 查使用者資訊

select * from user where id = 123;

2、根據區間值來查詢使用者資訊

select * from user where id > 123 and id < 234;

3、按 id 逆序排列,分頁取出使用者資訊

select * from user where id <  1234 order by id desc limit 10;

從以上的幾個常用 SQL 我們可以看到索引所用的資料結構必須滿足以下三個條件

  1. 根據某個值精確快速查詢
  2. 根據區間值的上下限來快速查詢此區間的資料
  3. 索引值需要排好序,並支援快速順序查詢和逆序查詢

接下來我們以主鍵索引(id 索引)為例來看看如何用相應的資料結構來構造它

幾種常見的資料結構對比

接下來我們想想有哪些資料結構滿足以上的條件

1、散列表

散列表(也稱雜湊表)是根據關鍵碼值(Key value)而直接進行訪問的資料結構,它讓碼值經過雜湊函式的轉換對映到散列表對應的位置上,查詢效率非常高。雜湊索引就是基於散列表實現的,假設我們對名字建立了雜湊索引,則查詢過程如下圖所示:

對於每一行資料,儲存引擎都會對所有的索引列(上圖中的 name 列)計算一個雜湊碼(上圖散列表的位置),散列表裡的每個元素指向資料行的指標,由於索引自身只儲存對應的雜湊值,所以索引的結構十分緊湊,這讓雜湊索引查詢速度非常快!但是雜湊索引也有它的劣勢,如下:

  1. 針對雜湊索引,只有精確匹配索引所有列的查詢才有效,比如我在列(A,B)上建立了雜湊索引,如果只查詢資料列 A,則無法使用該索引。
  2. 雜湊索引並不是按照索引值順序存儲存的,所以也就無法用於排序,也就是說無法根據區間快速查詢
  3. 雜湊索引只包含雜湊值和行指標,不儲存欄位值,所以不能使用索引中的值來避免讀取行,不過,由於雜湊索引多數是在記憶體中完成的,大部分情況下這一點不是問題
  4. 雜湊索引只支援等值比較查詢,包括 =,IN(),不支援任何範圍的查詢,如 age > 17

綜上所述,雜湊索引只適用於特定場合, 如果用得對,確實能再帶來很大的效能提升,如在 InnoDB 引擎中,有一種特殊的功能叫「自適應雜湊索引」,如果 InnoDB 注意到某些索引列值被頻繁使用時,它會在記憶體基於 B+ 樹索引之上再建立一個雜湊索引,這樣就能讓 B+樹也具有雜湊索引的優點,比如快速的雜湊查詢。

2、連結串列

雙向連結串列支援順序查詢和逆序查詢,如圖下

但顯然不支援我們說的按某個值或區間的快速查詢,另外我們知道表中的資料是要不斷增加的,索引也是要及時插入更新的,連結串列顯然也不支援資料的快速插入,所以能否在連結串列的基礎上改造一下,讓它支援快速查詢,更新,刪除。有一種結構剛好能滿足我們的需求,這裡引入跳錶的概念。

什麼是跳錶?簡單地說,跳錶是在連結串列之上加上多層索引構成的。如下圖所示

假設我們現在要查詢區間 7- 13 的記錄,再也不用從頭開始查找了,只要在上圖中的二級索引開始找即可,遍歷三次即可找到連結串列的區間位置,時間複雜度是 O(logn),非常快,這樣看來,跳錶是能滿足我們的需求的,實際上它的結構已經和 B+ 樹非常接近了,只不過 B+ 樹是從平衡二叉查詢樹演化而來的而已,接下來我們一步步來看下如何將平衡二叉查詢樹改造成 B+ 樹。

先來看看什麼是平衡二叉查詢樹,平衡二叉查詢樹具有如下性質:

  1. 若左子樹不空,則左子樹上所有節點的值均小於它的根節點的值;
  2. 若右子樹不空,則右子樹上所有節點的值均大於或等於它的根節點的值;
  3. 每個非葉子節點的左右子樹的高度之差的絕對值(平衡因子)最多為1。

下圖就是一顆平衡二叉查詢樹

從其特性就可以看到平衡二叉查詢樹查詢節點的時間複雜度是 O(log2n)

現在我們將其改造成 B+ 樹

可以看到主要區別就是所有的節點值都在最後葉節點上用雙向連結串列連線在了一起,仔細和跳錶對比一下 ,是不是很像,現在如果我們要找15 ~ 27 這個區間的數只要先找到 15 這個節點(時間複雜度 logn = 3 次)再從前往後遍歷直到 27 這個節點即可,即可找到這區間的節點,這樣它完美地支援了我們提的三個需求:快速查詢值,區間,順序逆序查詢。

假設有 1 億個節點,每個節點要查詢多少次呢,顯然最多為 log21億 = 27 次,如果這 1 億個節點都在記憶體裡,那 27 次顯然不是問題,可以說是非常快了,但一個新的問題出現了,這 1 億個節點在記憶體大小是多少呢,我們簡單算一下,假設每個節點 16 byte,則 1 億個節點大概要佔用 1.5G 記憶體!對於記憶體這麼寶貴的資源來說是非常可怕的空間消耗,這還只是一個索引,一般我們都會在表中定義多個索引,或者庫中定義多張表,這樣的話記憶體很快就爆滿了!所以在記憶體中完全裝載一個 B+ 樹索引顯然是有問題的,如何解決呢。

記憶體放不下, 我們可以把它放到磁碟嘛,磁碟空間比記憶體大多了,但新的問題又來了,我們知道記憶體與磁碟的讀取速度相差太大了,通常記憶體是納秒級的,而磁碟是毫秒級的,讀取同樣大小的資料,兩者可能相差上萬倍,於是上一步我們計算的 27 次查詢如果放在磁碟中來看就非常要命了(查詢一個節點可以認為是一次磁碟 IO,也就是說有 27 次磁碟 IO!),27 次查詢是否可以優化?

可以很明顯地觀察到查詢次數和樹高有關,那樹高和什麼有關,很明顯和每個節點的子節點個數有關,即 N 叉樹中的 N,假設現在有 16 個數,我們分別用二叉樹和五叉樹來構建,看下樹高分別是多少

可以看到如果用二叉樹 ,要遍歷 5 個節點,如果用五叉樹 ,只要遍歷 3 次,一下少了兩次磁碟 IO,回過頭來看 上文的一億個節點,如果我們用 100 叉樹來構建,需要幾次 IO 呢

可以看到,最多遍歷五次(實際上根節點一般存在記憶體裡的,所以可以認為是 4 次)!磁碟 IO 一下從 27 減少到了 5!效能可以說是大大提升了,有人說 5 次還是太多,是不是可以把 100 叉樹改成 1000 或 10000 叉樹呢,這樣 IO 次數不就就能進一步減少了。

這裡我們就需要了解頁(page)的概念,在計算機裡,無論是記憶體還是磁碟,作業系統都是按頁的大小進行讀取的(頁大小通常為 4 kb),磁碟每次讀取都會預讀,會提前將連續的資料讀入記憶體中,這樣就避免了多次 IO,這就是計算機中有名的區域性性原理,即我用到一塊資料,很大可能這塊資料附近的資料也會被用到,乾脆一起載入,省得多次 IO 拖慢速度, 這個連續資料有多大呢,必須是是作業系統頁大小的整數倍,這個連續資料就是 MySQL 的頁,預設值為 16 KB,也就是說對於 B+ 樹的節點,最好設定成頁的大小(16 KB),這樣一個 B+ 樹上的節點就只會有一次 IO 讀。

那有人就會問了,這個頁大小是不是越大越好呢,設定大一點,節點可容納的資料就越多,樹高越小,IO 不就越小了嗎,這裡要注意,頁大小並不是越大越好,InnoDB 是通過記憶體中的快取池(pool buffer)來管理從磁碟中讀取的頁資料的。頁太大的話,很快就把這個快取池撐滿了,可能會造成頁在記憶體與磁碟間頻繁換入換出,影響效能。

通過以上分析,相信我們不難猜測出 N 叉樹中的 N 該怎麼設定了,只要選的時候儘量保證每個節點的大小等於一個頁(16kb)的大小即可。

頁分裂與頁合併

現在我們來看看開頭的問題, 為啥推薦自增 id 作為主鍵,自建主鍵不行嗎,有人可能會說使用者的身份證是唯一的,可以用它來做主鍵,假設以身份證作主鍵,會有什麼問題呢。

B+ 樹為了維護索引的有序性,每插入或更新一條記錄的時候,會對索引進行更新。假設原來基於身份證作索引的 B+ 樹如下(假設為二叉樹 ,圖中只列出了身份證的前四位)

現在有一個開頭是 3604 的身份證對應的記錄插入 db ,此時要更新索引,按排序來更新的話,顯然這個 3604 的身份證號應該插到左邊節點 3504 後面(如下圖示,假設為二叉樹)

如果把 3604 這個身份證號插入到 3504 後面的話,這個節點的元素個數就有 3 個了,顯然不符合二叉樹的條件,此時就會造成頁分裂,就需要調整這個節點以讓它符合二叉樹的條件

如圖示:調整過後符合二叉樹條件

這種由於頁分裂造成的調整必然導致效能的下降,尤其是以身份證作為主鍵的話,由於身份證的隨機性,必然造成大量的隨機結點中的插入,進而造成大量的頁分裂,進而造成效能的急劇下降,那如果是以自增 id 作為主鍵呢,由於新插入的表中生成的 id 比索引中所有的值都大,所以它要麼合到已存在的節點(元素個數未滿)中,要麼放入新建的節點中(如下圖示)所以如果是以自增 id 作為主鍵,就不存在頁分裂的問題了,推薦!

有頁分裂就必然有頁合併,什麼時候會發生頁合併呢,當刪除表記錄的時候,索引也要刪除,此時就有可能發生頁合併,如圖示

當我們刪除 id 為 7,9 對應行的時候,上圖中的索引就要更新,把 7,9 刪掉,此時 8,10 就應該合到一個節點,不然 8,10 分散在兩個節點上,可能造成兩次 IO 讀,勢必會影響查詢效率! 那什麼時候會發生頁合併呢,我們可以定個閾值,比如對於 N 叉樹來說,當節點的個數小於 N/2 的時候就應該和附近的節點合併,不過需要注意的是合併後節點裡的元素大小可能會超過 N,造成頁分裂,需要再對父節點等進行調整以讓它滿足 N 叉樹的條件。

怎麼根據索引查詢行記錄

相信大家看完以上的 B+ 樹索引的介紹應該還有個疑惑,怎麼根據對應的索引值查詢行記錄呢,其實相應的行記錄就放在最後的葉子節點中,找到了索引值,也就找到了行記錄。如圖示

可以看到,非葉子節點只存了索引值,只在最後一行才存放了行記錄,這樣極大地減小了索引了大小,而且只要找到索引值就找到了行記錄,也提升了效率,

這種在葉節點存放一整行記錄的索引被稱為聚簇索引,其他的就稱為非聚簇索引。

關於 B+ 樹的總結

綜上所述,B+樹有以下特點:

  • 每個節點中子節點的個數不能超過 N,也不能小於 N/2(不然會造成頁分裂或頁合併)
  • 根節點的子節點個數可以不超過 m/2,這是一個例外
  • m 叉樹只儲存索引,並不真正儲存資料,只有最後一行的葉子節點儲存行資料。
  • 通過連結串列將葉子節點串聯在一起,這樣可以方便按區間查詢

總結

本文由日常中常用的 SQL 由淺入深地總結了 B+ 樹的特點,相信大家應該對 B+ 樹索引有了比較清晰地認識,所以說為啥我們要掌握底層原來,學完了 B+ 樹,再看開頭提的幾個問題,其實也不過如此,深挖底層,有時候確實能讓你以不變應萬變。

最後,歡迎大家關注我的公號「碼海」,共同進步!

巨人的肩膀

http://www.rainybowe.com/blog/2016/05/10/mysql%E7%B4%A2%E5%BC%95/index.html
https://time.geekbang.org/column/article/69236