土法炼钢:怎么实现一个简单的B+Tree In-Disk

时间:2023-03-09 02:49:11
土法炼钢:怎么实现一个简单的B+Tree In-Disk

1. 写在前面

说起B+树,大家应该都很熟悉。B+树是一种平衡的多路搜索树,广泛在操作系统和数据库系统用作索引。相比于内存的存取速度,磁盘I/O存取的开销要高上几个数量级。而将B+树用作索引时,它可以在查找过程有效地减少磁盘I/O操作次数。

一般涉及B+Tree的书籍和文章都会提到它广泛用作外存的索引中,但是并没有详细讲解怎么实现。本文打算独辟蹊径,基于以前实现过的一个程序,介绍怎么实现一个简单可用的磁盘B+树。

本文对B+树的基础知识就不再赘言。磁盘中的B+树以文件的形式将整体都存放磁盘当中,使用时只在内存中缓存部份结构。在本文中,数据块和结点这两个词语都代表了B+树的一个结点。

当然本文作者水平有限,如有错误,还请大家给予指出。

2. 简单实现

将B+树存放于磁盘之中,第一步先定自义文件的格式,以便于读回的时候可以正确解析文件的数据。

2.1 B+树文件的格式

B+树的结点在内存中使用指针进行结点之间的串联,指针值是结点的第一个字节的虚拟内存地址。而对应的在磁盘中可以用所在的数据块的第一个字节相对文件头部的偏移量来标识。

索引文件主要分两个部份组成:

  1. 文件头:描述本文件的信息。
  2. 数据块:对应于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+树文件,通过去读该文件获得文件头和根结点。过程如下:

  1. 打开文件。
  2. 读入文件头,首先判断魔数是否正确。如果错误,则代表该文件不是我们定义的B+树文件,可以直接退出。
  3. 获得该B+树的阶数、结点总数和高度。
  4. 这时通过已知的阶数可以计算出每个数据块的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+树在结点中进行指针互指。

在这里有几种办法可以解决这个问题,其中有一个办法叫“指针混写”,它的效率会更高一些,然而实现起来都会比较复杂。

在这里介绍“不混写”的做法,通过建立磁盘地址和内存地址的双向转换表,表的两端分别是同一个数据块的磁盘地址和内存地址。在内存新建一个数据块时,就在表中增加一行映射。当然将某个数据块删去或者淘汰出去时,也要进行处理,避免野指针的存在。从一个数据块找到另一个对应的磁盘地址时,如果它已经被读入内存中,就可以通过查表的方式找到它的内存地址。

磁盘地址和内存地址的双向转换如图:

土法炼钢:怎么实现一个简单的B+Tree In-Disk

2.4 查找、插入与自顶向下的分裂

那在内存中应该维持多少个数据块的缓存?怎么缓存?可以维持一个Buffer池,然后使用LRU的算法作为淘汰算法。在这个Buffer池假设有若干slot,每个slot可以缓存一个数据块,并有一个flag用作脏位,表示这个数据块有没有被写过。如果这个数据块被写过,在它被淘汰换出时必须写回磁盘。

假设已经有一棵比较庞大的B+树存在。

在读到根结点之后,还有许多slot未被使用,可以通过多读一些数据块到内存中,进行“预热”。当然每读入一个数据块的同时,也要在磁盘地址和内存地址的双向转换表上加上对应的一行。至于选择哪些块?可以有很多不同的策略,这里就不再多做讨论了。

下面讨论磁盘B+树查找、插入和分裂。

2.4.1 查找

查找过程相对比较简单,它和内存版本区别在于如果对应结点不在内存中,则从磁盘中去读入。

查找过程:

  1. 从根结点出发,自上而下地查找对应的键值及其数据块所在的磁盘偏移量。
  2. 查找磁盘地址和内存地址的双向转换表上是否有对应的记录,确定该块有没有在内存中;如果没有,将其读入。如果这时Buffer池没有多余的slot,通过LRU算法将某个slot淘汰出去,如果该slot内的数据块是“脏的”,通过双向转换表,找到它的磁盘地址,将它写回。
  3. 继续向下查找,重复第2步的动作,直到找到叶子结点。
  4. 如果找到对应的值,返回对应的值;如果未找到,返回未找到。

2.4.2 插入与分裂

插入过程:

插入过程与查找过程前3步相同,在叶子结点上插入对应的键值,如果键值冲突,则返回键值冲突。在插入的过程中,可能会遇到叶子结点被写满的情况,这时就要进行“分裂”,将一个叶子结点分裂成两个,并将叶子的中间关健字提升到父结点,用于标识两棵新树的划分点。但是如果它的父结点也是满的,也会继续被“分裂”,这个过程会沿着树向上传播。

2.4.3 自顶向下的分裂

本文旨在介绍一种简单实现,所以要介绍一种比较简单的实现方法——自顶向下的分裂法。

事实上,不必等到插入满的叶子结点时才做分裂。当沿着树往下查找时 ,就可以分裂沿路遇到的每个已经满的结点。因此,每当要分裂一个满结点时,都能确定它的父结点不是满的。

在分裂的过程中,根结点是个特殊情况。分裂时,它的中间键也必须提升到父结点。因为根结点没有父结点,必须新建一个新的根结点取代它,修改文件头的一些数据。就可以将一个原根结点转换成一个非根结点去操作。

而分裂一个非根结点,可以确定它的父结点不是满的。过程是将一个结点分成两个,同时也要为新的结点分配一个文件位置,分裂之后将中间键提升至它的父结点中。

这样插入的过程就转变为如下:

  1. 如果该结点是根结点且它已经满了,分配一个新的根结点,修改文件头信息,置为新根结点。
  2. 如果这个结点不是叶子结点,且子结点已经满了,分裂它的子结点。
  3. 读入键对应的下一层的结点。
  4. 如果它是叶子结点,将键插入。如果不是,回到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字段,这个细节要分裂过程要注意处理,这里就略过)。

这时分裂一个子结点的流程如下:

  1. 修改该结点idle和busy的字段,将结点转换成一个左结点。
  2. 为新结点分配一个磁盘位置。
  3. 判断插入键处于左结点还是右结点。
  4. 如果处于左结点,将结点切换成右切点写回,再切回左结点。
  5. 如果处于右结点,将左结点写入磁盘,切换成右结点。
  6. 将中间键提升至父结点。

3. 写在后面

本文的介绍比较简单扼要,不过还是希望对那些感觉自己对磁盘B+树不熟悉的同学能够有些帮助。当然本文作者水平有限,如有错误,还请大家给予指出。