MySQL 索引:索引为什么使用 B+树?(详解B树、B+树)

文章目录

在MySQL中,无论是Innodb还是MyIsam,都使用了B+树作索引结构(这里不考虑hash等其他索引)。本文将从最普通的二叉查找树开始,逐步说明各种树解决的问题以及面临的新问题,从而说明MySQL为什么选择B+树作为索引结构。

一、二叉查找树(BST):不平衡

二叉查找树(BST,Binary Search Tree),也叫二叉排序树,在二叉树的基础上需要满足:任意节点的左子树上所有节点值不大于根节点的值,任意节点的右子树上所有节点值不小于根节点的值 。如下是一棵BST

当需要快速查找时,将数据存储在BST是一种常见的选择,因为此时查询时间取决于树高,平均时间复杂度是O(lgn)。然而,BST可能长歪而变得不平衡 ,如下图所示,此时BST退化为链表,时间复杂度退化为O(n)

为了解决这个问题,引入了平衡二叉树。

二、平衡二叉树(AVL):旋转耗时

AVL树是严格的平衡二叉树,所有节点的左右子树高度差不能超过1;AVL树查找、插入和删除在平均和最坏情况下都是O(lgn)。

AVL实现平衡的关键在于旋转操作:插入和删除可能破坏二叉树的平衡,此时需要通过一次或多次树旋转来重新平衡这个树。 当插入数据时,最多只需要1次旋转(单旋转或双旋转);但是当删除数据时,会导致树失衡,AVL需要维护从被删除节点到根节点这条路径上所有节点的平衡,旋转的量级为O(lgn)。

由于旋转的耗时,AVL树在删除数据时效率很低;在删除操作较多时,维护平衡所需的代价可能高于其带来的好处,因此AVL实际使用并不广泛。

三、红黑树:树太高

与AVL树相比,红黑树并不追求严格的平衡,而是大致的平衡:只是确保从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。从实现来看,红黑树最大的特点是每个节点都属于两种颜色(红色或黑色)之一,且节点颜色的划分需要满足特定的规则(具体规则略)。红黑树示例如下:

对红黑树不了解的可以看红黑树的创建

与AVL树相比,红黑树的查询效率会有所下降,这是因为树的平衡性变差,高度更高。但红黑树的删除效率大大提高了,因为红黑树同时引入了颜色,当插入或删除数据时,只需要进行O(1)次数的旋转以及变色就能保证基本的平衡,不需要像AVL树进行O(lgn)次数的旋转。 总的来说,红黑树的统计性能高于AVL。

因此,在实际应用中,AVL树的使用相对较少,而红黑树的使用非常广泛。例如,C++中的map使用红黑树存储排序键值对

对于数据在内存中的情况,红黑树的表现是非常优异的。但是对于数据在磁盘等辅助存储设备中的情况(如MySQL等数据库),红黑树并不擅长,因为红黑树长得还是太高了。当数据在磁盘中时,磁盘IO会成为最大的性能瓶颈,设计的目标应该是尽量减少IO次数;而树的高度越高,增删改查所需要的IO次数也越多,会严重影响性能。

由一个例子总结索引的特点

加索引是数据库加速查询的一种方式,那么为什么用索引可以加快查询呢?

讲到索引,其实我们经常会听到一个图书馆的例子,图书馆里的书目繁杂,我们如何从若干本书里面找到一本我们想要的书呢?

我们根据图书馆系统检索,可以找到某本书对应的图书编号。

在基于书籍按照一定规则排列的前提下,我们可以根据图书编号找到这本书

例如,假设图书编号根据:

第几个书架 - 书架上第几个格子 - 从左到右数第几个位置

这样的规则编排,

我们就可以轻松的获取到我们想要的书籍。

你也许发现了,这个例子中,藏着两个信息:

基于哈希表实现的哈希索引

到这里我们遇到了一个问题,就是哈希表虽然从查找效率上满足了我们查找单个数据的要求,但是显然,当遇到范围查询时,由于哈希表本身的无序性,不利于指定范围查找。

也就是说,我们的需求增加了,我们希望数据的组织方式,既要有一定规则,又要有序。

在引出这种数据结构之前,我们首先来看一种查找方式:二分查找。

高效的查找方式:二分查找

二分查找的核心思想是给定一个 有序 的数组,在查找过程中采用跳跃式的方式查找,即先以有序数列的中点位置为比较对象,如果要查找的元素小于中点元素,则将待查序列缩小为左半部分,否则为右半部分。通过每次比较,将查找区间减少一半,直到找到所需元素。


