知识点二十六:B树/B+树

前言

数据库作为主流的数据存储系统,在日常的业务开发中有着举足轻重的地位。在工作中,为了加速数据库中数据的查找速度,常用的处理思路是对表中的数据创建索引。那数据库的索引到底是如何实现的呢?譬如MySQL 数据库等底层使用的是什么数据结构和算法呢?

定义问题的需求

思考的过程比结论更重要,解决问题的前提是定义清楚问题,那如何定义清楚问题呢?除了对问题进行详细的调研,还有一个办法,那就是,通过对一些模糊的需求进行假设,来限定要解决的问题的范围。为了方便讲解,这里我们假设要解决的问题中,数据库的索引只包含下面这样两个常用的需求:

  • 根据某个值查找数据,比如 select * from user where id=1234;
  • 根据区间值来查找某些数据,比如 select * from user where id > 1234 and id < 2345。

除了这些功能性需求之外,这种问题往往还会涉及一些非功能性需求,比如安全、性能、用户体验等等。限于我们这里讨论的主要是数据结构和算法,对于非功能性需求,我们着重考虑性能方面的需求。具体来说,性能方面的需求,我们主要考察时间和空间两方面,也就是执行效率存储空间。在执行效率方面,我们希望通过索引,查询数据的效率尽可能的高;在存储空间方面,我们希望索引不要消耗太多的内存空间。

问题的需求大致定义清楚了,我们现在回想一下,能否利用已经学习过的数据结构解决这个问题呢?支持快速查询、插入等操作的动态数据结构,我们已经学习过的有散列表、平衡二叉查找树、跳表。

我们先来看散列表。散列表的查询性能很好,时间复杂度是 O(1)。但是,散列表不能支持按照区间快速查找数据。所以,散列表不能满足这个问题的需求。

我们再来看平衡二叉查找树。尽管平衡二叉查找树查询的性能也很高,时间复杂度是 O(logn)。而且,对树进行中序遍历,我们还可以得到一个从小到大有序的数据序列,但这仍然不足以支持按照区间快速查找数据。
在这里插入图片描述
我们再看看跳表。跳表是在链表的基础上加上多层索引构成的。它支持快速地插入、查找、删除数据,对应的时间复杂度是 O(logn)。而且,跳表也支持按照区间快速地查找数据。我们只需要先通过多级索引层,快速定位到区间起点值对应在链表中的结点的位置,然后从这个结点开始,顺序遍历链表,直到遍历到区间终点值对应在链表中的结点为止,这期间遍历得到的数据就是满足区间值的数据。这样看来,跳表是可以解决这个问题的。
在这里插入图片描述
实际上,数据库索引所用到的数据结构跟跳表非常相似,叫作 B+ 树。不过,它是通过二叉查找树演化过来的,而非跳表。所以,接下来,我们从二叉查找树讲起,看它是如何一步一步被改造成 B+ 树的。

B+ 树

为了让二叉查找树支持根据区间值来查找某些数据,我们可以对它进行这样的改造:树中的节点并不存储数据本身,而只是作为索引。除此之外,我们把每个叶子节点串在一条双向链表上,链表中的数据是从小到大有序的。经过改造之后的二叉树,就像下图中这样,看起来是不是很像跳表呢?
在这里插入图片描述
改造之后,如果我们要求某个区间的数据。我们只需要拿区间的起始值,在树中进行查找,当查找到某个叶子节点之后,我们再顺着链表往后遍历,直到链表中的结点数据值大于区间的终止值为止。这期间所有遍历到的数据,就是符合区间值的所有数据。
在这里插入图片描述
但是,我们要为数据库中几千万甚至上亿的数据都构建索引,如果将索引都存储在内存中,尽管内存访问的速度非常快,查询的效率非常高,但是,这样占用的内存会非常多。比如,我们给一亿个数据构建二叉查找树索引,那树中会包含大约 1 亿个节点,假设每个节点占用 16 个字节,那就需要大约 1GB 的内存空间。给一张表建立索引,就需要 1GB 的内存空间。如果要给 10 张表建立索引,那对内存的需求是无法满足的。如何解决这个索引占用太多内存的问题呢

我们可以借助时间换空间的思路把索引存储在硬盘中,而非内存中。我们知道,硬盘是一个非常慢速的存储设备。通常内存的访问速度是纳秒级别的,而磁盘访问的速度是毫秒级别的。读取同样大小的数据,从磁盘中读取花费的时间,是从内存中读取所花费时间的上万倍,甚至几十万倍。这种将索引存储在硬盘中的方案,尽管减少了内存消耗,但是在数据查找的过程中,由于读取磁盘中的索引花费的时间更多,因此数据查询效率就相应地降低很多。

此外,如果为了节省内存,把改造后的支持区间查找的二叉查找树存储在硬盘中,那么每个节点的读取(或访问),都对应一次磁盘 IO 操作。树的高度就等于每次查询数据时磁盘 IO 操作的次数。我们前面讲到,比起内存读写操作,磁盘 IO 操作非常耗时,所以我们优化的重点就是尽量减少磁盘 IO 操作,也就是尽量降低树的高度。那该如何降低树的高度呢?

