漫谈MySQL十-索引的数据结构,从二叉树到B+树

1,200 阅读11分钟

大家好,我是王老狮,索引使我们在学习数据库非常核心的一个能力,也对我们SQL的优化以及提升查询效率有极大的关系。那么索引是怎么实现的呢?他的数据结构是什么样的呢?为什么要用这样的数据结构呢?我们带着疑问来学习下索引数据结构相关的知识。

一.MySQL索引的数据结构

1.1.什么是索引

MySQL 官方对索引的定义为:索引(Index)是帮助 MySQL 高效获取数据的 数据结构。可以得到索引的本质:索引是数据结构。

上面的理解比较抽象,举一个例子,平时看任何一本书,首先看到的都是目录,通过目录去查询书籍里面的内容会非常的迅速。

和书的目录一样,下图就是MySQL中的一种B+树索引,每一 个节点都是主键的 ID当我们通过 ID 来查询内容的时候,首先去查索引库,查到索引后能快速的 根据索引定位数据的具体位置。

1.2.索引的常见分类

InnoDB 存储引擎支持以下几种常见的索引:B+树索引、全文索引、哈希索引,其中比较关键的是 B+树索引。

B+树索引就是传统意义上的索引,这是目前关系型数据库系统中查找最常用 和最为有效的索引。B+树索引的构造类似于二叉树,根据键值(Key Value)快速 找到数据。注意 B+树中的 B 不是代表二叉(binary) ,而是代表平衡(balance) ,因 为 B+树是从最早的平衡二叉树演化而来,但是 B+树不是一个二叉树。

在了解 B+树索引之前先要知道与之密切相关的一些算法与数据结构,这有 助于我们更好的理解 B+树索引的工作方式,因为 B+树是通过二叉查找树,再由 平衡二叉树,B 树演化而来。

二.二分查找

二分查找法(binary search)  也称为折半查找法,用来查找一组有序的记录 数组中的某一记录。

其基本思想是:将记录按有序化(递增或递减)排列,在查找过程中采用跳 跃式方式查找,即先以有序数列的中点位置作为比较对象,如果要找的元素值小 于该中点元素,则将待查序列缩小为左半部分,否则为右半部分。通过一次比较, 将查找区间缩小一半。

#  给出一个例子,注意该例子已经是升序排序的,且查找 数字 48 数据:5 ,  10 ,  19 ,  21 ,  31 ,  37 ,  42 ,  48 ,  50 ,  52

下标:0 ,  1 ,   2 ,   3 ,   4 ,   5 ,   6 ,   7 ,   8 ,   9

  • 步骤一:设 low  为下标最小值 0  ,  high  为下标最大值 9 ;
  • 步骤二:通过 low  和 high  得到 mid  ,mid=  (low + high)  / 2 ,初始时 mid  为下标 4 (也可以=5 ,看具体算法实现);
  • 步骤三 :  mid=4  对应的数据值是 31 ,31 < 48  (我们要找的数字);
  • 步骤四:通过二分查找的思路,将 low  设置为 31 对应的下标 4  ,  high 保持不变为 9  ,此时 mid  为 6  ;
  • 步骤五 :  mid=6  对应的数据值是 42 ,42 < 48  (我们要找的数字);
  • 步骤六:通过二分查找的思路,将 low  设置为 42 对应的下标 6  ,  high 保持不变为 9  ,此时 mid  为 7  ;
  • 步骤七 :  mid=7  对应的数据值是 48 ,48 == 48(我们要找的数字),查 找结束;通过 3 次二分查找 就找到了我们所要的数字,而顺序查找需 8 次。

因此二分查找法的效率比顺序查找法要好(平均地来说) 。但如果说查 5 这 条记录,顺序查找只需 1 次,而二分查找法需要 4 次。我们来看,对于上面 10 个数来说,顺序查找平均查找次数为(1+2+3+4+5+6+7+8+9+ 10)/10=5.5 次。而二 分查找法为(4+3+2+4+3+ 1+4+3+2+3)/10=2.9 次。在最坏的情况下,顺序查找的次 数为 10 ,而二分查找的次数为 4。

三、树结构的演进

根据数组的特性我们知道,有最优的查找效率和空间需求;能够进行有序性 相关操作,缺点也很明显,插入和删除操作很慢,所以为了结合链表插入的灵活 性和有序数组查找的高效性,我们引入了二叉查找树。那么首先来看看什么是二叉树。

3.1.二叉树

每个节点至多只有二棵子树,左子树和右子树,次序不能颠倒。逻辑上二叉 树有五种基本形态:空二叉树、只有一个根结点的二叉树、只有左子树、只有右 子树、完全二叉树(特例为满二叉树)。遍历是对树的一种最基本的运算,所谓 遍历二叉树,就是按一定的规则和顺序走遍二叉树的所有结点,使每一个结点都 被访问一次,而且只被访问一次,有前序、中序、后序遍历,如下图。

