概述
經過幾天鏖戰終於完成了lab2,本lab實現一個支持並發操作的B+樹。簡直B格滿滿。
B+樹
為什么需要B+樹
B+樹本質上是一個索引數據結構。比如我們要用某個給定的ID去檢索某個student記錄,如果沒有索引的話,我們可能從第一條記錄開始遍歷每一個student記錄,直到找到某個ID和我們給定的ID一致的記錄。可想而知,這是非常耗時的。
如果我們已經維護了一個以ID為KEY的索引結構,我們可以向索引查詢這個ID對應的記錄所在的位置,然后直接從這個位置讀取這個記錄。從索引查詢某個ID對應的位置,這個操作需要高效,B+樹能保證以O(log n)的時間復雜度完成。
B+樹的性質
B+樹由葉子節點和內部節點組成,和其它樹結構差不多,但是對(KEY, VALUE)的個數和排列順序有要求。
葉子節點:
格式如下:
* ---------------------------------------------------------------------------
* | HEADER | KEY(1) + RID(1) | KEY(2) + RID(2) | ... | KEY(n) + RID(n)
* ---------------------------------------------------------------------------
假設葉子結點最多能容納個n個(KEY, RID)對,那么該葉子節點任何時候都不能少於n/2向上取整個(KEY, RID)對。假設(KEY, RID)對個數為x,那么x必須滿足:
ceil(n/2) <= x <= n
ceil表示向上取整,博客園不支持LaTeX o(╯□╰)o。
KEY是search key,RID是該KEY對應的記錄的位置。(KEY, RID)對按照KEY的増序進行排列。
HEADER的結構如下:
* ----------------------------------------------------------------------------------------
* | PageType (4) | LSN (4) | CurrentSize (4) | MaxSize (4) | ParentPageId (4) | PageId(4) |
* ---------------------------------------------------------------------------------------
ParentPageId指向父節點。
內部節點
* ----------------------------------------------------------------------------------------
* | HEADER | INVALID_KEY+PAGE_ID(1) | KEY(2)+PAGE_ID(2) | ... | KEY(n)+PAGE_ID(n) |
* ----------------------------------------------------------------------------------------
假設內部節點最多容納n個(KEY, PAGE_ID)對,和葉子節點一樣,x必須滿足:
ceil(n/2) <= x <= n
KEY表示search key,PAGE_ID指的是子節點的ID。
(KEY, PAGE_ID)對按照KEY的増序進行排列。
第一個KEY是無效的。
假設PAGE_ID(i)對應的子樹中的KEY用SUB_KEY表示,那么SUBKEY都滿足:KEY(i) <= SUB_KEY < KEY(i+1)。
查找操作
課本p489給出了find的偽代碼。總結來說就是先找到KEY應該出現的葉子節點,然后在該葉子節點中,查找KEY對應的RID。
如下圖:
假如我們希望查找的KEY為38,第一步在根節點A查找38應該出現在哪個子節點中,根據之前的性質,38應該出現在以B為根的子樹中,繼續查找節點B,以此類推,最終38應該出現在H的葉子節點中。最后我們在H中查找38。
所以對於內部節點,我們需要一個Lookup(const KeyType &key,const KeyComparator &comparator)方法,查找key應該出現在哪個子節點對應的子樹中。
INDEX_TEMPLATE_ARGUMENTS
ValueType
B_PLUS_TREE_INTERNAL_PAGE_TYPE::Lookup(const KeyType &key,
const KeyComparator &comparator) const {
assert(GetSize() >= 2);
// 先找到第一個array[index].first大於等於key的index(從index 1開始)
int left = 1;
int right = GetSize() - 1;
int mid;
int compareResult;
int targetIndex;
while (left <= right) {
mid = left + (right - left) / 2;
compareResult = comparator(array[mid].first, key);
if (compareResult == 0) {
left = mid;
break;
} else if (compareResult < 0) {
left = mid + 1;
} else {
right = mid - 1;
}
}
targetIndex = left;
// key比array中所有key都要大
if (targetIndex >= GetSize()) {
return array[GetSize() - 1].second;
}
if (comparator(array[targetIndex].first, key) == 0) {
return array[targetIndex].second;
} else {
return array[targetIndex - 1].second;
}
}
因為KEY是已排序的,所以可以先二分查找第一個大於或等於KEY的下標targetIndex,如果targetIndex對應的KEY就是我們要找的KEY,那么targetIndex對應的value就是下一步要搜索的節點,否則targetIndex-1對應的value是下一步應該搜索的節點。
插入操作
課本p494給出了完整的insert(key, value)操作的偽代碼。
思路就是:
- 先找到key應該出現的葉子節點,將(key, value)插入到該葉子節點中。
- 如果插入后該葉子節點中鍵值對超出了最大值,則進行分裂。如果插入后沒有超出最大限制,那么就完成任務了。
如上圖准備插入(7, 'g'),但是插入前p1葉子結點已經滿了,那么先插入,然后將插入后的節點,分裂出新的節點p3,將p1原來一半的元素挪到p3,然后將(6, p3)插入到父節點p2中,其中6是新創建的節點p3第一個key。
同樣的,如果我們在父節點p2中插入了(6, p3)導致了p2超過最大限制,p2也需要分裂,以此類推,這個過程可能產生新的根節點。
刪除操作
課本p498給出了完整的delete(key)操作的偽代碼。
思路:
- 先找到key應該出現的葉子節點,刪除該葉子節點中key對應的鍵值對。
- 刪除后如果個數少於規定最少個數,那么有兩個措施,如果當前節點個數和兄弟節點個數總和不超過允許的最大個數,那么進行並合。否則,從兄弟節點中借一個元素。
上圖第一種情況:
刪除(7, 'g')后,p3只有一個元素,少於最少允許的個數(2),於是將(6, 'f')已到兄弟節點p1, 刪除p3節點,並且刪除父節點p2中的(6, p3),如果p2也少於最少允許個數,遞歸進行。
第二種請求:
刪除p3的(8, 'h')后,p3只有一個元素,於是從兄弟節點p1借一個元素(6, f),然后將父節點(7, 'g')修改為(6, 'f'),這種情況不需要遞歸。
支持並發操作
最粗暴的方式就是在find, insert, delete開始就加鎖,執行完畢后解鎖,這樣邏輯上沒有問題,但是並發效率很低,相當於串行執行。
crabbing協議
該協議允許多個線程同時訪問修改B+樹。
基本算法
- 對於查詢操作,從根節點開始,首先獲取根節點的讀鎖,然后在根節點中查找key應該出現的孩子節點,獲取孩子節點的讀鎖,然后釋放根節點的讀鎖,以此類推,直到找到目標葉子節點,此時該葉子節點獲取了讀鎖。
- 對於刪除和插入操作,也是從根節點開始,先獲取根節點的寫鎖,一旦孩子節點也獲取了寫鎖,檢查根節點是否安全,如果安全釋放孩子節點所有祖先節點的寫鎖,以此類推,直到找到目標葉子節點。節點安全定義如下:如果對於插入操作,如果再插入一個元素,不會產生分裂,或者對於刪除操作,如果再刪除一個元素,不會產生並合。
舉個查找過程的例子,查找key=38:
舉個插入過程的例子,插入25:
crab有螃蟹的意思,了解完crabbing協議加鎖的過程,應該不難理解為什么叫crabbing協議了吧。
需要注意的地方
我們需要保護根節點id。
考慮下面這種情況:
兩個線程同時執行插入操作,插入前B+樹只有一個節點,線程一插入當前key后將分裂,生成一個新的根節點。另一個線程在線程一分裂前讀取了舊的根節點,從而將key插入到了錯誤的葉子節點中。
解決辦法:
在訪問,修改root_page_id_的地方加鎖,訪問或者修改完畢root_page_id_后釋放鎖。root_page_id_指向的是該B+樹的根節點,會保存在內存中,以便快速查找。
實驗遇到的坑和解決方案
- 前文提到我們需要保護root_page_id_這個變量,可以用一個mutex,訪問或修改前加鎖,訪問或者修改后釋放鎖。一次加鎖只能對應一次解鎖,如果多調用了一次unlock(),同樣起不到保護的作用。unlock()調用分別在各個函數中,很可能不小心就多調用了次,所以千萬要小心。
- 必須先釋放Page上的鎖,然后才能unpin該Page。為什么?我們知道unpin后,如果pin_count為0,那么這個Page將被送到LRUReplacer,當沒有足夠的Page時,將從LRUReplacer中取Page,將該Page的內容保存到磁盤后用於保存其它其它頁的內容。考慮下面這個場景:在插入25的過程中,查找到目標葉子節點,這時該葉子節點肯定被加上了寫鎖,如果我們執行完插入后,先unpin了該Page,然后才釋放該Page的鎖。可能出現這種情況,在unpin完后,釋放鎖前,這個Page被送到了LRUReplacer,另一個線程請求訪問頁面1,但是所有的Page都被占用了,LRUReplacer選擇這個淘汰帶鎖的這個Page來保存頁面1,因為該Page的鎖還沒釋放,所以另一個線程可以直接訪問或者修改,這是回到原來的線程,再釋放已經晚了。
- lab本身提供的測試case是完全不夠的,就算全部通過了,也不能保證代碼是正確的。我自己加入了很多測試,涵蓋多個線程的,根節點分裂等case。原代碼只有對BPlusTree的測試,所以我添加了對BPlusTreeInternalPage和BPlusTreeLeafPage單獨的測試,這樣在用BPlusTreeInternalPage和BPlusTreeLeafPage構建BPlusTree前能保證自己是正確的。
- 在使用完一個Page后應該立刻unpin掉,不能忘記unpin,如果忘記unpin的話,那么這個Page將永遠不能用於保存其它頁,當所有Page都被占用后,系統將無法繼續運行。這個問題一度困擾我很久,一定要非常仔細。
- 本lab的一個難點是調試,多使用assert和log。