理解 B+Tree 时,只需要理解其最重要的两个特征即可:
- 所有的关键字(可以理解为数据)都存储在叶子节点(Leaf Page),非叶子节点(Index Page)并不存储真正的数据,所有记录节点都是按键值大小顺序存放在同一层叶子节点上。
- 所有的叶子节点由指针连接。如下图为高度为 2 的简化了的 B+Tree。
简化 B+Tree
怎么理解这两个特征?MySQL 将每个节点的大小设置为一个页的整数倍(原因下文会介绍),也就是在节点空间大小一定的情况下,每个节点可以存储更多的内结点,这样每个结点能索引的范围更大更精确。
所有的叶子节点使用指针链接的好处是可以进行区间访问,比如上图中,如果查找大于 20 而小于 30 的记录,只需要找到节点 20,就可以遍历指针依次找到 25、30。
如果没有链接指针的话,就无法进行区间查找。这也是 MySQL 使用 B+Tree 作为索引存储结构的重要原因。
MySQL 为何将节点大小设置为页的整数倍,这就需要理解磁盘的存储原理。磁盘本身存取就比主存慢很多,再加上机械运动损耗(特别是普通的机械硬盘),磁盘的存取速度往往是主存的几百万分之一。
为了尽量减少磁盘 I/O,磁盘往往不是严格按需读取,而是每次都会预读,即使只需要一个字节,磁盘也会从这个位置开始,顺序向后读取一定长度的数据放入内存,预读的长度一般为页的整数倍。
页是计算机管理存储器的逻辑块,硬件及 OS 往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(许多 OS 中,页的大小通常为 4K)。主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后一起返回,程序继续运行。
MySQL 巧妙利用了磁盘预读原理,将一个节点的大小设为等于一个页,这样每个节点只需要一次 I/O 就可以完全载入。
为了达到这个目的,每次新建节点时,直接申请一个页的空间,这样就保证一个节点物理上也存储在一个页里,加之计算机存储分配都是按页对齐的,就实现了读取一个节点只需一次 I/O。
假设 B+Tree 的高度为 h,一次检索最多需要 h-1 次 I/O(根节点常驻内存),复杂度 O(h) = O(logmN)。
实际应用场景中,M通常较大,常常超过 100,因此树的高度一般都比较小,通常不超过 3。
最后简单了解下 B+Tree 节点的操作,在整体上对索引的维护有一个大概的了解,虽然索引可以大大提高查询效率,但维护索引仍要花费很大的代价,因此合理的创建索引也就尤为重要。
仍以上面的树为例,我们假设每个节点只能存储 4 个内节点。首先要插入第一个节点 28,如下图所示:
leaf page 和 index page 都没有满
接着插入下一个节点 70,在 Index Page 中查询后得知应该插入到 50-70 之间的叶子节点。
但叶子节点已满,这时候就需要进行分裂的操作,当前的叶子节点起点为 50,所以根据中间值来拆分叶子节点,如下图所示:
Leaf Page 拆分
最后插入一个节点 95,这时候 Index Page 和 Leaf Page 都满了,就需要做两次拆分,如下图所示:
Leaf Page 与 Index Page 拆分
拆分后最终形成了这样一棵树,如下图所示:
最终树
B+Tree 为了保持平衡,对于新插入的值需要做大量的拆分页操作,而页的拆分需要 I/O 操作,为了尽可能的减少页的拆分操作,B+Tree 也提供了类似于平衡二叉树的旋转功能。
当 Leaf Page 已满但其左右兄弟节点没有满的情况下,B+Tree 并不急于去做拆分操作,而是将记录移到当前所在页的兄弟节点上。
通常情况下,左兄弟会被先检查用来做旋转操作。就比如上面第二个示例,当插入 70 的时候,并不会去做页拆分,而是左旋操作。
左旋操作
通过旋转操作可以最大限度的减少页分裂,从而减少索引维护过程中的磁盘的 I/O 操作,也提高索引维护效率。
需要注意的是,删除节点跟插入节点类似,仍然需要旋转和拆分操作,这里就不再说明。