基于二分查找思想的二叉查找树

二叉查找树(Binary Search Tree)即BST树是这样的一种数据结构,如下图:

但是当二叉树的构造变成这样时,

此时我们再查找 8 时,查找效率就沦为接近顺序遍历查找的效率。

显然这不是我们想要的,二叉查找树也需要 balance。

由于树是存储在磁盘中的,访问每个节点,都对应一次磁盘 I/O 操作(假设一个节点的大小「小于」操作系统的最小读写单位块的大小),也就是说树的高度就等于每次查询数据时磁盘 IO 操作的次数,所以树的高度越高,就会影响查询性能。 二叉查找树由于存在退化成链表的可能性,会使得查询操作的时间复杂度从 O(logn) 升为 O(n)。

升级版的BST树:AVL 树

我们对二叉查找树做个限制,限制必须满足任何节点的两个子树的最大差为 1,也是AVL 树的定义,这样我们的查找效率就有了一定的保障。

AVL 树 是一种自平衡二叉查找树(self-balancing binary search tree)。

当然,维护AVL 树也是需要一定开销的,即当树插入/更新/删除新的数据时假设破坏了树的平衡性,那么需要通过左旋和右旋来维护树的平衡。

当数据量很多时,同样也会出现二叉树过高的情况。

我们知道AVL 树的查找效率为 O(log n),也就是说,当树过高时,查找效率会下降。

另外由于我们的索引文件并不小,所以是存储在磁盘上的。文件系统需要从磁盘读取数据时,一般以页为单位进行读取,假设一个页内的数据过少,
那么操作系统就需要读取更多的页,涉及磁盘随机 I/O 访问的次数就更多。

将数据从磁盘读入内存涉及随机 I/O 的访问,是数据库里面成本最高的操作之一。

因而这种树高会随数据量增多急剧增加,每次更新数据又需要通过左旋和右旋维护平衡的二叉树,不太适合用于存储在磁盘上的索引文件。

四、B树:为磁盘而生

前面我们看到,虽然AVL树既有链表的快速插入与删除操作的特点,又有数组快速查找的优势,但是这并不是最符合磁盘读写特征的数据结构。

也就是说,我们要找到这样一种数据结构,能够有效的控制树高,那么我们把二叉树变成m叉树,也就是下图的这种数据结构:B 树。

B树是一种这样的数据结构:




可以看到,B树在保留二叉树预划分范围从而提升查询效率的思想的前提下,做了以下优化:

二叉树变成 m 叉树,这个 m 的大小可以根据单个页的大小做对应调整,从而使得一个页可以存储更多的数据,从磁盘中读取一个页可以读到的数据就更多,随机 IO 次数变少,大大提升效率。

但是我们看到,我们只能通过中序遍历查询全表,当进行范围查询时,可能会需要中序回溯。

让我们从空的5阶B-Tree开始,按照顺序插入这些数字:3, 8, 31, 11, 23, 29, 50, 28, 1, 2。

对于5阶的B树每个结点最多存储4个key,所以对于3,8,31,11的新增(排序)很好理解

但是当我们新增23的时候,就会打破这种规则,所以我们需要对结点进行分裂,来保证定义2成立,分裂操作很简单, 将中间元素进行提取,充当父结点,左右元素一分为2,分别充当孩子结点。

我们继续新增29,50

新增到28的时候,又会出现分裂的动作,但是与第一次分裂不同,这个时候分裂出来的中间结点需要进行上升,然后左右元素,与父级上升的结点进行关联, 这样才能保证定义5成立:根结点到每个叶子结点的长度都相同

继续新增1,2

到这里就新增完成了,可以发现为了保证关键字不超过m-1,需要进行分裂,而分裂的操作需要保证根结点到任意叶子结点距离是相等的

删除操作

相对于新增操作,删除操作比新增要复杂一些,因为新增只涉及到分裂,但是删除会涉及到替换结点,借结点,合并结点等操作。这些操作的目的同样是为了保证结构满足定义

首先看替换结点,当我们要删除非叶子结点的时候,比如图上的29,我们使用它的前继或者后继的结点来替换它,这里前继/后继指的是, 中序遍历结果的前一个/后一个结点,而且在B树中,该结点必然是叶子结点,比如29的前继结点是28,后继结点是31。

当上面替换成功之后,我们就要把当前的叶子结点删掉,删除动作很简单,但是 当我们把关键字删掉之后,需要判断当前结点的关键字是否满足我们所说的至少有Math.ceil(m/2)-1个的要求,如果满足,删除成功,不满足时为了保证 满足这样的条件,我们看看兄弟结点有没有富裕(大于Math.ceil(m/2)-1)的关键字,给他借过来。这一步叫借结点。

