图文并茂说MySQL索引——入门进阶必备

MySQL 专栏收录该内容
9 篇文章 39 订阅

本篇是我阅读《MySQL是怎样运行的》一书的笔记,我简化了一些概念,删除了一些不容易新手入门理解的内容,在这里分享给大家

1.索引介绍

  你一定看到过这样的例子,索引是什么,你要查字典,不可能从第一页翻到最后一页去查找,你会先查找拼音或者偏旁部首,然后直接跳转到对应的页小范围的一条一条查找,这样会快得多。

  索引也是类似,建立索引就是按照某种规则建立一颗B+树,在B+树查询到符合要求的记录就类似于查询到拼音或者偏旁部首一样,最后回表就能很快的查找到完整具体的记录。在这里,我会带大家来看看记录的插入是如何变化的,记录原理是什么。

假设数据表index_demo是这样的:

CREATE TABLE index_demo(
    c1 INT,
    c2 INT,
    c3 CHAR(1),
    PRIMARY KEY(c1)
)charset=utf8mb4;

你插入的每条记录的行格式,我们将其简化为如下

  • record_type:记录头信息的一项属性,表示记录的类型,0表示普通记录,1表示目录项记录,2表示Infimum记录(最小记录的上一条记录),3表示Supremum记录(最大记录的下一条记录)。

  • next_record:记录头信息的一项属性,表示下一条地址相对于本条记录的地址偏移量,为了方便大家理解,我们都会用箭头来表明下一条记录是谁。

  • 各个列的值:这里只记录在index_demo表中的三个列,分别是c1c2c3

  • 其他信息:除了上述3种信息以外的所有信息,包括其他隐藏列的值以及记录的额外信息。

所以,你插入记录后,结构应该是这样的。


2.当你插入记录,页中有什么变化?

  我们假设每个数据页最多能存放3条记录(实际上一个数据页非常大,可以存放好多条记录)。

  向index_demo插入3条记录

INSERT INTO index_demo VALUES(1, 4, 'u'), (3, 9, 'd'), (5, 3, 'y');

变化如下:

可以看到,这些记录组成了单链表,并且按照主键值从小到大的顺序将记录排序。innodb要求插入记录必须这么做。

接着我们在插入一条记录

INSERT INTO index_demo VALUES(4, 4, 'a');

innodb还要求,下一个数据页用户记录的主键值大于上一个页中用户记录的主键值。

而页10最多存放3条数据记录,所以不得不再分配一个新页。

  细心的小伙伴看到此图主键值4的记录在5的后面,别急,下面马上讲到。

  这里分配的页号为什么是28而不是11呢?学过数据结构的人就知道,链表新增结点都是随机分配一块空间,在内存上并不是连续的,这里就假设为28,用来说明这个内存的不连续性。

  不管对页中的记录是增加、删除还是修改,我们必须遵守"下一个数据页用户记录的主键值大于上一个页中用户记录的主键值"这个规则。为了维持这个规则,往往伴随记录的移动,甚至需要为记录分配新页,这个过程也叫页分裂

假设此时你往表中插入了很多条记录,如下

之前说过,查字典不可能从头开始一个个往后查,记录也是一样,不可能一条条顺序往后找,那么innodb是怎么做的呢?

那就是给这些记录一个目录!

文章开头就提到了的record_type属性,它的各个取值代表的意思如下:

0:普通的用户记录
1:目录项记录
2:Infimum记录
3:Supremum记录

可以看到,目录项记录的record_type值是1,而普通用户记录的record_type值是0

  目录项记录只有主键值和页的编号两个列,而普通的用户记录的列是用户自己定义的,可能包含很多列,另外还有InnoDB自己添加的隐藏列。

innodb怎么查询呢?

以查找主键为8的记录为例。

  1. 先到存储目录项记录的页(页30)中通过二分法快速定位到用户记录页,因为5< 8< 12,所以定位到对应的记录所在的页就是页28

  2. 再到存储用户记录的页28中根据单链表遍历定位到主键值为8的用户记录。

目录项比较多一页放不下怎么办?那就和用户记录一样进行页分裂。

  我们假设一个目录页最多存放4条目录项记录(真实情况是可以存放很多条),如果此时再插入一条主键值为320的用户记录,那就需要再分配一个新的页去存放目录了。

