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+树在结点中进行指针互指。

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

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

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

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+树不熟悉的同学能够有些帮助。当然本文作者水平有限,如有错误,还请大家给予指出。

土法炼钢:怎么实现一个简单的B+Tree In-Disk的更多相关文章

  1. js/jq基础(日常整理记录)-4-一个简单的自定义tree插件

    一.一个简单的自定义tree插件 上一篇是之前自定义的table插件,这一篇也是之前同期尝试做的一个tree插件. 话不多说,先看看长什么样子哈! 现在来看确实不好看,以后在优化吧! 数据源:ajax ...

  2. 哪种缓存效果高?开源一个简单的缓存组件j2cache

    背景 现在的web系统已经越来越多的应用缓存技术,而且缓存技术确实是能实足的增强系统性能的.我在项目中也开始接触一些缓存的需求. 开始简单的就用jvm(java托管内存)来做缓存,这样对于单个应用服务 ...

  3. 在Openfire上弄一个简单的推送系统

    推送系统 说是推送系统有点大,其实就是一个消息广播功能吧.作用其实也就是由服务端接收到消息然后推送到订阅的客户端. 思路 对于推送最关键的是服务端向客户端发送数据,客户端向服务端订阅自己想要的消息.这 ...

  4. ASP.NET Aries 入门开发教程2:配置出一个简单的列表页面

    前言: 朋友们都期待我稳定地工作,但创业公司若要躺下,也非意念可控. 若人生注定了风雨飘摇,那就雨中前行了. 最机开始看聊新的工作机会,欢迎推荐,创业公司也可! 同时,趁着自由时间,抓紧把这系列教程给 ...

  5. 计算机程序的思维逻辑 (60) - 随机读写文件及其应用 - 实现一个简单的KV数据库

    57节介绍了字节流, 58节介绍了字符流,它们都是以流的方式读写文件,流的方式有几个限制: 要么读,要么写,不能同时读和写 不能随机读写,只能从头读到尾,且不能重复读,虽然通过缓冲可以实现部分重读,但 ...

  6. 如何开发一个简单的HTML5 Canvas 小游戏

    原文:How to make a simple HTML5 Canvas game 想要快速上手HTML5 Canvas小游戏开发?下面通过一个例子来进行手把手教学.(如果你怀疑我的资历, A Wiz ...

  7. CSharpGL(24)用ComputeShader实现一个简单的图像边缘检测功能

    CSharpGL(24)用ComputeShader实现一个简单的图像边缘检测功能 效果图 这是红宝书里的例子,在这个例子中,下述功能全部登场,因此这个例子可作为使用Compute Shader的典型 ...

  8. CSharpGL(23)用ComputeShader实现一个简单的ParticleSimulator

    CSharpGL(23)用ComputeShader实现一个简单的ParticleSimulator 我还没有用过Compute Shader,所以现在把红宝书里的例子拿来了,加入CSharpGL中. ...

  9. 应用OpenMP的一个简单的设计模式

    小喵的唠叨话:最近很久没写博客了,一是因为之前写的LSoftmax后馈一直没有成功,所以在等作者的源码.二是最近没什么想写的东西.前两天,在预处理图片的时候,发现处理200w张图片,跑了一晚上也才处理 ...

随机推荐

  1. Xshell远程连接Linux时无法使用小键盘的解决方式

    我在用xshell连接远程的centos时,每次使用vi/vim的时候而NumLock明明在开启着,小键盘都不能正确输入数字,其实这是时按小而是出现一个字母然后换行(实际上是命令模式上对应上下左右的键 ...

  2. C语言每日一题之No.3

    几天下来,感慨学习要坚持下来真的是件很难的事,本来说了每天一题,可是毕竟这是个细活,需要用心雕琢,有时候真的不能当天拿下来>_<.虽然说只是一题,却涉及到很多小细节,慢慢的琢磨直至完全摸透 ...

  3. (微信API接口开发) 使用HttpWebRequest进行请求时发生错误:基础连接已关闭,发送时发生错误处理

    最近调试原来的微信模拟登陆时发生了“基础连接已关闭,发送时发生错误”的错误提示,原来都是好好的,只是很久没用了. 出错代码如下: HttpWebRequest req = (HttpWebReques ...

  4. activiti自定义流程之Spring整合activiti-modeler5.16实例(六):启动流程

    注:(1)环境搭建:activiti自定义流程之Spring整合activiti-modeler5.16实例(一):环境搭建        (2)创建流程模型:activiti自定义流程之Spring ...

  5. 简单的缓存代理HTTP服务器

        http, 演示客户与服务器套接字. http只支持GET操作及硬编码的MIME类型的一小部分(MIME类型是多媒体内容的类型描述符).代理HTTP服务器是单线程的,该线程中每一个请求依次被处 ...

  6. PLSQL_性能优化系列03_Oracle Parallel并发处理

    2014-09-25 Created By BaoXinjian

  7. git 命令的使用(一) add commit push pull

    一. commit 和 push 的区别 git作为支持分布式版本管理的工具,它管理的库(repository)分为本地库.远程库.git commit操作的是本地库,git push操作的是远程库. ...

  8. 给Mac下的iTerm2增加配色

    iterm2就不说了,Mac下非常好用的终端,这里就先谈谈如何给其增加配色,效果如下图 可以来这下载theme : http://iterm2colorschemes.com/ 1.先编辑你的prof ...

  9. SVN---脱离SVN控制

    创建一个记事本文件,然后吧这句话复制进去for /r . %%a in (.) do @if exist "%%a\.svn" rd /s /q "%%a\.svn&qu ...

  10. U盘安装Linux CentOS 6.5 64位操作系统(来自互联网)

    从centOS6.5开始直接把iso文件写入u盘就行了. 方法1:windows平台:1.用UltraISO打开iso(如:CentOS-6.5-x86_64-bin-DVD1.iso)2.然后点“启 ...