MongoDB 及 Mysql 背后的 B/B+树

索引是数据库常见的数据结构,每个后台开发人员都应该对索引背后的数据结构有所了解。

本文通过分析B-Tree及B-/+Tree数据结构及索引性能分析及磁盘存取原理尝试着回答一下问题:

  1. 为什么B-Tree适合数据库索引及红黑树的二叉平衡树不适合作为索引
  2. B+Tree比BTree做索引的优势
  3. 为什么MongoDB采用B-Tree作为索引结构而MySQL采用B+Tree作为索引存储结构

B-Tree

B 树(B-Tree)是为磁盘等辅助存取设备设计的一种平衡查找树,它实现了以 $O(\lg n)$ 时间复杂度执行查找、顺序读取、插入和删除操作。由于 B 树和 B 树的变种在降低磁盘 I/O 操作次数方面表现优异,所以经常用于设计文件系统和数据库。

使用阶来定义 B 树,一棵 m 阶的 B 树,需要满足下列条件:

  1. 每个节点最多拥有m个子节点且m>=2,空树除外
  2. 除根节点外每个节点的关键字数量大于等于ceil(m/2)-1,小于等于m-1,非根节点关键字数必须>=2
  3. 所有叶子节点均在同一层、叶子节点除了包含了关键字和关键字记录的指针外也有指向其子节点的指针只不过其指针地址都为null对应下图最后一层节点的空格子
  4. 如果一个非叶节点有n个子节点,则该节点的关键字数等于n-1
  5. 所有节点关键字是按递增次序排列,并遵循左小右大原则

注:

  1. m阶代表一个树节点最多有多少个查找路径,m阶=m路,当m=2则是2叉树,m=3则是3叉。
  2. ceil()是个朝正无穷方向取整的函数,如ceil(1.1)结果为2,即向上取整。

B-Tree

B 树中的节点分为内部节点(Internal Node)和叶节点(Leaf Node),内部节点也就是非叶节点(Non-Leaf Node)。

B-Tree的查找

B-Tree的查找过程:根据给定值查找结点和在结点的关键字中进行查找交叉进行。

首先从根结点开始重复如下过程:若比结点的第一个关键字小,则查找在该结点第一个指针指向的结点进行;若等于结点中某个关键字,则查找成功;若在两个关键字之间,则查找在它们之间的指针指向的结点进行;若比该结点所有关键字大,则查找在该结点最后一个指针指向的结点进行;若查找已经到达某个叶结点,则说明给定值对应的数据记录不存在,查找失败。

例如:
在一棵 5 阶B-树中查找元素 29

首先29比根节点值大,所以找根节点的右子数,然后再根据值得判断,发现 29 介于 28 和 48 之间,然后在从中间子树继续查找下去。

B-Tree的插入

插入的过程分两步完成:

  1. 利用前述的B-树的查找算法查找关键字的插入位置。若找到,则说明该关键字已经存在,直接返回。否则查找操作必失败于某个最低层的非终端结点上。

  2. 判断该结点是否还有空位置。即判断该结点的关键字总数是否满足n<=m-1。若满足,则说明该结点还有空位置,直接把关键字k插入到该结点的合适位置上。若不满足,说明该结点己没有空位置,需要把结点分裂成两个。

分裂的方法是:
生成一新结点。把原结点上的关键字和k按升序排序后,从中间位置把关键字(不包括中间位置的关键字)分成两部分。左部分所含关键字放在旧结点中,右部分所含关键字放在新结点中,中间位置的关键字连同新结点的存储位置插入到父结点中。如果父结点的关键字个数也超过(m-1),则要再分裂,再往上插。直至这个过程传到根结点为止。

例子:

如果该节点的元素个数还没达到 m,则插入完后无需处理
比如:

如果该节点元素个数达到 m 时,这时候将元素插入到合适的位置,将最中间的元素取出,成为该节点的父节点元素,然后将其余左右元素拆成两个新节点

比如:

刚才的操作可能导致父节点的元素个数达到 m,这时候用情况 2 迭代处理,直到如果遇到根结点元素个数达到 m,则最中间元素将成为新的根结点。

比如:

