1. 寫在前面
說起B+樹,大家應該都很熟悉。B+樹是一種平衡的多路搜索樹,廣泛在操作系統和數據庫系統用作索引。相比於內存的存取速度,磁盤I/O存取的開銷要高上幾個數量級。而將B+樹用作索引時,它可以在查找過程有效地減少磁盤I/O操作次數。
一般涉及B+Tree的書籍和文章都會提到它廣泛用作外存的索引中,但是並沒有詳細講解怎么實現。本文打算獨辟蹊徑,基於以前實現過的一個程序,介紹怎么實現一個簡單可用的磁盤B+樹。
本文對B+樹的基礎知識就不再贅言。磁盤中的B+樹以文件的形式將整體都存放磁盤當中,使用時只在內存中緩存部份結構。在本文中,數據塊和結點這兩個詞語都代表了B+樹的一個結點。
當然本文作者水平有限,如有錯誤,還請大家給予指出。
2. 簡單實現
將B+樹存放於磁盤之中,第一步先定自義文件的格式,以便於讀回的時候可以正確解析文件的數據。
2.1 B+樹文件的格式
B+樹的結點在內存中使用指針進行結點之間的串聯,指針值是結點的第一個字節的虛擬內存地址。而對應的在磁盤中可以用所在的數據塊的第一個字節相對文件頭部的偏移量來標識。
索引文件主要分兩個部份組成:
- 文件頭:描述本文件的信息。
- 數據塊:對應於B+樹各個結點的數據。
2.1.1 文件頭的格式:
typedef
struct tag_BTREE_HEADER
{
// base
int _magicNum;
size_t _orderNum;
size_t _nodeNum;
size_t _height;
// read pos
off_t _rootPos;
off_t _startLeafPos;
// freespace admin
size_t _freeBlockNum;
off_t _firstFreeBlockPos;
}BTREE_HEADER;
第一部份標識最基本的參數。其中:
- MagicNum: 定義一個魔數,使得程序在讀取該文件時可以判斷是不是自己可以處理的索引文件。
- OrderNum: B+樹的階數。
- NodeNum: B+樹的總結點數量。
- Height: B+樹的高度。
第二部份標識B+樹的位置。其中:
- RootPos: 根結點所在的數據塊的位置,它的值是相對於文件頭部的偏移值。
- StartLeafPos: 最左邊葉子的數據塊的位置,它的值也是相對於文件頭部的偏移值。在B+樹中支持在葉子之間進行區間遍歷,在這里標識第一個葉子結點的位置。
第三部份用來管理文件中的空閑塊。保持一個空閑塊的鏈表,可以使得分裂時需要新的塊時可以優先從鏈表中摘得,如果鏈表中沒有空閑塊時再把塊添加在文件的尾部。如果有一個數據塊因為結點合並被廢棄了,它可以簡單地通過“頭插法”被加入鏈表中。其中:
- FreeBlockNum: 用來標識目前文件中有多少數據塊是未被使用的。可以設定FreeBlockNum/NodeNum大於某個比例,就認為該索引文件空洞過多而重新進行整理。
- FirstFreeBlockPos: 用來標識空閑塊鏈表的首地址。
2.1.2 數據塊的格式:
數據塊的格式與B+樹內存中的結點沒有不一樣,一樣是將指針表示成偏移量。
它的一個簡單格式可以如下:
typedef
struct tag_BTREE_NODE
{
BTREE_NODE_TYPE _eNodeType;
size_t _busy;
size_t _idle;
off_t _nextPos;
KEY_AND_POS _keyAndPos[ORDER_NUM];
}BTREE_NODE;
- BTREE_NODE_TYPE: 用來標識該數據塊的屬性,是葉子結點、中間結點,還是未被使用的空閑塊。
- Busy: 用來標識該塊中有幾個有效的鍵值。
- Idle: 用來標識該塊中有幾個未用的鍵值。Busy+Idle=ORDER_NUM。
- NextPos: 在葉子中,用來標識相鄰的葉子結點的位置;在中間結點中,N個鍵可以有N+1個偏 移值,NextPos則用來表示第N+1個偏移值;在空閑塊中,它用來標識下一個空閑塊。
- KEY_AND_POS: 用來放置一對鍵和偏移值,個數為階數。
以上就是B+樹的簡單的文件定義,在使用B+樹文件時,首先將文件讀入內存。
2.2 讀B+樹文件
假設現在有一個B+樹文件,通過去讀該文件獲得文件頭和根結點。過程如下:
- 打開文件。
- 讀入文件頭,首先判斷魔數是否正確。如果錯誤,則代表該文件不是我們定義的B+樹文件,可以直接退出。
- 獲得該B+樹的階數、結點總數和高度。
- 這時通過已知的階數可以計算出每個數據塊的size,按根結點的偏移值+數據塊的size將根結點讀入內存中。
2.2.1"空數組"
在網上看到一些內存版本的B+樹基本都會將階數寫死在程序中,這顯然不夠靈活。更好的做法是將階數寫在文件中去動態獲得,這樣可以方便程序處理任何階數的B+樹。注意到,上面定義的BTREE_NODE中ORDER_NUM是個可變化的值,而數組[]中的值應該是個常量,我們沒法提前知道ORDER_NUM的值,所以上面的定義是錯誤的。對於size變化的數組也許應該定義成:
typedef
struct tag_BTREE_NODE
{
BTREE_NODE_TYPE _eNodeType;
size_t _busy;
size_t _idle;
off_t _nextPos;
KEY_AND_POS *_keyAndPos;
}BTREE_NODE;
這樣的結果就是,沒有辦法將在內存中的一個結點當成一個連續的空間,然后去對應於磁盤中的一個數據塊。如果可以一一對應,使用最簡單的二進制拷貝就可以將磁盤中的數據塊直接賦與內存中的結點。而這里就不得不分成兩次,首先賦值_keyAndPos以外的變量,再去將_keyAndPos指針內的數組賦值。為了可以變化的階數導致處理結點的數據有點別扭。
實際上是有辦法可以解決這個問題的,在C語言里支持一種叫做“空數組”的機制。空數組即下標為0的數組,如a[0]。在函數中聲明空數組是沒有任何意義的,也是不能編譯通過的。而在類或結構體中,卻是可以這樣聲明的。這里將結點的結構聲明如下。
typedef
struct tag_BTREE_NODE
{
BTREE_NODE_TYPE _eNodeType;
size_t _busy;
size_t _idle;
off_t _nextPos;
KEY_AND_POS _keyAndPos[0];
}BTREE_NODE;
使用這樣的語句去申請內存空間:
BTREE_NODE *b = malloc(sizeof(BTREE_NODE)+ ORDER_NUM * sizeof(KEY_AND_POS));
就可以為這個數據塊去分配內存空間,這時申請的塊的大小實際上比BTREE_NODE這個結構更大一些,然而多出來的部份將自然地成為數組中的一部份。這樣就可以解決結點空間不能連續的問題。
2.3 磁盤地址和內存地址的雙向轉換
將部份數據塊從磁盤中讀入內存中之后,會發現一個問題,就是數據塊中並沒有內存指針,它保存的值實際是磁盤文件偏移值,沒有辦法像內存版本的B+樹在結點中進行指針互指。
在這里有幾種辦法可以解決這個問題,其中有一個辦法叫“指針混寫”,它的效率會更高一些,然而實現起來都會比較復雜。
在這里介紹“不混寫”的做法,通過建立磁盤地址和內存地址的雙向轉換表,表的兩端分別是同一個數據塊的磁盤地址和內存地址。在內存新建一個數據塊時,就在表中增加一行映射。當然將某個數據塊刪去或者淘汰出去時,也要進行處理,避免野指針的存在。從一個數據塊找到另一個對應的磁盤地址時,如果它已經被讀入內存中,就可以通過查表的方式找到它的內存地址。
磁盤地址和內存地址的雙向轉換如圖:

