你管这破玩意叫 B+ 树?(二)

Hunter37
发布于 2022-5-26 18:38
浏览
1收藏

 

B+ 树的诞生


现在问题来了,如果有很多页,该怎么定位元素呢,如果元素刚好在前几个页还好,大不了遍历前几个页也很快,但如果要查 id = 100w 这样的元素一页页遍历的话就要遍历 1w 页(假设每页 100 条记录),那显然是不可接受的,如何改进呢,其实之前建的页内目录已经给了我们启发,既然在页内我们可以通过为记录建页目录的形式来先定位元素在哪个槽然后再找,那针对多页,能否先定位元素在哪个页呢,也就是说我们可以为页也建立一个目录,这个目录里的每一条记录都对应着页及页中的最小记录,当然这个目录也是以页的形式存在的,为了便于区分 ,我们把针对页生成的目录对应的页称为目录页,而之前存储完整记录的页称为数据页

 你管这破玩意叫 B+ 树?(二)-鸿蒙开发者社区
画外音:目录页与数据页一样,内部也是有槽的,上文为了方便展示,没有画出,目录页和数据除了记录数据不一样,其他结构都是一致的

 

现在如果要查找 id = xxx 的记录就很简单了,只要先到目录页中定位它的起始页然后再依次查找即可,由于不管是目录页还是数据页里面都有槽,所以无论是定位目录页的页码还是定位数据页中的记录都是非常快的。

 

当然了,随着页的增多,目录页存放的记录也越来越多,目录页也终归会满的,那就再建一个目录页吧,于是现在问题来了,怎么定位要找的 id 是在哪个目录页呢,再次制定针对目录页的目录页不就行了,如下

 你管这破玩意叫 B+ 树?(二)-鸿蒙开发者社区
看到上面这个结构你想到了什么?没错,这就是一颗 B+ 树!到此相信你已经明白了 B+ 树的演进之路,也明白了它的原理,可以看到这颗 B+ 树有三层,我们把最顶层的目录页称为根节点,最下层的存储完整记录的页称为叶子节点

 

现在我们再来看一下如何查找  id = 55 的记录,首先会加载根节点,发现应该在页码 30 的页中去找,于是加载页 30,在页 30 中又发现应该在页 4 中查中,于是再次把页 4 加载进内存中,然后在页 4 中依次遍历查找,可以看到总共经历了 3 次 IO(B+树有几层就会有几次 IO),页读取之后会缓存在内存中,再读的话如果命中内存中的页就会直接从内存中获取。有人可能会问,如果 B+ 树层数很多,那岂不是可能会有很多次 IO,我们简单的算一下,假设数据页可以存储 100 条记录,目录页可以存储 1000 条记录(目录页由于只存储了主键,不存储完整的数据,所以可以存储更多的记录),那么

  • 如果B+树只有 1 层,也就是只有 1 个用于存放用户记录的节点,最多能存放100条记录。
  • 如果B+树有 2 层,最多能存放1000×100=100000条记录。
  • 如果B+树有 3 层,最多能存放1000×1000×100=100000000条记录。
  • 如果B+树有 4 层,最多能存放1000×1000×1000×100=100000000000条记录!
    所以一般3~4 层的 B+ 树足以满足我们的要求,而且每次读取后会缓存在内存中(当然也会根据一定的算法被换出内存),所以整体来看 3~4 层 B+ 树足以满足我们需求

 

聚簇索引与非聚簇索引


相信你已经发现了,上文中我们举的 B+ 树的例子针对的是 id 也就是主键的索引,不难发现主键索引中的叶子结点存储了完整的 SQL 记录,我们把这种存储了完整记录的索引称为聚簇索引,只要你定义了主键,那么主键索引就是聚簇索引。

 

那么如果是非主键的列创建的索引又是怎样的形式呢,非叶子节点的形式完全一样,但叶子节点的存储则有些不同,非主键列索引叶子节点上存储的是索引列及主键值,比如我们假设对 age 这个列建立了索引,那么它的索引树如下

 你管这破玩意叫 B+ 树?(二)-鸿蒙开发者社区
可以看到非叶子节点保存的是「age 值 + 页码」,而叶子节点保存的是 「age 值+主键值」,那么你可能就会疑惑了,如下 SQL 是怎么取出完整记录的呢

select * from user where age = xxx

第一步大家都知道,上述 SQL 可以命中 age 列对应的索引,然后找到叶子节点上对应的记录(如果有的话),但叶子节点上的记录只有 age 和 id 这两列,而你用的是 select *,意味着要查找 user 的所有列信息,该怎么办呢,答案是根据拿到的 id 再到聚簇索引找 id 对应的完整记录,这就是我们所说的回表,如果回表多的话显然会造成一定的性能问题,因为 id 可能分布在不同的页中,这意味着要将不同的页从磁盘读入内存,这些页很可能不是相邻的,也就意味着会造成大量的随机 IO,会严重地影响性能,看到这相信大家不难明白一道高频面试题:为什么设置了命中了索引但还是造成了全表扫描,其中一个原因就是虽然命中了索引但在叶子节点查询到记录后还要大量的回表,导致优化器认为这种情况还不如全表扫描会更快些

 

有人可能会问,为啥都二级索引不存储完整的记录呢,当然是为了节省空间,毕竟完整的数据是很耗空间的,如果每加一个索引都要额外存储完整的记录,那会造成很多数据冗余。

 