B-Tree 的删除

我们需要分两种情况进行讨论:

  • 如果该元素存在于叶子结点,直接删除它,无需进行其它处理。
  • 如果该元素存在于非叶子节点,那么删除它将会留下一个空位,这时候我们需要一些处理来填充该位置。
    因为节点的元素个数在 [M/2, M] 的范围内,所以比如这里我们以 5 阶B-树为例,判断节点元素是否充足即满足个数则至少拥有三(2 + 1)个元素的节点才算是有充足的元素。
    1. 如果被删元素的左子树拥有足够的元素,这时候我们只需拿左子节点的最大值元素上来填充即可
    2. 当左子树不够元素而右子树元素充足时,这时候我们拿右子树的最小值元素上来进行填充
    3. 当左右子树所含元素均不足时,但左子树的左边兄弟节点的元素个数充足,这时我们需要拿左边的兄弟节点来进行调整。
    4. 当左右子树所含元素均不足时,但左子树的左边兄弟节点的元素个数也不足时,这时候我们还是拿左子树的最大值元素进行填充,之后再将该节点与其他节点合并形成新的节点。

B+Tree

B-Tree有许多变种,其中最常见的是B+Tree,例如MySQL就普遍使用B+Tree实现其索引结构。

与B-Tree相比,B+Tree有以下不同点:

  • 每个节点的指针上限为2d而不是2d+1。
  • B+Tree叶子节点保存了父节点的所有关键字和关键字记录的指针,每个叶子节点的关键字从小到大链接
  • 内节点不存储data,只存储key;叶子节点不存储指针。因此所有数据地址必须要到叶子节点才能获取到,所以每次数据查询的次数都一样。

索引

红黑树等数据结构也可以用来实现索引,但是文件系统及数据库系统普遍采用B-/+Tree作为索引结构,这一节将结合计算机组成原理相关知识讨论B-/+Tree作为索引的理论基础。

一般来说,索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式存储的磁盘上。这样的话,索引查找过程中就要产生磁盘I/O消耗,相对于内存存取,I/O存取的消耗要高几个数量级,所以评价一个数据结构作为索引的优劣最重要的指标就是在查找过程中磁盘I/O操作次数的渐进复杂度。换句话说,索引的结构组织要尽量减少查找过程中磁盘I/O的存取次数

下面先介绍内存和磁盘存取原理,然后再结合这些原理分析B-/+Tree作为索引的效率。

磁盘存取原理

索引一般以文件形式存储在磁盘上,索引检索需要磁盘I/O操作。与主存不同,磁盘I/O存在机械运动耗费,因此磁盘I/O的时间消耗是巨大的。

下面是磁盘的整体结构示意图:

一个磁盘由大小相同且同轴的圆形盘片组成,磁盘可以转动(各个磁盘必须同步转动)。在磁盘的一侧有磁头支架,磁头支架固定了一组磁头,每个磁头负责存取一个磁盘的内容。磁头不能转动,但是可以沿磁盘半径方向运动(实际是斜切向运动),每个磁头同一时刻也必须是同轴的,即从正上方向下看,所有磁头任何时候都是重叠的(不过目前已经有多磁头独立技术,可不受此限制)。

下面是磁盘结构的示意图:

盘片被划分成一系列同心环,圆心是盘片中心,每个同心环叫做一个磁道,所有半径相同的磁道组成一个柱面。磁道被沿半径线划分成一个个小的段,每个段叫做一个扇区,每个扇区是磁盘的最小存储单元。为了简单起见,我们下面假设磁盘只有一个盘片和一个磁头。

当需要从磁盘读取数据时,系统会将数据逻辑地址传给磁盘,磁盘的控制电路按照寻址逻辑将逻辑地址翻译成物理地址,即确定要读的数据在哪个磁道,哪个扇区。为了读取这个扇区的数据,需要将磁头放到这个扇区上方,为了实现这一点,磁头需要移动对准相应磁道,这个过程叫做寻道,所耗费时间叫做寻道时间,然后磁盘旋转将目标扇区旋转到磁头下,这个过程耗费的时间叫做旋转时间。

局部性原理与磁盘预读