从图中可以看出,我们插入了一条主键值为320的用户记录之后需要两个新的数据页:

  1. 为存储该用户记录而新生成了页31

  2. 因为原先存储目录项记录的页30的容量已满(我们前边假设只能存储4条目录项记录),所以不得不需要一个新的页32来存放页31对应的目录项。

假如还是查找主键为8的记录,此时innodb该如何查询?

  1. 确定目录项在哪个页。页30的目录项记录的主键值的范围是[1, 320),页32表示的目录项记录的主键值范围[320, +∞),显然主键值为8的记录在页30中。
  2. 通过目录页确定用户记录页。因为5< 8< 12,所以用户记录在页28中。
  3. 在真正用户记录页中定位到具体记录。这个就是单链表遍历即可。

  如果表中的数据确实是很多,会产生很多存储目录项的页,那我们怎么根据主键值快速定位一个存储目录项记录的页呢?

  那就是为这些存储目录的页再生成一个更高级的目录页,就像是一个多级目录一样。

  我们生成了一个存储更高级目录项的页33,这个页中的两条记录分别代表页30和页32,如果用户记录的主键值在[1, 320)之间,则到页30中查找更详细的目录项记录,如果主键值范围是[320, +∞),就到页32中查找更详细的目录项记录。随着表中记录的增加,这个目录的层级会继续增加。

有人做过这样一个计算,假如叶子结点代表的数据页可以存放100条用户记录,所有目录页可以存放1000条目录项记录,那么:
如果B+树只有1层,也就是只有1个用于存放用户记录的节点,最多能存放100条记录。
如果B+树有2层,最多能存放1000×100=100000条记录。
如果B+树有3层,最多能存放1000×1000×100=100000000条记录。
如果B+树有4层,最多能存放1000×1000×1000×100=100000000000条记录。(一千亿条记录)
一般都不会超过这个数字,也就是说B+树一般不超过4层,可以通过二分法快速定位记录,然后小范围遍历即可。


3.聚集索引

  刚刚上面几节所画的B+树全部都是按照聚集索引(主键值)排序的,可以说B+树本身就是一个索引。聚集索引有以下2个特点

1.使用主键值大小进行记录和页的排序,都是按照主键值升序排列,记录之间是单链表结构,页与页之间是双链表结构。

2.B+树的叶子结点是完整的用户记录,也就是叶子结点存储了所有列的值(包括隐藏列)。

具有这2个特点的B+树称为聚集索引,聚集索引不需要显式使用INDEX语句创建,如下

CREATE INDEX 索引名称 ON 表名(字段);
ALTER TABLE 表名 ADD INDEX 索引名称(字段); 

innodb自动为我们创建聚集索引。


4.非聚集索引(二级索引)

  你肯定遇到过这样的情况,每次查询的时候,where条件并不是按照主键id去筛选条件,比如查询test表中的session_id是"abc"的记录

select * from test where session_id = "abc";

  很显然,除了主键使用默认聚集索引以外,你并没有手动添加其他的索引。那么根据条件去筛选的时候就只能沿着链表依次遍历了。有没有更好的方法呢?

  当然有!我们可以多建几颗B+树啊,不同的B+树可以采用不同的排序规则。比如刚刚的index_demo 表中的c2列的值的大小作为记录和页的排序规则,再建这样一个规则的B+树。

看看这个图,是不是和聚集索引很像?但是还是有点不同。

  1. B+树的叶子结点存储的不是完整的用户记录,没有全部列的信息,只有c2列和主键列这两个列的值。
  2. 目录项记录不再是主键+页号,而是c2列+页号。
  3. 使用记录c2列的大小进行记录和页的排序。不管是用户记录页还是目录页,都是按照c2列的大小升序排列的单链表结构。

