MySQL ---- 索引类型 & 使用规则 & 回表覆盖索引 & 设计索引考虑因素
Posted whc__
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了MySQL ---- 索引类型 & 使用规则 & 回表覆盖索引 & 设计索引考虑因素相关的知识,希望对你有一定的参考价值。
1、前置知识
mysql把数据存储和查询操作抽象成了存储引擎,不同的存储引擎,对数据的存储和读取方式都各不相同。
1.1 磁盘数据页存储结构
虽然数据保存在磁盘中,但其处理都是在内存中进行的。为了减少磁盘随机读取次数,InnoDB采用页
而不是行的粒度来保存数据,即数据被分成若干页,以页为单位保存在磁盘中。InnoDB的页大小,一般是16KB
。
数据页结构图
各个数据页组成一个双向链表,每个数据页中的记录按照主键顺序组成单向链表
。每一个数据页中有一个页目录,方便按照主键查询记录。
多个数据页结构图
在一个数据页
中,InnoDB 的设计者对每个分组中的记录条数是有规定的:对于最小记录所在的分组只能有 1 条记录,
最大记录所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间。
-
一个数据页中查找指定主键值的记录的过程分为两步:
-
通过二分法确定该记录所在的槽,并找到该槽中主键值最小的那条记录
槽:每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页的尾部的地方,这个地方就是所谓的页目录(Page Directory),页目录中的这些地址偏移量被称为槽,所以页面目录是由槽组成的。 槽对应的是该组中最大的记录,如何定位最小记录? 因为槽是紧挨着的,所以可以往上找到比当前小的槽。
-
通过记录的
next_record
属性遍历该槽所在的组中的各个记录
-
1.2 页分裂
当不停的往表里插入数据,刚开始是不停的在一个数据页插入数据,接着数据越来越多,而索引运行的一个核心基础要求后一个数据页的主键值大于前面一个数据页的主键值,如果你的主键是自增的,这点可以保证,因为新插入后一个数据页的主键值一定都大于前一个数据页的主键值。
但如果主键不是自增长的,可能出现后一个数据页的主键值有的主键是小于前一个数据页的主键值的。这样肯定会出现问题,所以页分裂
就是解决这个问题的。
原理是:如果主键值是自己设置的,在增加一个新的数据页的时,实际上会把前一个数据页里主键值大的挪动到新的数据页里,然后把新插入的主键值较小的数据挪动到上一个数据页里去,保证新数据页里的主键值一定都比上一个数据页里的主键值大。
2、索引
2.1 设计
在没有索引的时候,如果要搜索某个主键值对应的数据,如果磁盘中有很多条数据,对应着多个数据页,没有人告诉它在哪个数据页,那么它只能去进行一个全表扫描,从第一个数据页开始,将数据页加载到Buffer Pool中,然后数据页进入到页目录找到对应数据的槽,最坏的情况下,磁盘中的所有数据页都要进行扫描一遍,效率极低。
所以需要一种设计,将数据页组织起来,把每个数据页的页号
,还有数据页里最小的主键值
放在一起,组成一个索引的目录,这就是主键索引。
2.2 InnoDB索引方案(B+树索引)
InnoDB中的聚簇索引是索引即数据,数据即索引。
上面的设计是一种简单的方案,如果表里的数据很多,比如几百万,几千万条数据,大量的数据页,就需要主键目录中存储大量的数据页号和最小主键值,这显然不太行。
所以需要一种可以灵活管理所有目录项的方式。复用之前存储用户记录的数据页来存储目录项,为了和用户记录做区分,把这些用来表示目录项的二级路称为 目录项记录
。如图所示:
能够存储的数据量?
假设一个存储用户记录的叶子节点代表的数据页可以存放100条用户记录,所有存放目录项记录的内节点代表的数据页可以存放1000条目录项记录,那么:
- 如果B+树只有1层,最多能存放100条记录
- 如果B+树有2层,最多能存放1000 * 100 = 10W条记录
- 如果B+树有3层,最多能存放1000 * 1000 * 100 = 1000W条记录
- 如果B+树有4层,最多能存放1000 * 1000 * 1000 * 100 = 1000亿条记录
一般情况下,用到的B+树都不会超过4层,主键值去查找某条记录最多只需要做4个页内的查找(查找3个目录项和1个用户记录页
)
2.2.1 聚簇索引
索引页 + 数据页
组成的 B+树就是聚簇索引
上面所介绍的B+树本身就是一个目录,或者说本身是一个索引。有两个特点:
-
使用记录主键值的大小进行记录和页的排序:
- 页内的记录是按照主键的大小顺序排成一个单向链表
- 各个存放用户记录的页也是根据页中用户记录的主键大小顺序排成一个双向链表
- 存放目录项记录的页分为不同的层次,在同一层次中的页也是根据页中目录项记录的主键大小顺序排成一个双向链表
-
B+树的叶子节点存储的是完整的用户记录
(完整的用户记录,指这个记录中存储了所有列的值,包括隐藏列)
所以具有这两种特性的B+树称为聚簇索引
,所有完整的用户记录都存放在这个聚簇索引
的叶子节点处。
InnoDB存储引擎会自动为我们创建聚簇索引,聚簇索引就是数据的存储方式(所有的用户记录都存储在了叶子节点),也就是所谓的索引即数据,数据即索引。
2.2.2 二级索引
主键外的其它字段建立的索引
聚簇索引
只能在搜索条件是主键值时才能发挥作用,因为B+树中的数据都是按照主键进行排序的。如果想要以别的列作为搜索条件呢?这时就要新建一棵B+树,如图所示
这个B+树与聚簇索引B+树的区别:
- 使用记录非主键列(索引)的大小进行记录和页的排序:
- 页内记录是按照非主键列(索引)的大小顺序排成一个单向链表
- 各个存放用户记录的页也是根据页中记录的非主键列(索引)的大小顺序排成一个双向链表
- 存放目录项记录的页分为不同层次的页,在同一层次中的页也是根据页中目录项记录的非主键列(索引)顺序排成一个双向链表
- B+树的叶子节点存储的并不是完整的用户记录,而是
非主键列(索引) + 主键
这两个列的值 - 目录项记录中不再是
主键 + 页号
的搭配,而变成了非主键列 + 页号
搭配。
由于B+树的叶子节点中的记录只存储了非主键列 和 主键列 ,所以如果要得到完整的用户记录的话,需要根据主键值去聚簇索引中再查找一遍完整的用户记录,这个操作叫做回表
。
这种按照非主键列
建立的B+树需要一次回表
操作才可以定位到完整的用户记录,这种B+树也被称为二级索引
,或者辅助索引
。
2.2.3 联合索引
以多个列的大小作为排序规则,同时为多个列建立索引,比如让B+树按照c2和c3列的大小进行排序,包含了两层含义:
- 先把各个记录和页按照c2列进行排序
- 在记录的c2列相同的情况下,采用c3列进行排序
以c2和c3列的大小为排序规则建立的B+树称为联合索引,本质上也是一个二级索引。它的意思与分别为c2列和c3列分别建立的索引的表述是不同的
不同点如下:
- 建立
联合索引
只会建立一棵B+树 - 为c2列和c3列分别建立索引会分别以c2和c3列的大小为排序规则建立2棵B+树
2.3 MyISAM索引方案
MyISAM的索引方案虽然也使用了树形结构,但是却将索引和数据分开存储。
所以MyISAM是索引是索引,数据是数据。
-
将表中的记录按照记录的插入顺序单独存储在一个文件中,称之为
数据文件
。这个文件并不划分为若干个数据页,有多少记录就往这个文件中添加多少记录就可以了。我们可以通过行号而快速访问到一条记录
-
使用MyISAM存储引擎的表会把索引信息另外存储到一个称为
索引文件
的另一个文件中。MyISAM会单独为表的主键创建一个索引,索引的叶子节点存储的不是完整的用户记录,而是主键值 + 行号
的组合。(先通过索引找到对应的行号,再通过行号去找对应的记录)
MyISAM中建立的索引相当于全部都是二级索引
-
同理,如果为其它列创建索引或者联合索引,在叶子节点上存储的是
相应的列 + 行号
。这些索引也全部都是二级索引
3、索引使用规则
以联合索引为例,是因为在设计系统中一般都是设计联合索引,很少用单个字段做索引,我们要尽可能让索引数量少一些,避免磁盘占用太多,增删改性能太差。
表信息:
CREATE TABLE person_info(
id INT NOT NULL auto_increment,
name VARCHAR(100) NOT NULL,
birthday DATE NOT NULL,
phone_number CHAR(11) NOT NULL,
country varchar(100) NOT NULL,
PRIMARY KEY (id),
KEY idx_name_birthday_phone_number (name, birthday, phone_number)
);
联合索引B+树:
- 先按照name排序
- 如果name相同,则按照birthday列的值排序
- 如果birthday相同,则按照phone_number的值进行排序
只要页面和记录是排好序的,我们就可以通过二分法来快速定位查找
3.1 全值匹配
(等值匹配)
搜索条件中的列和索引列一致的话,这种情况称为全值匹配,比如这条语句:
SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday = '1990-09-27' AND phone_number = '15123983239';
查找过程:
- 因为 B+ 树的数据页和记录先是按照 name 列的值进行排序的,所以先可以很快定位 name 列的值是 Ashburn
的记录位置。 - 在 name 列相同的记录里又是按照 birthday 列的值进行排序的,所以在 name 列的值是 Ashburn 的记录里又可以快速定位 birthday 列的值是 ‘1990-09-27’ 的记录。
- 如果很不幸, name 和 birthday 列的值都是相同的,那记录是按照 phone_number 列的值排序的,所以联合索引中的三个列都可能被用到。
WHERE子句中的几个搜索条件的顺序对查询结果有影响吗?
答: 没影响。MySQL的查询优化器会分析这些搜索条件并且按照可以使用的索引中的列顺序来决定先使用哪个搜索条件,后使用哪个搜索条件
3.2 匹配左边的列
(最左侧列匹配)
意思就是假设我们联合索引是KEY(name, birthday, phone_number),那么不一定必须要在where语句里根据三个字段来查,其实只要根据最左侧的部分字段来查,也是可以的。
比如下面语句都是可以的:
SELECT * FROM person_info WHERE name = 'Ashburn';
SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday = '1990-09-27';
如果写成:
SELECT * FROM person_info WHERE birthday = '1990-09-27';
SELECT * FROM person_info WHERE birthday = '1990-09-27' and phone_number = '15123983239';
这种就不行了,因为联合索引的B+树里,是必须先按name查,再按birthday,不能跳过前面一个字段,直接按第二个字段。
因为B+树的数据页和记录先是按照name列的值排序的,在name值相同的情况下才使用birthday列进行排序,在name列的值不同的记录中birthday的值可能是无序的。
注意:如果我们想使用联合索引中尽可能多的列,搜索条件中的各个列必须是联合索引中
从最左边连续的列
3.3 匹配列前缀
最左前缀匹配原则
比如想查询名字以 ‘As’ 开头的记录,那就可以这么写查询语句:
SELECT * FROM person_info WHERE name LIKE 'As%';
但是如果只出现后缀或者中间的某个字符串,比如:
SELECT * FROM person_info WHERE name LIKE '%As%';
MySQL 就无法快速定位记录位置了,因为字符串中间有 ‘As’ 的字符串并没有排好序,所以只能全表扫描了。
3.4 匹配范围值
范围查找规则
B+ 树示意图,所有记录都是按照索引列的值从小到大的顺序排好序的,所以这极大的方便我们查找索引列的值在某个范围内的记录。比方说下边这个查询语句:
SELECT * FROM person_info WHERE name > 'Asa' AND name < 'Barlow';
由于 B+ 树中的数据页和记录是先按 name 列排序的,所以我们上边的查询过程其实是这样的:
- 找到 name 值为 Asa 的记录。
- 找到 name 值为 Barlow 的记录。
- 由于所有记录都是由链表连起来的(记录之间用单链表,数据页之间用双链表),所以他们之间的记录都可以很容易的取出来
- 找到这些记录的主键值,再到聚簇索引中
回表
查找完整的记录。
注意:如果对多个列同时进行范围查找的话,只有对索引最左边的那个列进行范围查找的时候才能用到 B+ 树索引,比如下面:
SELECT * FROM person_info WHERE name > 'Asa' AND name < 'Barlow' AND birthday > '1980-01-01';
上边这个查询可以分成两个部分:
- 通过条件 name > ‘Asa’ AND name < ‘Barlow’ 来对 name 进行范围,查找的结果可能有多条 name 值不同的记录,
- 对这些 name 值不同的记录继续通过 birthday > ‘1980-01-01’ 条件继续过滤。
这样子对于联合索引 idx_name_birthday_phone_number 来说,只能用到 name 列的部分,而用不到 birthday 列
的部分,因为只有 name 值相同的情况下才能用 birthday 列的值进行排序,而这个查询中通过 name 进行范围查
找的记录中可能并不是按照 birthday 列进行排序的,所以在搜索条件中继续以 birthday 列进行查找时是用不到
这个 B+ 树索引的。
3.5 等值匹配 + 范围匹配
对于同一个联合索引来说,虽然对多个列都进行范围查找时只能用到最左边那个索引列,但是如果左边的列是精确查找,则右边的列可以进行范围查找,比方说这样:
SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday > '1980-01-01' AND birthday< '2000-12-31' AND phone_number > '15100000000';
- 由于 name 列是精确查找,所以通过 name =
‘Ashburn’ 条件查找后得到的结果的 name 值都是相同的,它们会再按照 birthday 的值进行排序。所以此时
对 birthday 列进行范围查找是可以用到 B+ 树索引的。 - phone_number > ‘15100000000’ ,通过 birthday 的范围查找的记录的 birthday 的值可能不同,所以这个
条件无法再利用 B+ 树索引了,只能遍历上一步查询得到的记录。
3.6 用于排序
我们在写查询语句的时候经常需要对查询出来的记录通过 ORDER BY 子句按照某种规则进行排序。一般情况下,
我们只能把记录都加载到内存中,再用一些排序算法,比如快速排序、归并排序等在内存中对这些记录进行排序,有的时候可能查询的结果集太大以至于不能在内存中进行排序的话,还可能暂时借助磁盘的空间来存放中间结果,排序操作完成后再把排好序的结果集返回到客户端。
在 MySQL 中,把这种在内存中或者磁盘上进行排序的方式统称为文件排序
(英文名: filesort
),速度很慢。
但是如果 ORDER BY 子句里使用到了我们的索引列,就有可能省去在内存或文件中排序的步骤,比如下边这个简单的查询语句:
SELECT * FROM person_info ORDER BY name, birthday, phone_number LIMIT 10;
这个时候默认情况下在索引树里本身就是依次按照name, birthday, phone_number三个字段的值去排序的,不再需要用到文件排序了,直接按照索引树的顺序,按照从小到大的值获取前面10条数据就可以了。然后拿到10条数据的主键再去聚簇索引里回表
查询剩余所有的字段。
注意事项:
-
ORDER BY 的子句后边的列的顺序也必须按照索引列的顺序给出,如果给出
ORDER BY phone_number, birthday, name 的顺序,那也是用不了 B+ 树索引,这种颠倒顺序就不能使用索引的 -
ORDER BY name 、 ORDER BY name, birthday 这种匹配索引左边的列的形式可以使用部分的 B+ 树索引。
当联合索引左边列的值为常量,也可以使用后边的列进行排序 -
对于使用联合索引进行排序的场景,我们要求各个排序列的排序顺序是一致的,也就是要么各个列都是 ASC 规则
排序,要么都是 DESC 规则排序。ORDER BY子句后的列如果不加ASC或者DESC默认是按照ASC排序规则排序的,也就是升序排序的。
如果我们查询的需求是先按照 name 列进行升序排列,再按照 birthday 列进行降序排列的话,比如说下面的查询语句,是不能高效使用索引的,还不如直接文件排序来的快。
SELECT * FROM person_info ORDER BY name, birthday DESC LIMIT 10;
3.7 用于分组
为了方便统计表中的一些信息,会把表中的记录按照某些列进行分组。比如下边这个分组查询:
SELECT name, birthday, phone_number, COUNT(*) FROM person_info GROUP BY name, birthday, phone_number
4、回表、覆盖索引
4.1 回表
比如下面的语句的执行步骤:
SELECT * FROM person_info WHERE name > 'Asa' AND name < 'Barlow';
- 从索引 idx_name_birthday_phone_number 对应的 B+ 树中取出 name 值在 Asa ~ Barlow 之间的用户记录。
- 由于索引 idx_name_birthday_phone_number 对应的 B+ 树用户记录中只包含 name 、 birthday 、
phone_number 、 id 这4个字段,而查询列表是 * ,意味着要查询表中所有字段,也就是还要包括 country
字段。这时需要把从上一步中获取到的每一条记录的 id 字段都到聚簇索引对应的 B+ 树中找到完整的用户记
录,也就是我们通常所说的回表
,然后把完整的用户记录返回给查询用户。
4.2 回表带来的性能问题
-
会使用到两个 B+ 树索引,一个
二级索引
,一个聚簇索引
。 -
访问二级索引使用
顺序I/O
,访问聚簇索引使用随机I/O
顺序I/O:值在 Asa ~ Barlow 之间的记录在磁盘中的存储是相连的,集中分布在一个或几个数据页中,可以很快的把这些连着的记录从磁盘中读出来,这种方式称为顺序I/O 随机I/O: 根据获取到的记录的 id 字段的值可能并不相连,而在聚簇索引中记录是根据 id (也就是主键)的顺序排列的,所以根据这些并不连续的 id值到聚簇索引中访问完整的用户记录可能分布在不同的数据页中,这样读取完整的用户记录可能要访问更多的数 据页,这种读取方式我们也可以称为随机I/O 。
需要回表的记录越多,使用二级索引的性能就越低
- 需要回表的记录特别多,还不如直接遍历聚簇索引低,优化器会倾向于使用
全表扫描
的方式执行查询。 - 需要回表的记录特别少,优化器就会倾向于使用
二级索引 + 回表
的方式执行查询。
4.3 覆盖索引
覆盖索引不是一种索引,是一种基于索引查询的方式。
查询列表里只包含索引列,比如
SELECT name, birthday, phone_number FROM person_info WHERE name > 'Asa' AND name < 'Barlow'
因为我们只查询 name , birthday , phone_number 这三个索引列的值,所以在通过
idx_name_birthday_phone_number 索引得到结果后就不必到 聚簇索引 中再查找记录的剩余列,也就是
country 列的值了,这样就省去了回表 操作带来的性能损耗。我们把这种只需要用到索引的查询方式称为 覆盖索引
。
排序操作也优先使用 覆盖索引 的方式进行查询,比方说这个查询:
SELECT name, birthday, phone_number FROM person_info ORDER BY name, birthday, phone_number;
查询优化器就会直接使用
idx_name_birthday_phone_number 索引进行排序而不需要回表操作了
当然,如果业务需要查询出索引以外的列,那还是以保证业务需求为重,如果真的要回表到聚簇索引,也尽可能使用limit、where
之类的语句限定一下回表到聚簇索引的次数,能够从联合索引里筛选少数数据,然后再回表到聚簇索引里去,性能会好一些。
但是我们很不鼓励用 * 号作为查询列
表,最好把我们需要查询的列依次标明。
5、设计索引考虑的因素
5.1 只为用于搜索、排序或分组的列创建索引
第一个索引设计原则,针对SQL语句里的where条件、order by条件以及group by条件去设计索引。而出现在查询列表中的列就没必要建立索引了。
SELECT birthday, country FROM person name WHERE name = 'Ashburn';
像查询列表中的 birthday 、 country 这两个列就不需要建立索引,我们只需要为出现在 WHERE 子句中的 name
列创建索引就可以了。
5.2 考虑列的基数
列的基数
指的是某一列中不重复数据的个数,比方说某个列包含值 2, 5, 8, 2, 5, 8, 2, 5, 8 ,虽然有 9 条
记录,但该列的基数却是 3 。
在记录行数一定的情况下,列的基数越大,该列中的值越分散,列的基数越小,该列中的值越集中
假设某个列
的基数为 1 ,也就是所有记录在该列中的值都一样,那为该列建立索引是没有用的,因为所有值都一样就无法排序,无法进行快速查找了。
而且如果某个建立了二级索引的列的重复值特别多,那么使用这个二级索引查出的记
录还可能要做回表操作,这样性能损耗就更大了。
所以结论就是:最好为那些列的基数大的列建立索引,为基数太小列的建立索引效果可能不好。
5.3 索引列的类型尽量小
在定义表结构的时候要显式的指定列的类型。
以整数类型为例,有 TINYINT 、 MEDIUMINT 、 INT 、 BIGINT
这么几种,它们占用的存储空间依次递增,我们这里所说的 类型大小
指的就是该类型表示的数据范围的大小。
能表示的整数范围当然也是依次递增,如果我们想要对某个整数列建立索引的话,在表示的整数范围允许的情况
下,尽量让索引列使用较小的类型。
数据类型越小,索引占用的存储空间就越少,在一个数据页内就可以放下更多的记录,从而减少磁盘 I/O 带来的性能损耗,也就意味着可以把更多的数据页缓存在内存中,从而加快读写效率。
5.4 索引字符串值的前缀
只对字符串的前几个字符进行索引
也就是说在二级索引的记录中只保留字符串前几个字符。
这样在查找记录时虽然不能精确的定位到记录的位置,但是能定位到相应前缀所在的位置,然后根据前缀相同的记录的主键值回表查询完整的字符串值,再对比就好了。这样只在 B+ 树中存储字符串的前几个字符的编码,既节约空间,又减少了字符串的比较时间。
比如创建这样的语句:
KEY idx_name_birthday_phone_number (name(10), birthday, phone_number)
5.4.1 索引列前缀对排序的影响
假如你要是order by name,那么此时你的name因为在索引树里仅仅包含了前几个字符,所以这个排序是没法用上索引了!group by也是同理的。
(因为如果要排序的话根据name完整值进行排序,而设计索引的name字段只使用到了10个字符,那么后面的字符是无法按照顺序排序的,最后排序的结果也是错误的,同理分组也一样)
5.5 让索引列在比较表达式中单独出现
假设表中有一个整数列 my_col ,我们为这个列建立了索引。下边的两个 WHERE 子句虽然语义是一致的,但是在
效率上却有差别:
- WHERE my_col * 2 < 4
- WHERE my_col < 4/2
第1个 WHERE 子句中 my_col 列并不是以单独列的形式出现的,而是以 my_col * 2 这样的表达式的形式出现的,
存储引擎会依次遍历所有的记录,计算这个表达式的值是不是小于 4 ,所以这种情况下是使用不到为 my_col 列
建立的 B+ 树索引的。
而第2个 WHERE 子句中 my_col 列并是以单独列的形式出现的,这样的情况可以直接使用
B+ 树索引。
所以结论就是:如果索引列在比较表达式中不是以单独列的形式出现,而是以某个表达式,或者函数调用形式出现的话,是用不到索引的
。
5.5 主键插入顺序
建议:让主键具有 AUTO_INCREMENT ,让存储引擎自己为表生成主键,而不是我们手动插入,避免自己手动插入的主键破坏了数据页,数据页分裂意味着 性能损耗。
所以,在设计主键的时候,别用UUID之类的,避免聚簇索引频繁的页分裂。
6、小结
-
B+ 树索引在空间和时间上都有代价,所以不要随意创建索引。
-
B+ 树索引适用于下边这些情况:
- 全值匹配
- 匹配左边的列
- 匹配列前缀
- 匹配范围值
- 等值匹配 + 范围匹配
- 用于排序
- 用于分组
-
在使用索引时需要注意下边这些事项:
- 只为用于搜索、排序或分组的列创建索引
- 为列的基数大的列创建索引
- 索引列的类型尽量小(列的数据类型)
- 可以只对字符串值的前缀建立索引
- 只有索引列在比较表达式中单独出现才可以适用索引
- 为了尽可能少的让
聚簇索引
发生页面分裂和记录移位的情况,建议让主键拥有AUTO_INCREMENT
属性。 - 定位并删除表中的
重复
和冗余
索引 - 尽量使用
覆盖索引
进行查询,避免回表
带来的性能损耗。
以上是关于MySQL ---- 索引类型 & 使用规则 & 回表覆盖索引 & 设计索引考虑因素的主要内容,如果未能解决你的问题,请参考以下文章