怎么避免这种情况呢?索引覆盖,如果如下 SQL 满足你的需求,那么就建议采用如下形式

select age from user where age = xxx
select age,id from user where age = xxx

不难发现这种 SQL 的特点是要获取的列(age)就是索引列本身(包括 id),这样在根据 age 的索引查到叶子节上对应的记录后,由于记录本身就包含了这些列,就不需要回表了,能提升性能

 

磁盘预读

 

接下来我们讨论一个网上很多人搞不拎清的一个问题,我们知道操作系统是以页为单位来管理内存的,在 Linux 中,一页的大小默认为 4 KB,也就是说无论是从磁盘载入数据到内存还是将内存写回磁盘,操作系统都会以页为单位进行操作,哪怕你只对一个空文件只写入了一个字节,操作系统也会为其分配一个页的大小( 4 KB)

 你管这破玩意叫 B+ 树?(二)-鸿蒙开发者社区
如图示,向磁盘写入了两个 byte ,但操作系统依然为其分配了一个页(4 KB)的大小

 

innoDB 也是以页为单位来存储与读取的,而 innoDB 页的默认大小为 16 KB,那么网上很多人的疑问是这是否意味着它需要执行 4 次 IO 才能把 innoDB 的页读完呢?不是的,只需要一次 IO,为什么?这需要理解一点磁盘读取数据的工作原理

 

磁盘的构造


首先我们来看看磁盘的物理结构

 你管这破玩意叫 B+ 树?(二)-鸿蒙开发者社区
硬盘内部主要部件为磁盘盘片、传动磁臂、读写磁头和转轴,数据主要写入磁盘的盘片上的,盘片又是由若干个扇区构成的,数据写入读取都是以扇区为基本单位的,另外以盘片中心为圆心,把盘片分成若干个同心圆,那每一个划分圆的“线条”,就称为磁道

 

那么数据是如何读取与写入的呢,主要有三步

  1. 寻道:既然数据是保存在扇区上的,那我首先我们需要知道它到底是在哪个扇区上吧,这就需要先让磁头移动到扇区所在的磁道上,我们把它称为寻道时间,平均寻道时间一般在3-15ms
  2. 旋转延迟: 磁盘移动到扇区所在的磁盘上时,此时的磁头对准的还不一定我们想要的数据对应的扇区,所以需要等待盘片旋转片刻,等到我们想要的数据对应的扇区落到磁头下,旋转延迟取决于磁盘转速,通常用磁盘旋转一周所需时间的1/2表示。比如:7200rpm的磁盘平均旋转延迟大约为60*1000/7200/2 = 4.17ms,而转速为15000rpm的磁盘其平均旋转延迟为2ms
  3. 数据传输:经过前面的两步,磁头终于开始读写数据了,目前IDE/ATA能达到133MB/s,SATA II可达到300MB/s的接口数据传输率,数据传输时间通常远小于前两部分消耗时间。可忽略不计
    注意数据传输中的忽略不计是有前提的,即是需要读取连续相邻的扇区,也就是我们常说的顺序 IO,磁盘顺序 IO 的读写速度可以媲美甚至超越内存的随机 IO,所以这部分时间可以忽略不计,(大家熟知的 Kafka 之所以性能强悍,有一个重要原因就是利用了磁盘的顺序读写),但如果要读取的数据是分布在不同的扇区的话,也就变成了随机 IO,随机 IO 毫无疑问增大了寻道时间和旋转延迟,性能是非常堪忧的(典型代表就是上文提到的 回表时大量 id 分布在不同的页上,造成了大量的随机 IO)

 你管这破玩意叫 B+ 树?(二)-鸿蒙开发者社区
如图示:图片来自著名学术期刊 ACM Queue 上的性能对比图,可以看到磁盘顺序 IO(Sequential Disk)的速度比内存随机读写(Random memory)还快

 

那读取 innoDB 中的一个页为啥算一次 IO 呢,相信你已经猜到了,因为这一个页是连续分配的,也即意味着它们的扇区是相邻的,所以它是顺序 IO

 

操作系统是以页为单位来管理内存的,它可以一次加载整数倍的页,而 innoDB 的页大小为 16KB,刚好是操作系统页(4KB)的 4 倍,所以可以指定在读取的起始地址连续读取 4 个操作系统页,即 16 KB,这就是我们说的磁盘预读,至此相信大家不难明白为啥说读取一页其实只是一次 IO 而不是 4 次了

 

总结
看完本文相信大家能明白索引的由来了,此外对页以及磁盘预读对性能的提升应该也有不少了解,其实 MySQL 的页结构与我们推演的结构有些许出入,不过不影响整体的理解,如果大家有兴趣深入了解 MySQL 的页结构,强烈建议大家看看文末的<MySQL是怎样运行的>这本书,讲解得非常细致

巨人的肩膀

  1. 磁盘I/O那些事: https://tech.meituan.com/2017/05/19/about-desk-io.html
  2. 带你从头到尾捋一遍MySQL索引结构: https://blog.51cto.com/u_12302929/3290492
  3. MySQL 是怎样运行的:从根儿上理解 MySQL

 

文章转自公众号:码海

分类
标签
已于2022-5-26 18:38:42修改
收藏 1
回复
举报
回复