那么合并结点就是当兄弟结点不足的时候,我们需要找兄弟结点来进行合并,从而满足B树的定义。

下面具体看下实现过程:

首先我们删除关键字8:因为是叶子结点,且删除后关键字满足 >=Math.ceil(m/2)-1 的条件

我们继续删除关键字29:首先找到他的前继结点关键字28来替换删除

删除之后我们会发现当前结点关键字已经不足2了,所以需要借结点或者合并结点,我们会发现,左兄弟结点是富裕的,所以可以向左兄弟结点借关键字。借的过程:父结点下移一个关键字,左兄弟结点上移一个关键字,从而使B树平衡。

删完29之后,我们再来删除关键字11,此时兄弟结点没有富裕的关键字来让该结点满足 >=Math.ceil(m/2)-1 的条件,所以需要进行结点合并, 这里我们选择合并左孩子来进行合并 ,合并过程:将父结点关键字下移,到合并结点,失衡的被合并结点关键字插入到合并结点,父结点删除失衡结点。由于父结点是根结点,所以即使一个关键字也符合要求。

总结

在实现B树的过程中,有一点容易让人误解的是孩子结点与父亲结点的关系,实际上孩子是整个结点的孩子,而不是结点某个关键字的孩子。

但是孩子结点与父亲结点关键字之间是存在一定的关系的,比如父结点有两个关键字,那么就会有三个孩子 ,而父结点关键字所在的索引序号,比如下标是0, 那么孩子中下标为0的结点所有关键字都会小于父亲结点下标为0的关键字 ,如下图,这也是为什么使用二分可以找到相应关键字的原因。

下图是一个3阶B树的例子

B树的优势除了树矮小,还有对访问局部性原理的利用。所谓局部性原理,是指当一个数据被使用时,其附近的数据有较大概率在短时间内被使用。B树将键相近的数据存储在同一个节点当访问其中某个数据时,数据库会将该整个节点读到缓存中;当它临近的数据紧接着被访问时,可以直接在缓存中读取,无需进行磁盘IO;换句话说,B树的缓存命中率更高。

B树在数据库中有一些应用,如mongodb的索引使用了B树结构。但是在很多数据库应用中,使用了是B树的变种B+树。

五、B+树

基于以上的缺陷,又诞生了一种新的优化B树的树: B+ 树




B+树在定义上似乎没有官方的定义,从论坛上看,目前还是对定义存在两点争论:

其一:B+Tree是否B-Tree一样是结点有M-1个关键字拥有M棵子树,还是M个关键字拥有M颗子树。

其二:内部结点的索引值使用最大值还是最小值。

不过上述的争论对于实现并没有大的影响,我们可以自己去定义。所以这里选用百度百科上对B+树的描述:

(1)每个结点至多有m个子女;

(2)除根结点外,每个结点至少有[m/2]个子女,根结点至少有两个子女;

(3)有k个子女的结点必有k个关键字。

下面是实现的树(貌似根结点可以有一个关键字,但是这里还是引用k个子女的结点必有k个关键字 这条逻辑)。

从上面B+树的图可以看出与B-树相比,非叶子结点不存储数据,仅充当索引结点 ,其次是所有的数据都存储在叶子结点上, 且叶子结点利用指针形成单链表(双向链表,笔误)。通过这些特征我们可以得知:

  • 1.由于B+树内部结点不存储数据,所以树更加矮,内部结点在相同大小的磁盘页能存储更多,一次性读入内存的关键字也会更多,减少IO操作
  • 2.由于B+树内部结点不存储数据,所以查询全部落入叶子结点,所以相对B树查询更加稳定
  • 3.由于B+树叶子结点使用指针链接成链表,所以相对与B-树,其范围查询更加高效,因为B-树中范围查询 需要对B-树进行中序遍历,所以效率会低

注:B-Tree稳定不代表一定会快,如果是随机访问或者单一查询,有可能B树更快(数据存储在距离根结点越近则越快), 同理IO操作也不一定比B+Tree多。这也是为什么非关系型数据库不选用B+Tree的原因,因为非关系型数据库通常都是单一查询, 不需要遍历匹配。还需要注意的是,B+Tree与B-Tree一样,当按照key值的大小顺序插入分裂时,每个叶子结点的存储效率只有50%,如下图,我们会发现2与3之间不能再插入其他的正整数, 也就造成了空间的浪费

