本篇对于B+树讲的很细,希望大家要耐着性子看下去,保证会有收获!
相信大家都学过MySQL数据库,在开发中,为了加快数据库中数据的查找速度,一般会对表中的数据创建索引。MySQL数据库的索引就是用B+树来进行实现的。
实现一个需求:按值查找和按区间查找
如何通过索引来加快数据库表的查询速度呢?
#根据某个具体值来查找数据:select * from user where id = 1234;
#根据某个区间值来查询数据:select * from user where id > 1234 and id < 2345;
除了功能性需求外,在执行效率方面,我们希望通过索引让数据查询更快;在内存消耗方面,希望索引不用消耗太多的内存空间。
基于哈希表和二叉查找树的解决方案
对于支持快速查询操作的数据结构,我们学过了哈希表和平衡二叉查找树。
哈希表的查询性能很好,而且时间复杂度为O(1),但是它不支持按照区间进行快速查找,尽管平衡二叉查找树的性能也很好,时间复杂度为O(logn),而且使用中序遍历还可以输出有序的序列,但是也是不能满足按照区间快速查找的需求。索引B+树就应运而生!
基于B+树的解决方案
为了支持按照区间快速查找数据,我们对二叉查找树进行改造,使其具有按区间查找的功能,在原本二叉查找树下再加入一层节点,并且这层节点存储在有序链表中,将哈希表与有序链表进行结合使用。现在改造的二叉查找树就相当于将二叉查找树与有序链表结合使用。
如图所示:
如果要查询某个区间的位置,那么只要将区间的初始值在数中进行查找,当定位到有序链表中的某个节点时,再从这个节点顺序的依次向后遍历,直到有序链表中的值大于区间的终止值为止。遍历有序链表得到的数据就是落在要查找区间范围内的数据。如图所示
此时又会出现一个问题,如果数据库中某个表的数据量很大,对应的索引也很大,将索引存储在内存中,占用的内存也就更多,这不是我们希望看到的。举个例子,给一亿个数据构建二叉查找树索引,那么所有就会包含大约2亿个节点,每个节点假设占用16B,就需要占用大约2GB大小的内存空间。也就是说,给一个表构建所有需要消耗2GB大小的内存,那么给10个表构建索引,就需要消耗20GB的内存,显然目前内存是不可能承受的数据。既然需要消耗那么多的内存,那是不是可以用时间来换取空间呢?
所以我们可以采用时间获取空间的方法,也就是把索引存在磁盘中,而不是存在内存中。大家都知道,磁盘是外存,访问的速度很慢,形象一点说,访问内存的速度是纳秒级别,那么访问磁盘的速度就是 毫秒级别的。所以,对于同样的时间,从磁盘中读取数据花费的时间要比内存满上几十倍。对于这种将索引存储到磁盘的方案,如果每读取一个节点都要对应一次磁盘IO操作,尽管内存消耗减少了,但是效率却是慢了好多好多,那么,还能不能继续优化呢?
为了避免重复的IO操作,导致的性能问题,我们需要尽可能地减少IO操作。前面说过,树上很多操作的时间复杂度跟树的高度成正比,降低树的高度,就可以减少磁盘IO操作。问题又来了,怎么降低二叉树的高度呢?
是不是这样,如果我们把索引构建成m叉树(m > 2),高度是不是比二叉树小呢?
我们对16个数据构建二叉树索引(改造后的二叉查找树),树的高度是4,查找一个数据需要4次磁盘IO操作(如果根节点存储在内存,其他节点存储在磁盘中)。如果我们对16个数据构建五叉树索引,那么树的高度为2,查找一个数据需要2次磁盘IO。如果我们对1亿个数据构建索引,那么我们需要构建100叉树,树的高度仅仅为4,查找一次数据仅仅需要4次磁盘IO操作。问题就解决啦!
事实上,m叉树与有序链表构建成的m叉树索引就是B+树。结构如下图。
现在看看B+树对应的代码实现
/**
2 * 这是 B+ 树非叶子节点的定义。
3 *
4 * 假设 keywords=[3, 5, 8, 10]
5 * 4 个键值将数据分为 5 个区间:(-INF,3), [3,5), [5,8), [8,10), [10,INF)
6 * 5 个区间分别对应:children[0]...children[4]
7 *
8 * m 值是事先计算得到的,计算的依据是让所有信息的大小正好等于页的大小:
9 * PAGE_SIZE = (m-1)*4[keywordss 大小]+m*8[children 大小]
10 */
11 public class BPlusTreeNode {
12 public static int m = 5; // 5 叉树
13 public int[] keywords = new int[m-1]; // 键值,用来划分数据区间
14 public BPlusTreeNode[] children = new BPlusTreeNode[m];// 保存子节点指针
15 }
16
17 /**
18 * 这是 B+ 树中叶子节点的定义。
19 *
20 * B+ 树中的叶子节点跟内部结点是不一样的,
21 * 叶子节点存储的是值,而非区间。
22 * 这个定义里,每个叶子节点存储 3 个数据行的键值及地址信息。
23 *
24 * k 值是事先计算得到的,计算的依据是让所有信息的大小正好等于页的大小:
25 * PAGE_SIZE = k*4[keyw.. 大小]+k*8[dataAd.. 大小]+8[prev 大小]+8[next 大小]
26 */
27 public class BPlusTreeLeafNode {
28 public static int k = 3;
29 public int[] keywords = new int[k]; // 数据的键值
30 public long[] dataAddress = new long[k]; // 数据地址
31
32 public BPlusTreeLeafNode prev; // 这个结点在链表中的前驱结点
33 public BPlusTreeLeafNode next; // 这个结点在链表中的后继结点
34 }
现在想想这个问题,是不是相同数据的情况下,m越大,树的高度越小,那么是不是m叉树中的m是不是越大越好呢?
无论是内存中的数据,还是磁盘中的数据,操作系统都是按页(1页大小通常是4KB)来读取的,一次读一页的数据。如果读取的数据量超过一页的大小,就会触发多次IO操作。这个时候我们应该就要想到,不能将读取的数据量超过一页的大小,因此,在选择m大小的时候,要尽量让每个节点的大小等于一页的大小。这样,读取一个节点就需要一次IO操作。尽管索引可以大大提高数据库的查询效率,但是,肯定会带来弊端,对于数据的写入过程会涉及索引的更新,因此,也会导致写入数据的效率下降。
对于一个B+树,m值是根据页的大小事先计算好的,也就是说每个节点最多只能有m个子节点,在往数据库写入数据时,有可能某些节点的子结点的个数大于m,这个时候,就需要再次进行调整。
这个时候我们可以将这个节点进行分裂,这个节点分裂成之后,其上层父节点的子结点个数就有可能超过m个(为什么会超过m继续往下看),然后父节点也可以按照这个方法进行分裂。这种联级反应会从上往下,一直影响到根节点。
因为要时刻保证B+树索引是一个m叉树,所以索引的存在会导致数据库写入的速度降低,同时删除的速度也可能降低,因为在删除数据的时候,也需要更新索引节点。频繁的数据删除操作会导致某些节点中子结点的个数变得非常少,如果时间久了,每个节点的子结点数目多了,会影响索引的效率。
针对这种情况,我们可以设置一个阀值,如m/2,如果某个节点的子结点个数小于m/2,就将它相邻节点的兄弟节点进行合并。如果合并之后的子结点个数超过m,仍然使用刚刚插入的方法进行分裂。
下面从一些图来详细进行了解。
B树插入
插入操作
对于插入操作很简单,只需要记住一个技巧即可:当节点元素数量大于m-1的时候,按中间元素分裂成左右两部分,中间元素分裂到父节点当做索引存储,但是,本身中间元素还是分裂右边这一部分的。
下面以一颗5阶B+树的插入过程为例,5阶B+树的节点最少2个元素,最多4个元素。
- 插入5,10,15,20
- 插入25,此时元素数量大于4个了,分裂
- 接着插入26,30,继续分裂(中间元素的值分裂到父节点做元素的值)
现在应该就知道父节点的子节点为什么会超过m个了。
下面看看删除的操作
删除操作
对于删除操作是比B树简单一些的,因为叶子节点有指针的存在,向兄弟节点借元素时,不需要通过父节点了,而是可以直接通过兄弟节移动即可(前提是兄弟节点的元素大于m/2),然后更新父节点的索引;如果兄弟节点的元素不大于m/2(兄弟节点也没有多余的元素),则将当前节点和兄弟节点合并,并且删除父节点中的key,下面我们看看具体的实例。
- 初始状态
- 删除10,删除后,不满足要求,发现左边兄弟节点有多余的元素,所以去借元素,最后,修改父节点索引
- 删除元素5,发现不满足要求,并且发现左右兄弟节点都没有多余的元素,所以,可以选择和兄弟节点合并,最后修改父节点索引
- 发现父节点索引也不满足条件,所以,需要做跟上面一步一样的操作
关于删除操作的一些细节我们补充一些关于B树的删除操作,帮助大家理解B+树的删除
B树的删除操作
B树的删除操作相对于插入操作是相对复杂一些的,但是,你知道记住几种情况,一样可以很轻松的掌握的。
- 现在有一个初始状态是下面这样的B树,然后进行删除操作。
- 删除15,这种情况是删除叶子节点的元素,如果删除之后,节点数还是大于
m/2
,这种情况只要直接删除即可。
- 接着,我们把22删除,这种情况的规则:22是非叶子节点,对于非叶子节点的删除,我们需要用后继key(元素)覆盖要删除的key,然后在后继key所在的子支中删除该后继key。对于删除22,需要将后继元素24移到被删除的22所在的节点。
此时发现26所在的节点只有一个元素,小于2个(m/2),这个节点不符合要求,这时候的规则(向兄弟节点借元素):如果删除叶子节点,如果删除元素后元素个数少于(m/2),并且它的兄弟节点的元素大于(m/2),也就是说兄弟节点的元素比最少值m/2还多,将先将父节点的元素移到该节点,然后将兄弟节点的元素再移动到父节点。这样就满足要求了。
我们看看操作过程就更加明白了。
- 接着删除28,删除叶子节点,删除后不满足要求,所以,我们需要考虑向兄弟节点借元素,但是,兄弟节点也没有多的节点(2个),借不了,怎么办呢?如果遇到这种情况,首先,还是将先将父节点的元素移到该节点,然后,将当前节点及它的兄弟节点中的key合并,形成一个新的节点。
移动之后,跟兄弟节点合并。
B+树的特点
1.B+树由m叉查找树和有序链表组成
2.每个节点的个数不能超过m,也不能小于m/2
3.根节点的子节点可以不超过m/2,这是一个例外
4.一般情况下,根节点存储在内存,其他节点存储在磁盘
5.B+树中的节点不存储数据。
6.非根节点元素范围:m/2 <= k <= m-1
现在关于B+树应该讲解的很详细了,大家都能理解的差不多了。
文章部分摘自:https://segmentfault.com/a/1190000020416577
参考书籍:《数据结构与算法之美》