如果把数据库中的数据当做1个词典,那索引就是字典的目录,其目的是提升查找数据的速度。

树的数据结构天然适合查找操作,最先被想到就是搜索二叉树。

<>搜索二叉树

二叉树(Binary Search Tree)是每个节点最多有2个子树(左子树和右子树)的树结构,而搜索二叉树是一类特殊的二叉树,其具有以下性质:

* 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值;
* 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值;
* 它的左右子树也分别为搜索二叉树。
搜索二叉树中序遍历的结果是有序的。

搜索二叉树能够提高查询的效率 O(logN),但是当你插入 {1, 2, 3, 4, 5, 6} 这类数据的时候,搜索二叉树会退化成"链表",搜索效率变为
O(N)。

为了让二叉搜索树的左右子树平衡,出现了平衡二叉树。

<>平衡二叉树

平衡二叉树是1种特殊的二叉树,除了满足前面提到的搜索二叉树的特性,其还有1个约束条件:

*
平衡二叉树左右子树的高度差的绝对值不超过1,并且其左右子树也分别为平衡二叉树。

可以通过左旋和右旋的方式将非平衡的搜索二叉树转化为平衡二叉树。

*
左旋

旧的根节点为新根节点的左子树,新根节点的左子树(如果存在)为旧根节点的右子树。

* 右旋
旧根节点为新根节点的右子树,新根节点的右子树(如果存在)为旧根节点的左子树。

1棵平衡二叉树最多容纳多少节点呢?

假设树的高度为h,则平衡二叉树最多容纳 1+21+22+…+2(h-1)=2h-1。

如果整个平衡二叉树可以完整的加载到内存中,则其性能完全满足数据库索引的需求。但现实情况却是,当数据量比较大的时候,索引的大小可能有几个G甚至更多,当我们用索引查询的时候,很难将其全部加载到内存进行查找,能做的只有逐一加载每一个树节点,这里的树节点一般对应磁盘页或者磁盘页组合,我们姑且称之为数据块。

如果每个数据块存储10行数据,每个数据块代表树的1个节点,则1000万行数据需要高度为20的平衡二叉树,即从1000万行数据的平衡二叉树中找1行数据,最坏的情况下需要20次磁盘IO。在机械硬盘时代,从磁盘随机读取1个数据块需要
10ms 左右的寻址时间,也就是说,对于1个1000万行的表,如果采用平衡二叉树来存储,单独访问1行需要20个 10ms 的时间,这个查询可真够慢的。

为了让1个查询尽量少地读磁盘,就必须让查询过程中访问尽量少的数据块,而磁盘IO次数就是树的高度,所以我们需要想办法降低树的高度,使其变得矮胖,最容易想到的方案就是将二叉树调整为多叉树,让每一层能够容纳更多的节点。

而 B-Tree(Balance Tree) 就是典型的多叉平衡搜索树。

其实平衡二叉树的查询性能已经是最高的了,将二叉树改多叉树实属无奈之举(因为内存无法完全加载索引),这也就理解了为啥 HashMap
这类纯内存的集合,使用平二叉的红黑树,而不是多叉树。

<>B-Tree

需要注意的是: 中间的-符号不是减号,读作B树,而不是B减树。

一棵 m 阶的 B 树具有如下特征:

* 根节点至少有2个子女;
* 每个中间节点都包含 k-1 个元素和 k 个孩子,其中 m/2<=k<=m;
* 每一个叶子节点都包含 k-1 个元素,其中 m/2<=k<=m;
* 所有的叶子节点均位于同一层;
* 每个节点中的元素从小到大排列,节点当中 k-1 个元素正好是 k 个孩子包含的元素的值域划分。
这条条框框的,听着就头大,别急,我们以3阶 B-Tree 为例:

重点看一下 (2,6) 节点,其包含2和6两个元素,又有3个孩子1、(3,5)、8。其中1小于元素2,(3,5)
在元素2和元素5之间,8大于元素6,正好符合刚才所列的几条特征。

基于 B-Tree
查找某元素时,元素之间的比较次数和平衡二叉树其实是一致的,但由于查找所经过的节点数量要小的多,也就意味着更少次数的磁盘IO,会极大的提高性能。

B-Tree 节点上除了索引元素,还包含卫星数据。

所谓卫星数据,指的是索引元素所指向的数据记录,比如数据库中的某一行。在 B-Tree 树中,无论中间节点还是叶子节点均带有卫星数据。

所以本质上数据库基于 B-Tree 实现索引时,每个节点是 key-data 的形式,key 就是数据的主键,data 就是具体的数据。

接下来我们说一下 B+ 树。

<>B+ Tree

B+ 树和 B 树有一些共同点,但也具备一些新的特性:

