CMU-15445 LAB2:實現一個支持並發操作的B+樹


概述

經過幾天鏖戰終於完成了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)。
lab2_1_page_node.PNG

查找操作

課本p489給出了find的偽代碼。總結來說就是先找到KEY應該出現的葉子節點,然后在該葉子節點中,查找KEY對應的RID。
如下圖:
lab2_2_find.PNG
假如我們希望查找的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)操作的偽代碼。
思路就是:

  1. 先找到key應該出現的葉子節點,將(key, value)插入到該葉子節點中。
  2. 如果插入后該葉子節點中鍵值對超出了最大值,則進行分裂。如果插入后沒有超出最大限制,那么就完成任務了。
    lab2_3_insert.png
    如上圖准備插入(7, 'g'),但是插入前p1葉子結點已經滿了,那么先插入,然后將插入后的節點,分裂出新的節點p3,將p1原來一半的元素挪到p3,然后將(6, p3)插入到父節點p2中,其中6是新創建的節點p3第一個key。
    同樣的,如果我們在父節點p2中插入了(6, p3)導致了p2超過最大限制,p2也需要分裂,以此類推,這個過程可能產生新的根節點。

刪除操作

課本p498給出了完整的delete(key)操作的偽代碼。
思路:

  1. 先找到key應該出現的葉子節點,刪除該葉子節點中key對應的鍵值對。
  2. 刪除后如果個數少於規定最少個數,那么有兩個措施,如果當前節點個數和兄弟節點個數總和不超過允許的最大個數,那么進行並合。否則,從兄弟節點中借一個元素。
    lab2_4_delete.png
    上圖第一種情況:
    刪除(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+樹。

基本算法

  1. 對於查詢操作,從根節點開始,首先獲取根節點的讀鎖,然后在根節點中查找key應該出現的孩子節點,獲取孩子節點的讀鎖,然后釋放根節點的讀鎖,以此類推,直到找到目標葉子節點,此時該葉子節點獲取了讀鎖。
  2. 對於刪除和插入操作,也是從根節點開始,先獲取根節點的寫鎖,一旦孩子節點也獲取了寫鎖,檢查根節點是否安全,如果安全釋放孩子節點所有祖先節點的寫鎖,以此類推,直到找到目標葉子節點。節點安全定義如下:如果對於插入操作,如果再插入一個元素,不會產生分裂,或者對於刪除操作,如果再刪除一個元素,不會產生並合。

舉個查找過程的例子,查找key=38:
lab2_5_crabbing_protol_find.png

舉個插入過程的例子,插入25:
lab2_6-crabbing_protol_insert.png

crab有螃蟹的意思,了解完crabbing協議加鎖的過程,應該不難理解為什么叫crabbing協議了吧。

需要注意的地方

我們需要保護根節點id。
考慮下面這種情況:
兩個線程同時執行插入操作,插入前B+樹只有一個節點,線程一插入當前key后將分裂,生成一個新的根節點。另一個線程在線程一分裂前讀取了舊的根節點,從而將key插入到了錯誤的葉子節點中。
解決辦法:
在訪問,修改root_page_id_的地方加鎖,訪問或者修改完畢root_page_id_后釋放鎖。root_page_id_指向的是該B+樹的根節點,會保存在內存中,以便快速查找。

實驗遇到的坑和解決方案

  1. 前文提到我們需要保護root_page_id_這個變量,可以用一個mutex,訪問或修改前加鎖,訪問或者修改后釋放鎖。一次加鎖只能對應一次解鎖,如果多調用了一次unlock(),同樣起不到保護的作用。unlock()調用分別在各個函數中,很可能不小心就多調用了次,所以千萬要小心。
  2. 必須先釋放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的鎖還沒釋放,所以另一個線程可以直接訪問或者修改,這是回到原來的線程,再釋放已經晚了。
  3. lab本身提供的測試case是完全不夠的,就算全部通過了,也不能保證代碼是正確的。我自己加入了很多測試,涵蓋多個線程的,根節點分裂等case。原代碼只有對BPlusTree的測試,所以我添加了對BPlusTreeInternalPage和BPlusTreeLeafPage單獨的測試,這樣在用BPlusTreeInternalPage和BPlusTreeLeafPage構建BPlusTree前能保證自己是正確的。
  4. 在使用完一個Page后應該立刻unpin掉,不能忘記unpin,如果忘記unpin的話,那么這個Page將永遠不能用於保存其它頁,當所有Page都被占用后,系統將無法繼續運行。這個問題一度困擾我很久,一定要非常仔細。
  5. 本lab的一個難點是調試,多使用assert和log。

最后,貼個實現:https://github.com/gatsbyd/cmu_15445_2018


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM