一、B+樹插入邏輯
1,如果結點不存在,則新生成一個結點,作為B+樹的根結點,結束。
2,如果結點存在,則查找當前數值應該插入的位置,定位到需要插入到葉子結點,然后插入到葉子結點。
3,插入的結點如果未達到最大數量,結束。如果達到最大數量,則把當前葉子結點對半分裂:[m/2]個放入左結點,剩余放入右結點。
4,將分裂后到右結點的第一個值提升到父結點中。若父結點元素個數未達到最大,結束。若父結點元素個數達到最大,分裂父結點:[m/2]個元素分裂為左結點,m-[m/2]-1個分裂為右結點,第[m/2]+1個結點提升為父結點。
下面以實際操作講解。為了演示,我們以3階B+樹為例。
1)插入結點5:B+樹根結點不存在,生成根結點
2)插入結點8:根結點存在,查找存放結點為根結點,插入
3)插入結點10 :同2)
結點元素個數達到最大3,分裂結點。([3/2]=)1個元素放左邊,剩余2個元素放右邊。並將右子結點的第一個值提升到父結點。
4)插入結點15 :找到插入到葉子結點,並插入,然后調整。步驟同3)
5)插入結點20 :步驟同3)
插入完成后,父結點元素個數達到最大,繼續分裂父結點。([3/2]=)1個結點分裂為左結點;3-[3/2]-1=1個結點,分裂為右結點;第([3/2]+1=)2個結點提升為父結點。
二、mysql索引的B+樹插入優化
以上就是B+樹的插入邏輯。你應該已經發現,雖然B+樹為3階樹,但是分裂后的葉子結點都只有1~2個。如果這個算法應用到數據庫索引,假設一個磁盤分頁可以存放2千條數據,但是每次分裂后,都只存儲1000條數據在磁盤分頁中,那么必然會造成磁盤浪費。而且時接近50%的浪費。B+樹的這個設計,是因為插入的結點不是有序的,每次的插入,定位到每個葉子結點的可能性都是有的,所以采用對半分,防止葉子結點頻繁分裂造成性能問題。但是索引值一般是自增數值,所以已經分裂過的葉子結點,后面是不會再有結點插入的。所以這部分的浪費是不可接受的。
出於以上考慮,mysql做了一版優化,即葉子結點在分裂時,不再按照對半分,而是保持原有的葉子結點不變,將超出的結點插入新的葉子結點,並把這個結點值,提升到父結點。父結點的分裂邏輯(待考證)。
但是,這樣會引發新的問題。假如索引值是嚴格按照順序插入的,那么沒有問題,如果不是,就會引發更嚴重的空間浪費。
例如有下面一顆5階B+樹。
現在插入結點19,定位到左邊葉子結點;插入后,該葉子結點達到最大值,然后分裂出新結點結點。
同理,繼續插入結點18,同樣會分裂出18的葉子結點。
由於,18,19,20之間已經沒有其他整型數值,所以18,19這兩個結點永遠都只有一個值,無疑帶來了更嚴重的存儲空間浪費。要知道,磁盤分頁存儲的可不是十幾二十條記錄。
然而,mysql團隊很快也發現了這個問題,並在一次補丁中修復了該漏洞。詳情可參見官方說明。
接下來,我們就用官方說明里面的方法,來驗證現行的mysql索引的插入邏輯是怎么樣的。
三、mysql的B+樹插入邏輯
3.1 mysql版本
1 mysql> show variables like 'ver%'; 2 +-------------------------+------------------------------+
3 | Variable_name | Value |
4 +-------------------------+------------------------------+
5 | version | 8.0.17 |
6 | version_comment | MySQL Community Server - GPL |
7 | version_compile_machine | x86_64 |
8 | version_compile_os | macos10.14 |
9 | version_compile_zlib | 1.2.11 |
10 +-------------------------+------------------------------+
11 5 行於數據集 (0.03 秒) 12
13 mysql>
3.2 新建測試表
1 CREATE TABLE test.page_split_test 2 ( 3 id BIGINT UNSIGNED NOT NULL, 4 payload1 CHAR(255) NOT NULL, 5 payload2 CHAR(255) NOT NULL, 6 payload3 CHAR(255) NOT NULL, 7 payload4 CHAR(255) NOT NULL, 8 PRIMARY KEY (`id`) 9 ) ENGINE=INNODB;
3.3 填充B+樹根結點
填充根結點至分裂前的最大值
1 # Fill up the root page, but don't split it.
2
3 INSERT INTO test.page_split_test (id, payload1, payload2, payload3, payload4) 4 VALUES 5 (1, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 6 (2, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 7 (3, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 8 (4, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 9 (5, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 10 (6, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 11 (7, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 12 (8, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 13 (9, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 14 (10, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 15 (11, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 16 (12, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 17 (13, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 18 (14, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255));
查看B+樹結點情況
1 SELECT page_number, page_type, number_records, data_size 2 FROM information_schema.innodb_buffer_page 3 WHERE table_name like "%page_split_test%" AND index_name = "PRIMARY";
結果如下,當前只有一個根結點,含14個數據行
3.4 首次分裂
繼續插入一個數據,B+樹達到最大,首次進行分裂。
1 INSERT INTO test.page_split_test (id, payload1, payload2, payload3, payload4) 2 VALUES (15, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255));
查看B+樹結點情況如下
根結點對半分裂成兩個結點,左結點(第5頁)和右結點(第6頁),原來的根結點(第4頁)升級為父結點,父結點中包含兩個元素,分別為指向兩個子結點的引用。
3.5 繼續填充右葉子結點
繼續插入數據,填充右葉子結點至臨近飽和狀態。
1 INSERT INTO test.page_split_test (id, payload1, payload2, payload3, payload4) 2 VALUES 3 (16, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 4 (17, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 5 (18, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 6 (19, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 7 (20, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255)), 8 (21, REPEAT("A", 255), REPEAT("B", 255), REPEAT("C", 255), REPEAT("D", 255));
查看B+樹狀態
在這里,mysql並沒有像之前的bug一樣,生成6個結點,而是將后面的6個結點合並成了一個。這就是mysql做的再一次優化,即葉子結點滿了之后,如果該葉子結點后面有還有葉子結
點,則會將新的數據插入到后續的葉子結點中。
然而這樣的方案,雖然避免了空間浪費,但是增加了索引插入時的性能。極端假設,新增的數據定位到了第一個葉子結點,插入后,葉子結點達到最大數量,然后分裂出最后的一個元素插到后一個葉子結點。假設后面的葉子結點都是已經滿額的狀態,那么這個插入會導致所有的葉子結點都發生一次分裂,且所有父結點的數據都要重新調整。這樣的效率簡直是災難性的。
然而,這個問題,可以通過人為的避免,那就是使用自增索引。自增索引,可以保證每次插入的主鍵都是遞增的,永遠都只會修改和新增最右的葉子結點,而不用修改原有葉子結點。所以,使用遞增索引,可以大大提升數據的插入效率。
四、mysql的B+樹插入邏輯小結
InnoDB的索引分裂策略,在特定的情況下,索引頁面的分裂存在問題,導致每個分裂出來的頁面,僅僅存儲一條記錄,頁面的空間利用率極低。
4.1 B+樹的分裂
傳統B+樹頁面分裂操作分析:
按照原頁面中50%的數據量進行分裂,針對當前這個分裂操作,3,4記錄保留在原有頁面,5,6記錄,移動到新的頁面。最后將新紀錄7插入到新的頁面中;
50%分裂策略的優勢:
分裂之后,兩個頁面的空間利用率是一樣的;如果新的插入是隨機在兩個頁面中挑選進行,那么下一次分裂的操作就會更晚觸發;
50%分裂策略的劣勢:
空間利用率不高:按照傳統50%的頁面分裂策略,索引頁面的空間利用率在50%左右;
分裂頻率較大:針對如上所示的遞增插入(遞減插入),每新插入兩條記錄,就會導致最右的葉頁面再次發生分裂;
4.2 B+樹分裂操作的優化
新的分裂策略,在插入7時,不移動原有頁面的任何記錄,只是將新插入的記錄7寫到新頁面之中;原有頁面的利用率,仍舊是100%;
優化分裂策略的優勢:
索引分裂的代價小:不需要移動記錄;
索引分裂的概率降低:如果接下來的插入,仍舊是遞增插入,那么需要插入4條記錄,才能再次引起頁面的分裂。相對於50%分裂策略,分裂的概率降低了一半;
索引頁面的空間利用率提高:新的分裂策略,能夠保證分裂前的頁面,仍舊保持100%的利用率,提高了索引的空間利用率;
優化分裂策略的劣勢:
如果新的插入,不再滿足遞增插入的條件,而是插入到原有頁面,那么就會導致原有頁面再次分裂,增加了分裂的概率。
因此,此優化分裂策略,僅僅是針對遞增遞減插入有效,針對隨機插入,就失去了優化的意義,反而帶來了更高的分裂概率。
在InnoDB的實現中,為每個索引頁面維護了一個上次插入的位置,以及上次的插入是遞增/遞減的標識。根據這些信息,InnoDB能夠判斷出新插入到頁面中的記錄,是否仍舊滿足遞增/遞減的約束,若滿足約束,則采用優化后的分裂策略;若不滿足約束,則退回到50%的分裂策略。
五、參考文章
https://blog.csdn.net/baidu_29258265/article/details/82150728?utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromMachineLearnPai2%7Edefault-4.control&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromMachineLearnPai2%7Edefault-4.control
https://blog.csdn.net/qq_42389764/article/details/108152624