由于存储介质的特性,磁盘本身存取就比主存慢很多,再加上机械运动耗费,磁盘的存取速度往往是主存的几百分分之一,因此为了提高效率,要尽量减少磁盘I/O。为了达到这个目的,磁盘往往不是严格按需读取,而是每次都会预读,即使只需要一个字节,磁盘也会从这个位置开始,顺序向后读取一定长度的数据放入内存。

这样做的理论依据是计算机科学中著名的局部性原理:

当一个数据被用到时,其附近的数据也通常会马上被使用。

程序运行期间所需要的数据通常比较集中。

由于磁盘顺序读取的效率很高(不需要寻道时间,只需很少的旋转时间),因此对于具有局部性的程序来说,预读可以提高I/O效率。

预读的长度一般为页(page)的整倍数。页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(在许多操作系统中,页得大小通常为4k),主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后异常返回,程序继续运行。

B-/+Tree索引的性能分析

一般使用磁盘I/O次数评价索引结构的优劣。先从B-Tree分析,根据B-Tree的定义,可知检索一次最多需要访问h个节点。数据库系统的设计者巧妙利用了磁盘预读原理,将一个节点的大小设为等于一个页,这样每个节点只需要一次I/O就可以完全载入。为了达到这个目的,在实际实现B-Tree还需要使用如下技巧:

每次新建节点时,直接申请一个页的空间,这样就保证一个节点物理上也存储在一个页里,加之计算机存储分配都是按页对齐的,就实现了一个node只需一次I/O。

B-Tree中一次检索最多需要h-1次I/O(根节点常驻内存),渐进复杂度为$O(h)=O(\log_d N)$。一般实际应用中,出度d是非常大的数字,通常超过100,因此h非常小(通常不超过3)。

综上所述,用B-Tree作为索引结构效率是非常高的。

而红黑树这种结构,h明显要深的多。由于逻辑上很近的节点(父子)物理上可能很远,无法利用局部性,所以红黑树的I/O渐进复杂度也为O(h),效率明显比B-Tree差很多。

上文还说过,B+Tree更适合外存索引,原因和内节点出度d有关。从上面分析可以看到,d越大索引的性能越好,而出度的上限取决于节点内key和data的大小:

$$
d_{max}=floor({pagesize \over keysize+datasize+pointsize})
$$

floor表示向下取整。

由于B+Tree内节点去掉了data域,因此可以拥有更大的出度,容纳更多的节点,能够有效减少磁盘IO次数

一般在数据库系统或文件系统中使用的B+Tree结构都在经典B+Tree的基础上进行了优化,增加了顺序访问指针。

如上图图所示,在B+Tree的每个叶子节点增加一个指向相邻叶子节点的指针,就形成了带有顺序访问指针的B+Tree。做这个优化的目的是为了提高区间访问的性能,例如图4中如果要查询key为从18到49的所有数据记录,当找到18后,只需顺着节点和指针顺序遍历就可以一次性访问到所有数据节点,极大提到了区间查询效率

综上所述:
B+Tree做索引的优势是:

  1. 内部节点取消data域,每一页可以容纳更多的数据,有效减少磁盘IO次数。
  2. 数据都存储在叶子节点,所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。所以B+树查询时间复杂度为log n,而B树查询时间复杂度不固定,与所查结点在树中的位置有关,最好为O(1)。
  3. 通过增加顺序访问指针提高区间查询效率。

而MongoDB索引选择B树可能是因为:
MongoDB 是文档型的数据库,是一种nosql,它使用BSON格式保存数据,归属于聚合型数据库。被设计用在数据模型简单,性能要求高的场合。之所以采用B树,是因为B树key和data域聚合在一起。

参考文档

  1. MySQL索引背后的数据结构及算法原理
  2. 人人都是 DBA(VII)B 树和 B+ 树
  3. 平衡二叉树、B-Tree、B+Tree、B*树 理解其中一种你就都明白了
  4. https://zh.wikipedia.org/wiki/B%E6%A0%91
  5. B-Tree gif:https://www.cs.usfca.edu/~galles/visualization/BTree.html
  6. 6. 数据结构 - B 树
0%