创建了非聚集索引后,现在想要查询where c2 = 4的记录的步骤如下:

  1. 确定第一条符合c2 = 4的目录项所在的页。
  2. 确定第一条符合c2 = 4的用户记录所在的页。
  3. 找到符合c2 = 4的第一条用户记录。找到该记录后,由于只存储了c2列和主键c1,所以需要根据该记录的主键信息到聚集索引中查找完整的用户记录(回表)。然后返回非聚集索引的叶子节点处,找到刚才定位到的符合条件的记录,继续往后扫描,因为c2列没有唯一键unique约束,可能满足c2 = 4的有多条记录,搜索完本页就跳到下一页第一条记录继续往后扫描,每找到一条满足的记录就进行一次回表操作,重复这个过程,直到下一条记录不满足c2 = 4为止。

  要知道,每次根据主键回表时,虽然是按照非聚集索引排好序的,但是这些记录的主键id是无序的,也就是说,这些非聚集索引记录对应的聚集索引记录所在的页面也是无序的。每次回表都要重新定位页的位置,将聚集索引页读取出来,这些非连续I/O的性能开销很大。

  因为大多数的数据都是按照主键递增顺序插入得到的,所以我们可以认为,如果按照主键 因的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能。

  于是,便有了MRR(Disk-Sweep Multi-Range Read,多范围读取)的优化措施。即先读取一部分满足条件的非聚集索引记录,将它们的主键值排好序之后再统一回表,相比于每读取一条非聚集索引记录就回表,这样会节省一些I/O开销(比如记录更有可能在同一页)。

  MRR优化的设计思路如下:

  1. 根据非聚集索引a,定位到满足条件的记录,将id值放入read_rnd_buffer中;
  2. read_rnd_buffer中的id进行递增排序;
  3. 排序后的id数组,依次到主键id索引中查记录,并作为结果返回。

  这里read_rnd_buffer的大小是由read_rnd_buffer_size参数控制的(运行show variables like 'read_rnd_buffer_size'查看大小,默认256K)。如果步骤1 中,read_rnd_buffer放满了,就会先执行完步骤23,然后清空read_rnd_buffer。之后继续找索引a的下个记录,并继续循环。

  按照官方文档的说法,判断消耗的时候,会更倾向于不使用MRR,把mrr_cost_based设置为'off'(默认是'on'),就是固定使用MRR了。经过我的测试,如果按照非主键id排序,则无论如何不会使用MRR。所以我们在之后的讨论将会忽略这个概念,直接认为每读取一条非聚集索引记录就立即回表操作。

  后面文章会详细举例子MRR的分析,请继续关注。


5.联合索引

  我们也可以同时以多个列的大小作为排序规则,也就是同时为多个列建立索引,比如说我们想让B+树按照c2c3列的大小进行排序,那么

  1. 先把各个记录和页按照c2列进行排序。
  2. 在记录的c2列相同的情况下,采用c3列进行排序

c2c3列建立的索引,如下:

  每条目录项记录都由c2c3、页号这三个部分组成,各条记录先按照c2列的值进行排序,如果记录的c2列相同,则按照c3列的值进行排序。可以看图中的页50和页55,当c2 = 4的时候,c3按照从小到大的顺序一次是"a""o""u"

  每条用户记录c2c3和主键c1列组成。


6.索引覆盖

  explain查询sql执行计划时,Extra显示Using index时,能够触发索引覆盖。索引覆盖无需回表,需要查询的字段已经都在该B+树上了。索引已经“覆盖了”我们的查询需求,所以称为覆盖索引。


7.非聚集索引一定会查询多次吗?如何避免回表?

  比如有一个联合索引idx_c2_c3(c2, c3)select c3 from 表名 where c2 = 4;就只需要查询一次辅助索引就可以了,因为我需要查询的值正好是索引之一,一棵索引树上就能获取SQL所需的列数据(索引覆盖),无需回表,速度更快。

注意:当你建立c2, c3联合索引之后却看到Extra显示Using index condition; Using filesort,一般是使用了where c2 = "xx" order by c3 desc这样的形式,有人认为Using filesort就是因为c3需要order by才导致外部排序,实际上是你联合索引顺序建反了,index(c2, c3)错误的写成了index(c3, c2),导致B+树优先按照c3排序,c3相同时,才按照c2排序,这样和写的sql意义不符合,具体可以看上面联合索引的图片理解


8.B+树的应用场景

  由于篇幅原因,我这里另开一篇,请见下一篇:MySQL中B+树索引的应用场景大全



欢迎一键三连~

有问题请留言,大家一起探讨学习

----------------------Talk is cheap, show me the code-----------------------
相关推荐
©️2020 CSDN 皮肤主题: 数字20 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值