* 每个中间节点都包含 k 个元素和 k 个孩子,其中 m/2<=k<=m;
* 中间节点仅包含索引元素,不包含数据,所有数据均在叶子节点;
* 所有叶子节点包含了全部元素的信息及指向这些元素的指针,且叶子节点本身根据关键字的大小有小到大顺序排列;
* 所有中间节点的元素均存在于子节点,在子节点元素中是最大(或最小)元素。
直接举例说明:


可以看到,每一个父节点元素均出现在子节点中,是子节点中的最大(或最小)元素。如:根节点元素8是子节点(2,5,8)的最大元素,也是叶子节点(6,8)的最大元素。

需要注意的是,根节点的最大元素(这里是15),也就等同于整个 B+ 树的最大元素。后续无论插入删除多少元素,始终要保持最大元素在根节点之中。

至于叶子节点,由于父节点的元素均会出现在子节点中,层层向下传导,所有叶子节点包含了全部元素信息。并且每一个叶子节点都带有指向下一个节点的指针,形成1个有序链表。

同样的,由于叶子节点包含了全部元素信息,所以 B+ 树的卫星数据只存在于叶子节点中,中间节点仅包含索引元素。

这时候,有读者就会问了,如果 B+ 树的卫星数据只存在于叶子节点上,岂不是每次都得遍历到最底下一层,才能拿到相应数据,而 B-Tree
中间节点就包含有卫星数据,假设索引元素命中,在中间层就可以返回,岂不是平均IO次数更低,那为啥还要使用 B+ 树呢?

主要是因为在 B+ 树中,中间节点仅存储索引元素,而 B-Tree 的中间节点即存储索引元素又存储索引元素对应的卫星数据,所以同样存储空间的节点,显然 B+
树可以容纳更多元素。比如建立100万条数据的索引,使用 B+ 树相比 B 树会使用更少的层数,即 B+ 树更矮胖,随之带来的就是磁盘IO次数的降低。

同时,由于 B+
树的叶子节点是根据索引元素大小顺序排列的,所以对于范围查询,只要从上到下找到范围的下界叶子节点,然后顺着叶子节点的横向指针就可以将符合条件的所有元素遍历出来。如果使用
B 树,则需要进行繁琐的中序遍历。

综上,B+ 树相对于 B 树,具有如下优点:

* 总元素个数相同的 B+ 树和 B 树相比,B+ 树更为矮胖(层高低),使得查询的 IO 次数更少;
* B+ 树的所有叶子节点形成有序链表,便于范围查询。
其实,B+ 树还有1个隐含优势,就是 B+ 树的元素查询性能更为稳定。

因为 B+ 树的查询必须最终找到叶子节点,而 B 树只要找到匹配元素即可,无论匹配元素处于中间节点还是叶子节点。因此,B
树的查询性能并不稳定(最好情况是只查根节点,最差情况是查到叶子节点),而 B+ 树的每一次查询过程都是稳定的。

<>B+ 树的层数计算

在计算机中,磁盘存储数据的最小单元是扇区,1个扇区的大小是512字节,而文件系统(例如 XFS/EXT4)的最小单元是块,1个块的大小是 4K。

MySQL 默认的 InnoDB 存储引擎也有自己的最小存储单元–页(Page),1页的默认大小是 16K。

假设 B+ 树每个节点均代表1页。

先假设 B+ 树有2层,第1层为非叶子节点,保存索引字段和指针,第2层为叶子节点,保存索引字段和具体行记录。

那么,2层高度的 B+ 树能存储的行记录数=根节点的指针数*每个指针对应的第2层的行记录数。

* 根节点的指针数
假设主键为 bigint 类型(8字节),InnoDB 的指针占用6个字节,则1个 page 能存放的指针数为 16K/(8+6) 约等于1170;

* 叶子节点的行记录数
常规的互联网项目的单行记录大小约为1K,则1个 page 可以存储的行记录数为 16K/1K=16。

所以1个2层 B+ 树大概能存储的行记录数为 1170*16=18,720。

以此类推:

3层 B+ 树: 1170117016=21,902,400(约2190万);
4层 B+ 树: 117011701170*25,625,808,000(256亿)。

考虑到根节点的数据块总是在内存中,1个200亿行的表上1个 bigint
类型的索引,查找1行数据最多只需要访问3次磁盘。其实,树的第二层也有很大概率在内存中,那么访问磁盘的平均次数就更少了。

哈哈哈,看出来多叉树的威力了吧。

本文到此结束,感谢阅读!

<>参考文献

* MySQL实战45讲(丁奇)
* 漫画: 什么是B+树?(程序员小灰)

技术
下载桌面版
GitHub
百度网盘(提取码:draw)
Gitee
云服务器优惠
阿里云优惠券
腾讯云优惠券
华为云优惠券
站点信息
问题反馈
邮箱:ixiaoyang8@qq.com
QQ群:766591547
关注微信