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

相关文章
相关标签/搜索