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

概述

通過幾天鏖戰終於完成了lab2,本lab實現一個支持併發操做的B+樹。簡直B格滿滿。node

B+樹

爲何須要B+樹

B+樹本質上是一個索引數據結構。好比咱們要用某個給定的ID去檢索某個student記錄,若是沒有索引的話,咱們可能從第一條記錄開始遍歷每個student記錄,直到找到某個ID和咱們給定的ID一致的記錄。可想而知,這是很是耗時的。
若是咱們已經維護了一個以ID爲KEY的索引結構,咱們能夠向索引查詢這個ID對應的記錄所在的位置,而後直接從這個位置讀取這個記錄。從索引查詢某個ID對應的位置,這個操做須要高效,B+樹能保證以O(log n)的時間複雜度完成。git

B+樹的性質

B+樹由葉子節點和內部節點組成,和其它樹結構差很少,可是對(KEY, VALUE)的個數和排列順序有要求。github

葉子節點:

格式以下:算法

*  ---------------------------------------------------------------------------
 * | 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

相關文章
相關標籤/搜索