3.2.二叉查找(搜索)树

二叉查找树首先肯定是个二叉树,除此之外,还规定,在二叉查找树中,左 子树的节点值总是小于根的节点值,右子树的节点值总是大于根的节点值,也就 是左子树节点值<根的节点值<右子树节点值。因此可以通过中序遍历得到节点值 的排序输出。以一个排序好的数组为例:

10 、25 、34 、48 、61 、73 、81

可以将这个数组转为二叉树结构,其中数组的中间元素作为树的根节点,左 半部分的中间元素作为根节点的左孩子,右半部分的中间元素作为根节点的右孩 子,第二层的节点以此类推,最后树的层数就会越来越多,这样我们就构建好了 一颗二叉树。

同时这棵树,对于每个节点而言,其左孩子都小于它的父节点,右孩子都大 于等于它的父节点。所以我们把这样的二叉树也是一个二叉查找树(binarysearch tree),它的查找时间复杂度和二分查找一样,O(logN)。

但是二叉查找树,如果设计不良,完全可以变成一颗极不平衡的二叉查找树:

查找效率和顺序查找基本没差别了。因此若想最大性能地构造一棵二叉查找树,需要这棵二叉查找树是平衡的,从而引出了新的定义——平衡二叉树,或称 为 AVL 树。

3.3.平衡二叉树(AVL-树)

平衡二叉树的定义如下:首先符合二叉查找树的定义,其次必须满足任何 节点的两个子树的高度最大差为 1。

平衡二叉树的查找性能是比较高的,但不是最高的,只是接近最高性能。最 好的性能需要建立一棵最优二叉树,但是最优二叉树的建立和维护需要大量的操 作,因此,用户一般只需建立一棵平衡二叉树即可。

平衡二叉树的查询速度的确很快,但是维护一棵平衡二叉树的代价是非常大 的。通常来说,需要 1 次或多次左旋和右旋来得到插入、更新和删除后树的平衡性。

平衡二叉树对一个数据库来说,有什么问题?因为二叉树每个节点最多只有 两个直属子节点,所以当节点数比较多时,二叉树的高度增长很快,比如 1000 个节点时,树的高度差不多有 9 到 10 层。我们知道数据库是持久化的,数据是 要从磁盘上读取的,一般的机械磁盘每秒至少可以做 100 次 IO,一次 IO 的时间 基本上在 0.01 秒,1000 个节点在查找时就需要 0.1 秒,如果是 10000 个节点,100000 个节点呢?所以对数据库来说,为了减少树的高度,提出了 B+树的数据 结构。

3.4.B+

B+树的定义:B+树和二叉树、平衡二叉树一样,都是经典的数据结构。B+树由 B 树和索 引顺序访问方法演化而来,但是在现实使用过程中几乎已经没有使用 B 树的情况 了。

B+树的定义在很多数据结构书中都能找到,非常复杂,我们概略它的定义:B+树是 B 树的一种变形形式,B+树上的叶子结点存储关键字以及相应记录 的地址,叶子结点以上各层作为索引使用。一棵 m 阶的 B+树定义如下:

  • 每个节点最多可以有 m  个元素;
  • 除了根节点外,每个节点最少有 (m/2)  个元素;
  • 如果根节点不是叶节点,那么它最少有 2  个孩子节点;
  • 所有的叶子节点都在同一层;
  • 一个有 k  个孩子节点的非叶子节点有 (k- 1)  个元素,按升序排列;
  • 某个元素的左子树中的元素都比它小,右子树的元素都大于或等于它;
  • 非叶子节点只存放关键字和指向下一个孩子节点的索引,记录只存放在 叶子节点中;
  • 相邻的叶子节点之间用指针相连。

B+树的变体为 B树,在 B+树的非根和非叶子结点再增加指向兄弟的指针; B树定义了非叶子结点关键字个数至少为(2/3)*M,即块的最低使用率为 2/3(代 替 B+树的 1/2)。我们概要的了解下 B 树和 B+树。

B+树是为磁盘或其他直接存取辅助设备设计的一种平衡查找树。在 B+树中, 所有记录节点都是按键值的大小顺序存放在同一层的叶子节点上,由各叶子节点 指针进行连接。比如:

 从上图我们可以归纳出 B+树的几个特征,在 B+树的简要定义中其实已经包 括了:

  1. 相同节点数量的情况下,B+树高度远低于平衡二叉树;
  2. 非叶子节点只保存索引信息和下一层节点的指针信息,不保存实际数据 记录;
  3. 每个叶子页(LeafPage)存储了实际的数据,比如上图中每个叶子页就 存放了4 条数据记录,当然可以更多,叶子节点由小到大(有序)串联在一起, 叶子页中的数据也是排好序的;
  4. 索引节点指示该节点的左子树比这个索引值小,而右子树大于等于这个 索引值。

 注意:叶子节点中的数据在物理存储上完全可以是无序的,仅仅是在逻辑 上有序(通过指针串在一起)。