构建B+树

仍以5阶为例,来构建5阶B+树。通过B+树的定义,我们可以知道,其结点最多有5个关键字,最少有[5/2]=3个关键字。这里假设存储的关键字为3, 8, 31, 11, 23, 29, 50, 28,1, 2,来看如何构建。

首先定义一颗空树,然后依次新增,新增流程如下:依次插入3, 8, 31, 11, 23

此时结点关键字已经达到M个的要求,如果再继续新增29时,会同B树一样,需要进行分裂。但是与B树分裂存在一些区别, 如下图,关键字上升的过程中,关键字并未从原来的结点中移除(B树中会被移除),其次根结点形成时,上升了两个关键字, 来保证k个子女的结点必有k个关键字的要求(我见到也有根结点有一个关键字的博文,这里感觉不打紧,所以就不纠结), 最后就是叶子结点之间利用形成单向链表

继续新增50。这一步相对与B树同样存在一些特殊步骤,更新内部的索引结点,插入50之前,31是最大值,索引结点存储11,31表明 目前31对应子树关键字范围在(11,31]之间,但是插入50之后,沿路的索引值也需要进行更新

最后插入28,1,2,这次插入无需更新沿路的索引结点,因为其并未打破索引的范围规则

通过新增的流程,我们会发现,相比较B树,插入逻辑要复杂一些。复杂在当插入结点打破索引规则时, 需要更新沿路索引,其次对于分裂的叶子结点需要形成一个单向链表

删除

新增操作之后继续看删除的流程,我们依次删除50,23,28,1,2。

删除50后,虽然结点多于最小关键字个数,但是索引结点的平衡被打破,即不存在50的索引,所以需要更新索引结点。

删除23的过程中,即没有打破索引,也没有导致结点关键字少于最小关键字个数,所以整棵树并没有大的改动, 但是当我们删除28的时候,结点最关键字小于最小关键字个数。此时就需要借结点或者合并结点。针对删除28,我们会发现,他的左兄弟结点关键字个数大于最小关键字个数,所以可以借用,借用过程:借用左兄弟最大关键字或者右兄弟最小关键字,如果是借用左兄弟,则更新左兄弟对应父结点的索引值(因为最大关键字被借走), 如果借用右兄弟则更新当前结点对应父结点的索引值(因为借过来的肯定比当前索引值大)

继续删除1,2。在删除2的过程中,结点关键字个数少于最小关键字个数,此时兄弟结点的关键字个数无法外借(因为已经是最少关键字), 此时进行合并,合并流程:如果合并之后根结点孩子不足2,则移除根结点,合并结点充当根结点,如果合并之后,根结点孩子大于等于2, 则将被合并结点对应父结点的索引值移除。

六、感受B+树的威力

前面说到,B树/B+树与红黑树等二叉树相比,最大的优势在于树高更小。实际上,对于Innodb的B+索引来说,树的高度一般在2-4层。下面来进行一些具体的估算。

树的高度是由阶数决定的,阶数越大树越矮;而阶数的大小又取决于每个节点可以存储多少条记录。Innodb中每个节点使用一个页(page),页的大小为16KB,其中元数据只占大约128字节左右(包括文件管理头信息、页面头信息等等),大多数空间都用来存储数据。

B+ 树和 B 树的区别?

为什么 B+ 树比 B 树更适合应用于数据库索引?

七、总结

之后我会持续更新,如果喜欢我的文章,请记得一键三连哦,点赞关注收藏,你的每一个赞每一份关注每一次收藏都将是我前进路上的无限动力 !!!↖(▔▽▔)↗感谢支持!

相关推荐
hj104339 分钟前
redis开启局域网访问
数据库·redis·缓存
睡觉的时候不会困3 小时前
MySQL 数据库表操作与查询实战案例
数据库·mysql
秋已杰爱3 小时前
Redis常见命令
数据库·redis·缓存
一个有梦有戏的人3 小时前
软考架构师:数据库的范式
数据库·oracle
stray小书童4 小时前
neo4j数据库实战
数据库·neo4j
时序数据说5 小时前
时序数据库为什么选IoTDB?
大数据·数据库·物联网·开源·时序数据库·iotdb
{⌐■_■}6 小时前
【MongoDB】简单理解聚合操作,案例解析
数据库·线性代数·mongodb
zuozewei6 小时前
MySQL高可用改造之数据库开发规范(大事务与数据一致性篇)
数据库·mysql·数据库开发
THXW.8 小时前
【Java项目与数据库、Maven的关系详解】
java·数据库·maven