我们来看下,如果把索引构建成 m 叉树,高度是不是比二叉树要小呢?如图所示,给 16 个数据构建二叉树索引,树的高度是 4,查找一个数据,就需要 4 个磁盘 IO 操作(一般树的根节点存储在内存中,其他结点存储在磁盘中),如果对 16 个数据构建五叉树索引,那树的高度只有 2,查找一个数据对应只需要 2 次磁盘操作。如果是构建成 100 叉树,那对一亿个数据构建索引,树的高度也只是 3(log100100000000 -1,树的高度是指根节点到叶子节点的最长路径的边数),最多只要 3 次磁盘 IO 就能获取到数据。磁盘 IO 变少了,查找数据的效率也就提高了。对于相同个数的数据构建 m 叉树索引,m 叉树中的 m 越大,那树的高度就越小,查找一个数据需要的磁盘 IO 操作次数就越少,那 m 叉树中的 m 是不是越大越好呢?到底多大才最合适呢?

不管是内存中的数据,还是磁盘中的数据,操作系统都是按页来读取的(一页的大小通常是 4KB,这个值可以通过 getconfig PAGE_SIZE 命令查看),一次会读取一页的数据。如果要读取的数据量超过一页的大小,就会触发多次 IO 操作。所以,我们在选择 m 大小的时候,要尽量让每个节点存储的所有信息的大小正好等于一个页的大小。这样既可以使得树的高度尽可能小,又能保证每次读取一个节点中的数据量时,只需要一次磁盘 IO 操作。
在这里插入图片描述
尽管通过索引可以提高数据库的查询效率,但是,索引有利也有弊,它也会让写入数据的效率下降。这是为什么呢?

对于一个 B+ 树来说,m 值是根据页的大小事先计算好的,也就是说,每个节点最多只能有 m 个子节点。在往数据库中写入数据的过程中,就有可能使索引中某些节点的子节点个数超过 m,导致这个节点所存储的信息大小超过了一个页的大小,读取这样一个节点,就会需要多次磁盘 IO 操作。那么,该如何解决这个问题呢?

实际上,处理思路也并不复杂。我们只需要将这个节点分裂成两个节点。但是,节点分裂之后,其上层父节点的子节点个数就有可能也超过 m 个。不过这也没关系,我们可以用同样的方法,将其父节点也分裂成两个节点。因此,这种级联反应会从下往上,一直影响到根节点。整个分裂过程,如下图所示,图中的 B+ 树是一个三叉树。我们限定叶子节点中,当数据的个数超过 2 个(大于等于 2 个)时就分裂节点;而对于非叶子节点中,当子节点的个数超过 3 个(大于等于 3 个)时就分裂节点。
在这里插入图片描述
正是因为要时刻保证 B+ 树的索引是一棵 m 叉树,在往数据库中写入数据的过程中,会涉及索引节点的更新,从而导致数据库写入的速度降低。实际上,不光写入数据会变慢,删除数据也会变慢。

我们在删除某个数据的时候,也要对应着更新索引节点。频繁的数据删除,会导致某些结点中子节点的个数变得非常少,长此以往,如果每个节点的子节点都比较少,势必会影响索引的效率。针对这个问题的处理思路有点类似于跳表中删除数据时的索引动态更新思路,我们可以设置一个阈值。在 B+ 树中,这个阈值等于 m/2。如果某个节点的子节点个数小于 m/2,我们就将它跟相邻的兄弟节点合并。不过,合并得到的结点的子节点个数有可能会超过 m。针对这种情况,我们可以借助插入数据时候的处理方法,再分裂该节点。具体的过程可以参考下图,图中的 B+ 树是一个五叉树。我们限定叶子节点中,数据的个数少于 2 个时就合并节点;非叶子节点中,子节点的个数少于 3 个时就合并节点。
在这里插入图片描述
以上就是将二叉查找树一步步改成 B+ 树的过程,总的来说, B+ 树就是通过存储在磁盘中的多叉树结构,做到了时间和空间的平衡,既保证了执行效率,又节省了内存。总结一下 B+ 树的特点:

  • m 叉树只存储索引,并不真正存储数据,这个有点儿类似跳表;
  • 对于一个 B+ 树来说,m 值是根据操作系统中页的大小事先计算好的。
  • 一般情况,根节点会被存储在内存中,其他节点存储在磁盘中。
  • 每个节点中子节点的个数不能超过 m(最多 m-1个),也不能小于 m/2;
  • 不过,根节点的子节点个数可以不超过 m/2,最少可以只有1个子节点,这是一个例外
  • 通过双向链表将叶子节点从小到大排列串联在一起,这样可以方便按区间查找;

如果我们将 B+ 树索引构建成 m 叉树的过程用代码实现出来,就是下面这个样子:

/**
 * 这是B+树非叶子节点的定义。
 *
 * 假设给 int类型的数据库字段添加索引,keywords=[3, 5, 8, 10]
 * 4个键值将数据分为5个区间:(-INF,3), [3,5), [5,8), [8,10), [10,INF)
 * 5个区间分别对应的子节点:children[0]...children[4]
 *
 * m值是事先计算得到的,计算的依据是让所有信息的大小正好等于页的大小:
 * PAGE_SIZE = (m-1)*4[keywordss大小]+m*8[children大小]
 */
public class BPlusTreeNode {
  public static int m = 5; // 构建 5叉树
  public int[] keywords = new int[m-1]; // 键值,用来划分数据区间
  public BPlusTreeNode[] children = new BPlusTreeNode[m];//保存子节点指针
}

/**
 * 这是B+树中叶子节点的定义。
 *
 * B+树中的叶子节点跟非叶子结点是不一样的,
 * 叶子节点存储的是值,而非区间。
 * 这个定义里,每个叶子节点存储3个数据行的键值及地址信息。
 *
 * k值是事先计算得到的,计算的依据是让所有信息的大小正好等于页的大小:
 * PAGE_SIZE = k*4[keyw..大小]+k*8[dataAd..大小]+8[prev大小]+8[next大小]
 */
public class BPlusTreeLeafNode {
  public static int k = 3;
  public int[] keywords = new int[k]; // 数据的键值
  public long[] dataAddress = new long[k]; // 数据地址

  public BPlusTreeLeafNode prev; // 这个结点在链表中的前驱结点
  public BPlusTreeLeafNode next; // 这个结点在链表中的后继结点
}

实际上,B+ 树的结构和操作,跟跳表非常类似。理论上讲,对跳表稍加改造,也可以替代 B+ 树,作为数据库的索引实现的。

B- 树

B- 树实际上就是 B 树,英文翻译都是 B-Tree,这里的“-”并不是相对 B+ 树中的“+”,而只是一个连接符。这个很容易造成误解。而 B 树其实就是低级版的 B+ 树,或者说 B+ 树是 B 树的改进版。B 树跟 B+ 树的不同点主要集中在这几个地方:

  • B+ 树中的节点不存储数据,只是索引,而 B 树中的节点中存储数据,数据按照从小到大的顺序排列;
  • B 树中的叶子节点并不需要链表来串联。

也就是说,B 树只是一棵每个节点的子节点个数不超过 m 且不能小于 m/2 的 m 叉树。

小结

一、数据库的索引实现
1.常用的索引需求:根据某个值查找数据、根据区间值来查找某些数据
2.散列表的查询性能很好,时间复杂度是 O(1)。但是,散列表不能支持按照区间快速查找数据。
3.平衡二叉查找树查询的性能也很高,时间复杂度是 O(logn)。而且,对树进行中序遍历,可以得到一个从小到大有序的数据序列,但这仍然不足以支持按照区间快速查找数据。
4.跳表是在链表之上加上多层索引构成的。它支持快速地插入、查找、删除数据,对应的时间复杂度是 O(logn)。并且,跳表支持按照区间快速地查找数据。我们只需要定位到区间起点值对应在链表中的结点,然后从这个结点开始,顺序遍历链表,直到区间终点对应的结点为止,这期间遍历得到的数据就是满足区间值的数据。
5.数据库的索引实现底层所依赖的数据结构跟跳表非常相似,叫作 B+ 树。

二、B+ 树
1.B+ 树通过二叉查找树演化过来的,它通过存储在磁盘的多叉树结构,做到了时间、空间的平衡,既保证了执行效率,又节省了内存。
2.B+ 树的特点:
(1)m 叉树只存储索引,并不真正存储数据,这个有点儿类似跳表;
(2)对于一个 B+ 树来说,m 值是根据操作系统中页的大小事先计算好的。
(3)一般情况,根节点会被存储在内存中,其他节点存储在磁盘中。
(4) 每个节点中子节点的个数不能超过 m(最多 m-1个),也不能小于 m/2;
(5)根节点的子节点个数可以不超过 m/2,最少可以只有1个子节点,这是一个例外;
(6)通过双向链表将叶子节点从小到大排列串联在一起,这样可以方便按区间查找;

三、B 树
1.实际上,B- 树就是 B 树,英文翻译都是 B-Tree,这里的“-”并不是相对 B+ 树中的“+”,而只是一个连接符。
2.B 树实际上是低级版的 B+ 树,它跟 B+ 树的不同点主要集中在这几个地方:
(1)B+ 树中的节点不存储数据,只是索引,而 B 树中的节点存储数据;
(2)B 树中的叶子节点并不需要链表来串联。
3.也就是说,B 树只是一棵每个节点的子节点个数不超过 m 且不能小于 m/2 的 m 叉树。

参考

《数据结构与算法之美》
王争
前Google工程师

知乎上一篇关于平衡二叉树、B树、B+树、B*树的讲解:https://zhuanlan.zhihu.com/p/27700617
B树和B+树的插入、删除图文详解:https://www.cnblogs.com/nullzx/p/8729425.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值