3.5.磁盘和 B+树

为什么关系型数据库都选择了 B+树,这个和磁盘的特性有着非常大的关系。

如果我们简化一下,可以这么看

一个磁盘由大小相同且同轴的圆形盘片组成,磁盘可以转动(各个磁盘必 须同步转动)。在磁盘的一侧有磁头支架,磁头支架固定了一组磁头,每个磁头 负责存取一个磁盘的内容。磁头不能转动,但是可以沿磁盘半径方向运动。

盘片被划分成一系列同心环,圆心是盘片中心,每个同心环叫做一个磁道, 所有半径相同的磁道组成一个柱面。磁道被沿半径线划分成一个个小的段,每个 段叫做一个扇区,每个扇区是磁盘的最小存储单元也是最小读写单元。现在磁盘 扇区一般是 512 个字节~4k 个字节。

磁盘上数据必须用一个三维地址唯一标示:柱面号、盘面号、扇区号。

读/写磁盘上某一指定数据需要下面步骤:

(1) 首先移动臂根据柱面号使磁头移动到所需要的柱面上,这一过程被称 为定位或查找。

(2)所有磁头都定位到磁道上后,这时根据盘面号来确定指定盘面上的具体 磁道。

(3) 盘面确定以后,盘片开始旋转,将指定块号的磁道段移动至磁头下。

经过上面步骤,指定数据的存储位置就被找到。这时就可以开始读/写操作了。

 可以看见,磁盘读取依靠的是机械运动,分为寻道时间、旋转延迟、传输 时间三个部分,这三个部分耗时相加就是一次磁盘 IO 的时间,一般大概 9ms 左 右。寻道时间(seek)是将读写磁头移动至正确的磁道上所需要的时间,这部分 时间代价最高;旋转延迟时间(rotation)是磁盘旋转将目标扇区移动到读写磁 头下方所需的时间,取决于磁盘转速;数据传输时间(transfer)是完成传输数 据所需要的时间,取决于接口的数据传输率,在纳秒级,远小于前两部分消耗时 间。磁盘读取时间成本是访问内存的几百倍到几万倍之间。

为了提高效率,要尽量减少磁盘 I/O。为了达到这个目的,磁盘往往不是严 格按需读取,而是每次都会预读,即使只需要一个字节,磁盘也会从这个位置开 始,顺序向后读取一定长度的数据放入内存,这个称之为预读。这样做的理论依 据是计算机科学中著名的局部性原理:

当一个数据被用到时,其附近的数据也通常会马上被使用。 程序运行期间所需要的数据通常比较集中。

由于磁盘顺序读取的效率很高(不需要寻道时间,只需很少的旋转时间), 一般来说,磁盘的顺序读的效率是随机读的 40 到 400 倍都有可能,顺序写是随 机写的 10 到 100 倍(SSD 盘则差距要小的多,顺序读写的效率是随机读写的 7 到 10 倍,但是有评测表明机械硬盘的顺序写性能稍优于 SSD 。总的来说 Mysql 数据库如果由硬盘由机械的换成 SSD 的,性能会有很大的提升),因此对于具有 局部性的程序来说,预读可以提高 I/O 效率。

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

按照磁盘的这种性质,如果是一个页存放一个 B+树的节点, 自然是可以存 放很多的数据的,比如 InnoDB 里,默认定义的 B+树的节点大小是 16KB,这就是 说,假如一个 Key 是 8 个字节,那么一个节点可以存放大约 1000 个 Key ,意味 着 B+数可以有 1000 个分叉。同时 InnoDB 每一次磁盘 I/O ,读取的都是 16KB 的 整数倍的数据。也就是说 InnoDB 在节点的读写上是可以充分利用磁盘顺序 IO 的 高速读写特性。

同时按照 B+树逻辑结构来说,在叶子节点一层,所有记录的主键按照从小 到大的顺序排列,并且形成了一个双向链表。同一层的非叶子节点也互相串联, 形成了一个双向链表。那么在实际读写的时候,很大的概率相邻的节点会放在相 邻的页上,又可以充分利用磁盘顺序 IO 的高速读写特性。所以我们对 MySQL 优 化的一大方向就是尽可能的多让数据顺序读写,少让数据随机读写

四.总结 B+树的作用

  • 在块设备上,通过 B+树可以有效的存储数据;
  • 所有记录都存储在叶子节点上,非叶子(non-leaf)存储索引(keys)信息; 而且记录按照索引列的值由小到大排好了序。
  • B+树含有非常高的扇出(fanout),通常超过 100 ,在查找一个记录时,可以有效的减少 IO 操作;

Tips:扇出 是每个索引节点(Non-LeafPage)指向每个叶子节点(LeafPage)的指针;

扇出数 = 索引节点(Non-LeafPage)可存储的最大关键字个数+ 1