插入后表中的数据如下:不知你有没发现我们在插入的时候并没有指定 id 值,但 InnoDB 为每条记录默认添加了一个 id 值,而且这个 id 值是递增的,每插入一条记录,id 递增 1,id 为什么要递增呢,主要是为了查询方便,每条记录按 id 由小到大的顺序用链表连接起来,这样每次查找 id = xxx 的值就从 id = 1 开始依次往后查找即可现在假设我们要执行以下 SQL 语句,MySQL 会怎么查询呢
select * fromuserwhereid = 3
页
如前所述,首先从 id 最小的记录也就是 id = 1 读起,每次读一条记录,将其 id 值与要查询的值比较,连续读三次记录于是找到了记录 3,注意这个读的操作,是首先需要把存储在磁盘的记录读取到内存然后再比较 id 的,从磁盘读到内存算一次 IO,也就是说此过程中产生了三次 IO。如果只是几条记录还好,但如果要比较的条数多的话对性能是非常严重的挑战,如果我要查询为 id = 100 的记录那岂不是要产生 100 次 IO?既然瓶颈在 IO,那该怎么改进呢?很简单,我们现在的设计一次 IO 只能读一条记录,那改为一次 IO 能读取 100 条甚至更多不就只产生一次 IO 了吗?这背后的思想就是程序局部性原理:当用到了某项数据时,很可能会用到与之相邻的数据,所以干脆把相依的数据一起加载进去(你从 id = 1 开始读,那很可能用到 id = 1 紧随其后的元素,于是干脆把 id = 1 ~ id = 100 的记录都加载进去)当然一次 IO 的读取记录也并不是多多益善,总不能为了一条查询记录而把很多无关的数据都加载到内存吧,那会造成资源的极大浪费,于是我们采用了一个比较折中的方案。我们规定一次 IO 读取 16 K 的数据,假设为 100 条数据好了,这样如果我们要查询 id = 100 的记录,只产生了一次 IO 读(id=1~id=100 的记录),比起原来的 100 次 IO 提升了 100 倍的性能我们把这 16KB 的记录组合称为一个页
页目录
一次 IO 会读取一个页,然后再在内存里查找页里的记录,在内存里查找确实比磁盘快多了,但我们仍不满意,因为如果要查找 id = 100 的记录,要先从 id = 1 的记录比较起,然后是id=2,…,id=100,需要比较 100 次,能否更快一点?可以参照二分查找,先查找 id = (1+100)/2 = 50,由于 50 < 100,接着在 50~100 的记录中查,然后再在 75~100 中查,这样经过 7 次就可找到 id = 100 次的记录,比起原来的 100 次比较又提升了不少性能。但现在问题来了,第一次要找到 id = 50 的记录又得从 id = 1 开始遍历 50 次才能找到,能否一下就定位到 id=50 的记录呢,如果不能,哪怕第一次从 id = 30 或 40 开始查找也行啊有什么数据结构能满足这种需求呢,还记得跳表不,每隔 n 个元素抽出一个组成一级索引,每隔 2*n 个元素组成二级索引。。。如图示,以建立一级索引为例,我们在查找的时候先在一级索引查找,在一级索引里定位到了再到链表里查找,比如我们要找 7 这个数字,如果不用跳表直接在链表里查,需要比较 7 次。而如果用了跳表我们先在一级索引查找,发现只要比较 3 次,减少了四次,所以我们可以利用跳表的思想来减少查询次数,具体操作如下,每 4 个元素为一组组成一个槽(slot),槽只记录本组元素最大的那条记录以及记录本组有几条记录现在假设我们想要定位 id = 9 的那条记录,该怎么做呢,很简单:首先定位记录在哪个槽,然后遍历此槽中的元素
定位在哪个槽,首先取最小槽和最大槽对应的 id(分别为 4, 12),先通过二分查找取它们的中间值为 (4+12)/2 = 8,8 小于 9,且槽 2 的最大 id 为 12,所以可知 id = 9 的记录在槽 2 里
遍历槽 2 中的元素,现在问题来了,我们知道每条记录都构成了一个单链表,而每个槽指向的是此分组中的最大 id 值,该怎么从此槽的第一个元素开始遍历呢,很简单,从槽 1 开始遍历不就行了,因为它指向元素的下一个元素即为槽 2 的起始元素,遍历后发现槽 2 的 第一个元素即为我们找到的 id 为 9 的元素
可以看到通过这种方式在页内很快把我们的元素定位出来了,MySQL 规定每个槽中的元素在 1~8 条,所以只要定位了在哪个槽,剩下的比较就不是什么问题了。当然一个页装的记录终究是有限的,如果页满了,就要要开辟另外的页来装记录了,页与页之间通过链表连接起来,但注意看下图,为啥要用双向链表连接起来呢?别忘了最开头我们列出的 「order by id asc 」和「order by id desc 」这两个查询条件,也就是说记录需要同时支持正序与逆序查找,这就是为什么要使用双向链表的原因。
相信你已经发现了,上文中我们举的 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