2.4 查找、插入與自頂向下的分裂
那在內存中應該維持多少個數據塊的緩存?怎么緩存?可以維持一個Buffer池,然后使用LRU的算法作為淘汰算法。在這個Buffer池假設有若干slot,每個slot可以緩存一個數據塊,並有一個flag用作臟位,表示這個數據塊有沒有被寫過。如果這個數據塊被寫過,在它被淘汰換出時必須寫回磁盤。
假設已經有一棵比較龐大的B+樹存在。
在讀到根結點之后,還有許多slot未被使用,可以通過多讀一些數據塊到內存中,進行“預熱”。當然每讀入一個數據塊的同時,也要在磁盤地址和內存地址的雙向轉換表上加上對應的一行。至於選擇哪些塊?可以有很多不同的策略,這里就不再多做討論了。
下面討論磁盤B+樹查找、插入和分裂。
2.4.1 查找
查找過程相對比較簡單,它和內存版本區別在於如果對應結點不在內存中,則從磁盤中去讀入。
查找過程:
- 從根結點出發,自上而下地查找對應的鍵值及其數據塊所在的磁盤偏移量。
- 查找磁盤地址和內存地址的雙向轉換表上是否有對應的記錄,確定該塊有沒有在內存中;如果沒有,將其讀入。如果這時Buffer池沒有多余的slot,通過LRU算法將某個slot淘汰出去,如果該slot內的數據塊是“臟的”,通過雙向轉換表,找到它的磁盤地址,將它寫回。
- 繼續向下查找,重復第2步的動作,直到找到葉子結點。
- 如果找到對應的值,返回對應的值;如果未找到,返回未找到。
2.4.2 插入與分裂
插入過程:
插入過程與查找過程前3步相同,在葉子結點上插入對應的鍵值,如果鍵值沖突,則返回鍵值沖突。在插入的過程中,可能會遇到葉子結點被寫滿的情況,這時就要進行“分裂”,將一個葉子結點分裂成兩個,並將葉子的中間關健字提升到父結點,用於標識兩棵新樹的划分點。但是如果它的父結點也是滿的,也會繼續被“分裂”,這個過程會沿着樹向上傳播。
2.4.3 自頂向下的分裂
本文旨在介紹一種簡單實現,所以要介紹一種比較簡單的實現方法——自頂向下的分裂法。
事實上,不必等到插入滿的葉子結點時才做分裂。當沿着樹往下查找時 ,就可以分裂沿路遇到的每個已經滿的結點。因此,每當要分裂一個滿結點時,都能確定它的父結點不是滿的。
在分裂的過程中,根結點是個特殊情況。分裂時,它的中間鍵也必須提升到父結點。因為根結點沒有父結點,必須新建一個新的根結點取代它,修改文件頭的一些數據。就可以將一個原根結點轉換成一個非根結點去操作。
而分裂一個非根結點,可以確定它的父結點不是滿的。過程是將一個結點分成兩個,同時也要為新的結點分配一個文件位置,分裂之后將中間鍵提升至它的父結點中。
這樣插入的過程就轉變為如下:
- 如果該結點是根結點且它已經滿了,分配一個新的根結點,修改文件頭信息,置為新根結點。
- 如果這個結點不是葉子結點,且子結點已經滿了,分裂它的子結點。
- 讀入鍵對應的下一層的結點。
- 如果它是葉子結點,將鍵插入。如果不是,回到2。
代碼如下:
Insert(B+Tree t, Key k)
{
if(t的根結點滿了)
{
//創建一個新的結點成為了根結點
//找到索引文件中為根結點找到一個空閑塊
//修改文件頭,樹的高度,根結點的位置等參數
SplitNode(node);
return InsertNodeNonFull(node, key);
}
else
{
return InsertNodeNonFull(node, key);
}
}
InsertNodeNonFull(node, key)
{
if(node是葉子結點)
{
//查找對應的位置
//插入
//回復是否成功
}
else //node不是葉子結點
{
//將node的子結點讀入
if(node的子結點是一個滿的結點)
SplitNode(node的子結點);
InsertNodeNonFull(node的子結點, key)
}
}
SplitNode(node)
{
//TODO,下面介紹
}
2.4.4 就地分裂
下面繼續介紹一種“奇技淫巧”——就地分裂,然而它沒有很大的實用性。
在“分裂”的過程中,將一個結點一分為二。一般的作法都是要找到一個額外的空間來存放分裂后的另一個結點的數據。但是如果不使用額外的內存空間,能不能實現就地分裂呢?答案當然是可以的,只是做法也很“分裂”。
這里的關鍵點在於,分裂前的那個結點事實上擁有分裂完的兩個結點所有數據,稍微做些變化都能使其變化成任意一個。在查找過程的過程中,從根結點自頂向下結點會串聯成一個鏈表,形成一條主鏈路。而分裂出來的兩個結點必然有一個結點不在主鏈路上,這時我們可以選擇先將不在主鏈路的結點直接寫回磁盤中。
回顧一下位運算里的循環移位,在匯編里使用rol和ror可以完成循環左移和循環右移。舉個例子,假如有個八位的字節,它的值是11110000,經過循環左移4位或者循環右移4位,將會變成00001111。類比到B+樹的場景中,假如B+樹的階數為8。分裂之后的兩個結點將各有4個鍵。第一個結點取得前4個鍵,舍棄后4個鍵。通過一次循環左移可以將前4個鍵移到末尾,后4個鍵就會被移到前面,此時就獲得了第二個結點。
這里再回顧一下前文的結點的數據結構:
typedef
struct tag_BTREE_NODE
{
BTREE_NODE_TYPE _eNodeType;
size_t _busy;
size_t _idle;
off_t _nextPos;
KEY_AND_POS _keyAndPos[0];
}BTREE_NODE;
我們自己編寫一個循環左移(RotateLeft)函數,因為要討論一種就地分裂的做法,所以這里也不使用O(1)以上的空間復雜度的做法。結點有busy,idle,在進行循環左移的同時也要適當修改它的值。這里的做法是使用三次翻轉的做法。
在滿的狀態下,busy的值等於ORDER_NUM,idle為0。因為可能存在階數為奇數的情況,在分裂后,busy=busy - ORDER_NUM/2,idle=ORDER_NUM-busy。
RotateLeft()
{
POS_AND_KEY *pFirstStart; // 數組keyAndPos的開頭,即keyAndPos[0]
POS_AND_KEY *pFirstEnd = pFirstStart + _busy;
POS_AND_KEY *pSecondStart = pFirstEnd;
POS_AND_KEY *pSecondEnd = pSecondStart + _idle;
std::reverse(pFirstStart , pFirstEnd);
std::reverse(pSecondStart , pSecondEnd);
std::reverse(pFirstStart , pSecondEnd);
std::swap(_idle , _busy);
}
通過RotateLeft函數就可以將一個分裂后的結點,在左結點和右結點來回切換(除了nextPos字段,這個細節要分裂過程要注意處理,這里就略過)。
這時分裂一個子結點的流程如下:
- 修改該結點idle和busy的字段,將結點轉換成一個左結點。
- 為新結點分配一個磁盤位置。
- 判斷插入鍵處於左結點還是右結點。
- 如果處於左結點,將結點切換成右切點寫回,再切回左結點。
- 如果處於右結點,將左結點寫入磁盤,切換成右結點。
- 將中間鍵提升至父結點。
3. 寫在后面
本文的介紹比較簡單扼要,不過還是希望對那些感覺自己對磁盤B+樹不熟悉的同學能夠有些幫助。當然本文作者水平有限,如有錯誤,還請大家給予指出。
