遍历槽 2 中的元素,现在问题来了,我们知道每条记录都构成了一个单链表,而每个槽指向的是此分组中的最大 id 值,该怎么从此槽的第一个元素开始遍历呢,很简单,从槽 1 开始遍历不就行了,因为它指向元素的下一个元素即为槽 2 的起始元素,遍历后发现槽 2 的 第一个元素即为我们找到的 id 为 9 的元素
可以看到通过这种方式在页内很快把我们的元素定位出来了,MySQL 规定每个槽中的元素在 1~8 条,所以只要定位了在哪个槽,剩下的比较就不是什么问题了。当然一个页装的记录终究是有限的,如果页满了,就要要开辟另外的页来装记录了,页与页之间通过链表连接起来,但注意看下图,为啥要用双向链表连接起来呢?别忘了最开头我们列出的 「order by id asc 」和「order by id desc 」这两个查询条件,也就是说记录需要同时支持正序与逆序查找,这就是为什么要使用双向链表的原因。
B 树的诞生
现在问题来了,如果有很多页,该怎么定位元素呢,如果元素刚好在前几个页还好,大不了遍历前几个页也很快,但如果要查 id = 100w 这样的元素一页页遍历的话就要遍历 1w 页(假设每页 100 条记录),那显然是不可接受的,如何改进呢?其实之前建的页内目录已经给了我们启发,既然在页内我们可以通过为记录建页目录的形式来先定位元素在哪个槽然后再找,那针对多页,能否先定位元素在哪个页呢,也就是说我们可以为页也建立一个目录,这个目录里的每一条记录都对应着页及页中的最小记录,当然这个目录也是以页的形式存在的。为了便于区分 ,我们把针对页生成的目录对应的页称为目录页,而之前存储完整记录的页称为数据页画外音:目录页与数据页一样,内部也是有槽的,上文为了方便展示,没有画出,目录页和数据除了记录数据不一样,其他结构都是一致的现在如果要查找 id = xxx 的记录就很简单了,只要先到目录页中定位它的起始页然后再依次查找即可,由于不管是目录页还是数据页里面都有槽,所以无论是定位目录页的页码还是定位数据页中的记录都是非常快的。当然了,随着页的增多,目录页存放的记录也越来越多,目录页也终归会满的,那就再建一个目录页吧,于是现在问题来了,怎么定位要找的 id 是在哪个目录页呢,再次制定针对目录页的目录页不就行了,如下:看到上面这个结构你想到了什么?没错,这就是一颗 B 树!到此相信你已经明白了 B 树的演进之路,也明白了它的原理,可以看到这颗 B 树有三层,我们把最顶层的目录页称为根节点,最下层的存储完整记录的页称为叶子节点。现在我们再来看一下如何查找 id = 55 的记录,首先会加载根节点,发现应该在页码 30 的页中去找,于是加载页 30,在页 30 中又发现应该在页 4 中查中,于是再次把页 4 加载进内存中,然后在页 4 中依次遍历查找,可以看到总共经历了 3 次 IO(B 树有几层就会有几次 IO),页读取之后会缓存在内存中,再读的话如果命中内存中的页就会直接从内存中获取。有人可能会问,如果 B 树层数很多,那岂不是可能会有很多次 IO,我们简单的算一下,假设数据页可以存储 100 条记录,目录页可以存储 1000 条记录(目录页由于只存储了主键,不存储完整的数据,所以可以存储更多的记录),那么
所以一般3~4 层的 B 树足以满足我们的要求,而且每次读取后会缓存在内存中(当然也会根据一定的算法被换出内存),所以整体来看 3~4 层 B 树足以满足我们需求
聚簇索引与非聚簇索引
相信你已经发现了,上文中我们举的 B 树的例子针对的是 id 也就是主键的索引,不难发现主键索引中的叶子结点存储了完整的 SQL 记录,我们把这种存储了完整记录的索引称为聚簇索引,只要你定义了主键,那么主键索引就是聚簇索引。那么如果是非主键的列创建的索引又是怎样的形式呢,非叶子节点的形式完全一样,但叶子节点的存储则有些不同,非主键列索引叶子节点上存储的是索引列及主键值,比如我们假设对 age 这个列建立了索引,那么它的索引树如下可以看到非叶子节点保存的是「age 值 页码」,而叶子节点保存的是 「age 值 主键值」,那么你可能就会疑惑了,如下 SQL 是怎么取出完整记录的呢select * fromuserwhere age = xxx 第一步大家都知道,上述 SQL 可以命中 age 列对应的索引,然后找到叶子节点上对应的记录(如果有的话),但叶子节点上的记录只有 age 和 id 这两列,而你用的是 select *,意味着要查找 user 的所有列信息,该怎么办呢。答案是根据拿到的 id 再到聚簇索引找 id 对应的完整记录,这就是我们所说的回表,如果回表多的话显然会造成一定的性能问题,因为 id 可能分布在不同的页中,这意味着要将不同的页从磁盘读入内存,这些页很可能不是相邻的,也就意味着会造成大量的随机 IO,会严重地影响性能。看到这相信大家不难明白一道高频面试题:为什么设置了命中了索引但还是造成了全表扫描,其中一个原因就是虽然命中了索引但在叶子节点查询到记录后还要大量的回表,导致优化器认为这种情况还不如全表扫描会更快些有人可能会问,为啥都二级索引不存储完整的记录呢,当然是为了节省空间,毕竟完整的数据是很耗空间的,如果每加一个索引都要额外存储完整的记录,那会造成很多数据冗余。怎么避免这种情况呢?索引覆盖,如果如下 SQL 满足你的需求,那么就建议采用如下形式select age fromuserwhere age = xxx select age,idfromuserwhere age = xxx 不难发现这种 SQL 的特点是要获取的列(age)就是索引列本身(包括 id),这样在根据 age 的索引查到叶子节上对应的记录后,由于记录本身就包含了这些列,就不需要回表了,能提升性能。磁盘预读接下来我们讨论一个网上很多人搞不拎清的一个问题,我们知道操作系统是以页为单位来管理内存的,在 Linux 中,一页的大小默认为 4 KB,也就是说无论是从磁盘载入数据到内存还是将内存写回磁盘,操作系统都会以页为单位进行操作,哪怕你只对一个空文件只写入了一个字节,操作系统也会为其分配一个页的大小( 4 KB)如图示,向磁盘写入了两个 byte ,但操作系统依然为其分配了一个页(4 KB)的大小innoDB 也是以页为单位来存储与读取的,而 innoDB 页的默认大小为 16 KB,那么网上很多人的疑问是这是否意味着它需要执行 4 次 IO 才能把 innoDB 的页读完呢?不是的,只需要一次 IO,为什么?这需要理解一点磁盘读取数据的工作原理