InnoDB索引及优化

写在前面 > 本文章是学习掘金小册《MySQL 是怎样运行的:从根儿上理解 MySQL》 之后整理的,文章大量使用和借鉴了该小册的内容。另外小册很不错,讲解十分到位,推荐阅读。

我们在上一篇文章 《 InnoDB中数据是如何存储的 》中详细介绍了MySQL数据存储的细节,包括 行格式 。我们知道页分为很多种,本篇文章中主要涉及两种 数据页索引页,我们知道 数据页 是存储数据的,那 索引页 又是用来做什么的呢?

ok,多的不说,少的不唠,我们直接开始吧。

试想一下,如果没有索引,我们单纯依靠叶子节点组成的双链表查询数据的话,只能从头开始向后遍历,这样的效率是很低的,索引的出现正是为了解决这个问题的。

InnoDB索引

为了方便描述,假设我们创建了一张表,包含三列,abc,其中a为 int 类型,bc 为char 类型,且a 为主键,然后我们向表中插入若干条数据,

insert into ... values(1, 'b1', 'c1');
insert into ... values(2, 'b2', 'c2');
insert into ... values(3, 'b3', 'c3');
insert into ... values(4, 'b4', 'c4');
依次类推...

这时MySQL 会将数据保存到数据页中,并且还会生成索引页

image.png

如图需要注意:

  • 数据页中保存了完整的数据(我们insert的全部数据),
  • 索引页中每条记录包含了主键值(红色部分数据)和 页的编号(它们指向对应的数据页)
  • 索引页中每条记录并不包含主键外的其他列数据(其他列,比如第二列 b,第三列 c)
  • 记录和页都是依靠主键从小到大进行排序

现在有了索引页的帮助,我们再来检索的话(这里所说的检索是利用主键检索),可以利用索引页通过二分查找快速找到记录所在的页,然后再在页中找到对应的数据,这种方式比文章开头说的遍历链表要快很多。

虽然说索引页中每条记录只存储主键值和对应的页号,相比数据页能存放更多的记录,但是不论怎么说一个页只有16KB大小,那如果表中的数据太多,那一个索引页也是不够用的。这时我们需要更多的索引页:

image.png

如果你对数据结构有了解的话,就会知道,这是 B+树 。

聚簇索引

我们上边介绍的B+树本身就是一个目录,或者说本身就是一个索引。它有两个特点:

  1. 使用记录主键值的大小进行记录和页的排序,这包括三个方面的含义:

    • 页内的记录是按照主键的大小顺序排成一个单向链表。
    • 各个存放用户记录的页也是根据页中用户记录的主键大小顺序排成一个双向链表。
    • 存放目录项记录的页分为不同的层次,在同一层次中的页也是根据页中目录项记录的主键大小顺序排成一个双向链表。
  2. B+树的叶子节点存储的是完整的用户记录。

    所谓完整的用户记录,就是指这个记录中存储了所有列的值(包括隐藏列)。

我们把具有这两种特性的B+树称为聚簇索引,所有完整的用户记录都存放在这个聚簇索引的叶子节点处。这种聚簇索引并不需要我们在MySQL语句中显式的使用INDEX语句去创建(后边会介绍索引相关的语句),InnoDB存储引擎会自动的为我们创建聚簇索引。另外有趣的一点是,在InnoDB存储引擎中,聚簇索引就是数据的存储方式(所有的用户记录都存储在了叶子节点),也就是所谓的索引即数据,数据即索引。

二级索引

上边介绍的聚簇索引只能在搜索条件是主键值时才能发挥作用,因为B+树中的数据都是按照主键进行排序的。那如果我们想以别的列作为搜索条件该咋办呢?这时候我们可以再建立一颗B+树,我们现在为第二列 b 建立索引,假设第二列 b 中数据大小是这样的:b1 > b2 > b2 > ... > b12 > ... ,新建的B+树如图:

image.png

如图需要注意几点,

  • 红色的列依然是主键,蓝色的列为第二列 b
  • 页与页之间以及页内部记录之间都是按照第二列 b 进行排序的
  • 叶子节点存放的数据并不是完整的数据,只有主键和第二列 b
  • 内部节点中不再是主键+页号的搭配,而变成了第二列 b+页号的搭配。

这个时候,我们利用刚创建的索引查找数据的话,比如我们执行这样的sql,select * from xxx where b = 'b7',查找过程是这样的:

我们首先要在二级索引中查找,利用二分查找,由根节点向下,知道找到对应的叶子节点,也就是 b = 'b7' 的这条记录,这个时候可以拿到这条记录的主键也就是 a = 7,我们再根据主键到聚簇索引那颗B+树中查找,这个过程也被称为回表,最终利用聚簇索引可以找到这条记录的完整数据。

联合索引

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

  • 先把各个记录和页按照b列进行排序。
  • 在记录的b列相同的情况下,采用c列进行排序

也就是联合索引,有以下特点:

  • 内部节点中将包含bc页号这三个部分,各条记录先按照b列的值进行排序,如果记录的b列相同,则按照c列的值进行排序。
  • 叶子节点处的用户记录由bc和主键a列组成。

要注意一点,以c2和c3列的大小为排序规则建立的B+树称为联合索引,本质上也是一个二级索引。

MyISAM中的索引方案简单介绍

我们知道InnoDB中索引即数据,也就是聚簇索引的那棵B+树的叶子节点中已经把所有完整的用户记录都包含了,而MyISAM的索引方案虽然也使用树形结构,但是却将索引和数据分开存储:

  • 将表中的记录按照记录的插入顺序单独存储在一个文件中,称之为数据文件。这个文件并不划分为若干个数据页,有多少记录就往这个文件中塞多少记录就成了。我们可以通过行号而快速访问到一条记录。

  • 使用MyISAM存储引擎的表会把索引信息另外存储到一个称为索引文件的另一个文件中。MyISAM会单独为表的主键创建一个索引,只不过在索引的叶子节点中存储的不是完整的用户记录,而是主键值 + 行号的组合。也就是先通过索引找到对应的行号,再通过行号去找对应的记录!

索引如何使用

索引的代价

俗话说,世上没有完美的东西,这句话在索引身上也同样适用,在使用索引之前,我们必须明白在有些时候索引反而会成为拖后腿的存在:

  • 空间上的代价

    这个是显而易见的,每建立一个索引都要为它建立一棵B+树,每一棵B+树的每一个节点都是一个数据页,一个页默认会占用16KB的存储空间,一棵很大的B+树由许多数据页组成,那可是很大的一片存储空间呢。

  • 时间上的代价

    每次对表中的数据进行增、删、改操作时,都需要去修改各个B+树索引。而且我们讲过,B+树每层节点都是按照索引列的值从小到大的顺序排序而组成了双向链表。不论是叶子节点中的记录,还是内节点中的记录(也就是不论是用户记录还是目录项记录)都是按照索引列的值从小到大的顺序而形成了一个单向链表。而增、删、改操作可能会对节点和记录的排序造成破坏,所以存储引擎需要额外的时间进行一些记录移位,页面分裂、页面回收啥的操作来维护好节点和记录的排序。如果我们建了许多索引,每个索引对应的B+树都要进行相关的维护操作,这还能不给性能拖后腿么?

所以说,索引虽好可不要“贪杯”哦。

那么我们平常写的查询语句那些能用到索引,那些无法使用索引呢?接下来我们先来了解B+树索引的适用场景,

B+树索引适用的条件

为了方便表述,我们先创建一个表:

CREATE TABLE staff_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)
);

对于这个staff_info表我们需要注意两点:

  • 表中的主键是id列,它存储一个自动递增的整数。所以InnoDB存储引擎会自动为id列建立聚簇索引。
  • 我们额外定义了一个二级索引idx_name_birthday_phone_number,它是由3个列组成的联合索引。所以在这个索引对应的B+树的叶子节点处存储的用户记录只保留namebirthdayphone_number这三个列的值以及主键id的值,并不会保存country列的值。
全值匹配

当我们的搜索条件中的列和索引列一致的话,就称为全值匹配,这种情况可以完美使用索引,如:

SELECT * FROM staff_info WHERE name = 'Ashburn' AND birthday = '1990-09-27' AND phone_number = '15123983239';

注意,下面这样写同样可以使用索引:

SELECT * FROM staff_info WHERE birthday = '1990-09-27' AND phone_number = '15123983239' AND name = 'Ashburn';

MySQL中的查询优化器会帮我们分析决定查询条件的先后顺序,所以效果和上面的sql是一样的。

匹配左边的列

如果搜索语句中只包含联合索引中左边的列,也可以使用索引,比如这样:

SELECT * FROM staff_info WHERE name = 'Ashburn';

或者这样

SELECT * FROM staff_info WHERE name = 'Ashburn' AND birthday = '1990-09-27';

需要注意的是,搜索条件中的各个列必须是联合索引中从最左边连续的列。下面的sql是无法使用索引的:

SELECT * FROM staff_info WHERE birthday = '1990-09-27';

这是因为,B+树的数据页和记录先是按照name列的值排序的,在name列的值相同的情况下才使用birthday列进行排序,也就是说name列的值不同的记录中birthday的值可能是无序的。而现在跳过name列直接根据birthday的值去查找,显然是无法匹配的,会执行全表扫描。同理,下面的sql也是不行的:

SELECT * FROM staff_info WHERE name = 'Ashburn' AND phone_number = '15123983239';
匹配列前缀

我们前面说过联合索引idx_name_birthday_phone_number会先用name列的值进行排序,在name列的值相同的情况下才使用birthday列进行排序,然后在birthday列的值相同的情况下才使用phone_number列进行排序。那么比较两个字符串的大小其实是从第一个字符开始以此比较大小,也就是说,在联合索引idx_name_birthday_phone_number中的这些字符串的前n个字符,也就是前缀都是排好序的。所以这样写也可以使用索引:

SELECT * FROM staff_info WHERE name LIKE 'As%';
匹配范围值

像这样范围值匹配,同样可以使用索引:

SELECT * FROM staff_info WHERE name > 'Asa' AND name < 'Barlow';

需要注意,这样是不行的:

SELECT * FROM staff_info WHERE name > 'Asa' AND name < 'Barlow' AND birthday > '1980-01-01';

原因我们上面说过,在name列的值相同的情况下才使用birthday列进行排序,也就是说name列的值不同的记录中birthday的值可能是无序的。

精确匹配某一列并范围匹配另外一列

如果左边的列是精确查找,右边的列进行范围查找,则可以使用索引:

SELECT * FROM staff_info WHERE name = 'Ashburn' AND birthday > '1980-01-01' AND birthday < '2000-12-31';
SELECT * FROM staff_info WHERE name = 'Ashburn' AND birthday = '1980-01-01' AND phone_number > '15100000000';
排序

先看下边这个简单的查询语句:

SELECT * FROM staff_info ORDER BY name, birthday, phone_number LIMIT 10;

这个查询的结果集需要先按照name值排序,如果记录的name值相同,则需要按照birthday来排序,如果birthday的值相同,则需要按照phone_number排序。大家可以回过头去看我们建立的idx_name_birthday_phone_number索引的示意图,因为这个B+树索引本身就是按照上述规则排好序的,所以直接从索引中提取数据,然后进行回表操作取出该索引中不包含的列就好了。

有个问题需要注意,ORDER BY的子句后边的列的顺序也必须按照索引列的顺序给出,如果给出ORDER BY phone_number, birthday, name的顺序,那也是用不了B+树索引。

分组

下边这个分组查询,同样可以使用索引:

SELECT name, birthday, phone_number, COUNT(*) FROM staff_info GROUP BY name, birthday, phone_number

回表的代价

我们现在来讨论一下回表。还是用idx_name_birthday_phone_number索引为例,看下边这个查询:

SELECT * FROM staff_info WHERE name > 'Asa' AND name < 'Barlow';

在使用idx_name_birthday_phone_number索引进行查询时大致可以分为这两个步骤:

  1. 从索引idx_name_birthday_phone_number对应的B+树中取出name值在AsaBarlow之间的用户记录。
  2. 由于索引idx_name_birthday_phone_number对应的B+树用户记录中只包含namebirthdayphone_numberid这4个字段,而查询列表是*,意味着要查询表中所有字段,也就是还要包括country字段。这时需要把从上一步中获取到的每一条记录的id字段都到聚簇索引对应的B+树中找到完整的用户记录,也就是我们通常所说的回表,然后把完整的用户记录返回给查询用户。

由于索引idx_name_birthday_phone_number对应的B+树中的记录首先会按照name列的值进行排序,所以值在AsaBarlow之间的记录在磁盘中的存储是相连的,集中分布在一个或几个数据页中,我们可以很快的把这些连着的记录从磁盘中读出来,这种读取方式我们也可以称为顺序I/O。根据第1步中获取到的记录的id字段的值可能并不相连,而在聚簇索引中记录是根据id(也就是主键)的顺序排列的,所以根据这些并不连续的id值到聚簇索引中访问完整的用户记录可能分布在不同的数据页中,这样读取完整的用户记录可能要访问更多的数据页,这种读取方式我们也可以称为随机I/O。一般情况下,顺序I/O比随机I/O的性能高很多,所以步骤1的执行可能很快,而步骤2就慢一些。所以这个使用索引idx_name_birthday_phone_number的查询有这么两个特点:

  • 会使用到两个B+树索引,一个二级索引,一个聚簇索引。
  • 访问二级索引使用顺序I/O,访问聚簇索引使用随机I/O

需要回表的记录越多,使用二级索引的性能就越低,甚至让某些查询宁愿使用全表扫描也不使用二级索引。比方说name值在AsaBarlow之间的用户记录数量占全部记录数量90%以上,那么如果使用idx_name_birthday_phone_number索引的话,有90%多的id值需要回表,这不是吃力不讨好么,还不如直接去扫描聚簇索引(也就是全表扫描)。

覆盖索引

为了彻底告别回表操作带来的性能损耗,我们建议:最好在查询列表里只包含索引列,比如这样:

SELECT name, birthday, phone_number FROM staff_info WHERE name > 'Asa' AND name < 'Barlow'

因为我们只查询name, birthday, phone_number这三个索引列的值,所以在通过idx_name_birthday_phone_number索引得到结果后就不必到聚簇索引中再查找记录的剩余列,也就是country列的值了,这样就省去了回表操作带来的性能损耗。我们把这种只需要用到索引的查询方式称为索引覆盖

索引如何优化

有了上面的铺垫,这部分内容可以说是水到渠成。一起来看一看。

只为用于搜索、排序或分组的列创建索引

也就是说,只为出现在WHERE子句中的列、连接子句中的连接列,或者出现在ORDER BYGROUP BY子句中的列创建索引。而出现在查询列表中的列就没必要建立索引了:

SELECT birthday, country FROM staff_info WHERE name = 'Ashburn';

像查询列表中的birthdaycountry这两个列就不需要建立索引,我们只需要为出现在WHERE子句中的name列创建索引就可以了。

考虑列的基数

列的基数指的是某一列中不重复数据的个数,比方说某个列包含值2, 5, 8, 2, 5, 8, 2, 5, 8,虽然有9条记录,但该列的基数却是3。也就是说,在记录行数一定的情况下,列的基数越大,该列中的值越分散,列的基数越小,该列中的值越集中。这个列的基数指标非常重要,直接影响我们是否能有效的利用索引。假设某个列的基数为1,也就是所有记录在该列中的值都一样,那为该列建立索引是没有用的,因为所有值都一样就无法排序,无法进行快速查找了~ 而且如果某个建立了二级索引的列的重复值特别多,那么使用这个二级索引查出的记录还可能要做回表操作,这样性能损耗就更大了。所以结论就是:最好为那些列的基数大的列建立索引,为基数太小列的建立索引效果可能不好。

索引列的类型尽量小

我们在定义表结构的时候要显式的指定列的类型,以整数类型为例,有TINYINTMEDIUMINTINTBIGINT这么几种,它们占用的存储空间依次递增,我们这里所说的类型大小指的就是该类型表示的数据范围的大小。能表示的整数范围当然也是依次递增,如果我们想要对某个整数列建立索引的话,在表示的整数范围允许的情况下,尽量让索引列使用较小的类型,比如我们能使用INT就不要使用BIGINT,能使用MEDIUMINT就不要使用INT~ 这是因为:

  • 数据类型越小,在查询时进行的比较操作越快
  • 数据类型越小,索引占用的存储空间就越少,在一个数据页内就可以放下更多的记录,从而减少磁盘I/O带来的性能损耗,也就意味着可以把更多的数据页缓存在内存中,从而加快读写效率。

这个建议对于表的主键来说更加适用,因为不仅是聚簇索引中会存储主键值,其他所有的二级索引的节点处都会存储一份记录的主键值,如果主键适用更小的数据类型,也就意味着节省更多的存储空间和更高效的I/O

索引字符串值的前缀

我们知道一个字符串其实是由若干个字符组成,如果我们在MySQL中使用utf8字符集去存储字符串的话,编码一个字符需要占用1~3个字节。假设我们的字符串很长,那存储一个字符串就需要占用很大的存储空间。在我们需要为这个字符串列建立索引时,那就意味着在对应的B+树中有这么两个问题:

  • B+树索引中的记录需要把该列的完整字符串存储起来,而且字符串越长,在索引中占用的存储空间越大。
  • 如果B+树索引中索引列存储的字符串很长,那在做字符串比较时会占用更多的时间。

我们前边儿说过索引列的字符串前缀其实也是排好序的,所以索引的设计者提出了个方案 --- 只对字符串的前几个字符进行索引也就是说在二级索引的记录中只保留字符串前几个字符。这样在查找记录时虽然不能精确的定位到记录的位置,但是能定位到相应前缀所在的位置,然后根据前缀相同的记录的主键值回表查询完整的字符串值,再对比就好了。这样只在B+树中存储字符串的前几个字符的编码,既节约空间,又减少了字符串的比较时间,还大概能解决排序的问题。

不过以上存在一个问题,如果使用了索引列前缀,比方说前边只把name列的前10个字符放到了二级索引中,下边这个查询可能就有点儿尴尬了:

SELECT * FROM staff_info ORDER BY name LIMIT 10;

因为二级索引中不包含完整的name列信息,所以无法对前十个字符相同,后边的字符不同的记录进行排序,也就是使用索引列前缀的方式无法支持使用索引排序。

让索引列在比较表达式中单独出现

假设表中有一个整数列my_col,我们为这个列建立了索引。下边的两个WHERE子句虽然语义是一致的,但是在效率上却有差别:

  1. WHERE my_col * 2 < 4
  2. WHERE my_col < 4/2

第1个WHERE子句中my_col列并不是以单独列的形式出现的,而是以my_col * 2这样的表达式的形式出现的,存储引擎会依次遍历所有的记录,计算这个表达式的值是不是小于4,所以这种情况下是使用不到为my_col列建立的B+树索引的。而第2个WHERE子句中my_col列并是以单独列的形式出现的,这样的情况可以直接使用B+树索引。

所以结论就是:如果索引列在比较表达式中不是以单独列的形式出现,而是以某个表达式,或者函数调用形式出现的话,是用不到索引的。

主键插入顺序

对于一个使用InnoDB存储引擎的表来说,在我们没有显式的创建索引时,表中的数据实际上都是存储在聚簇索引的叶子节点的。而记录又是存储在数据页中的,数据页和记录又是按照记录主键值从小到大的顺序进行排序,所以如果我们插入的记录的主键值是依次增大的话,那我们每插满一个数据页就换到下一个数据页继续插,而如果我们插入的主键值忽大忽小的话,这就比较麻烦了,涉及到页面分裂和记录移位。如果让主键具有AUTO_INCREMENT(自增),在插入记录时存储引擎会自动为我们填入自增的主键值,则可以避免这个问题。

总结

关于InnoDB存储引擎的B+树索引,有以下总结:

  • 每个索引都对应一棵B+树,B+树分为好多层,最下边一层是叶子节点,其余的是内节点。所有用户记录都存储在B+树的叶子节点,所有目录项记录都存储在内节点。
  • InnoDB存储引擎会自动为主键(如果没有它会自动帮我们添加)建立聚簇索引,聚簇索引的叶子节点包含完整的用户记录。
  • 我们可以为自己感兴趣的列建立二级索引二级索引的叶子节点包含的用户记录由索引列 + 主键组成,所以如果想通过二级索引来查找完整的用户记录的话,需要通过回表操作,也就是在通过二级索引找到主键值之后再到聚簇索引中查找完整的用户记录。
  • B+树中每层节点都是按照索引列值从小到大的顺序排序而组成了双向链表,而且每个页内的记录(不论是用户记录还是目录项记录)都是按照索引列的值从小到大的顺序而形成了一个单链表。如果是联合索引的话,则页面和记录先按照联合索引前边的列排序,如果该列值相同,再按照联合索引后边的列排序。
  • 通过索引查找记录是从B+树的根节点开始,一层一层向下搜索。由于每个页面都按照索引列的值建立了Page Directory(页目录),所以在这些页面中的查找非常快。

索引的使用和优化:

  1. B+树索引在空间和时间上都有代价,所以没事儿别瞎建索引。

  2. B+树索引适用于下边这些情况:

    • 全值匹配
    • 匹配左边的列
    • 匹配范围值
    • 精确匹配某一列并范围匹配另外一列
    • 用于排序
    • 用于分组
  3. 在使用索引时需要注意下边这些事项:

    • 只为用于搜索、排序或分组的列创建索引
    • 为列的基数大的列创建索引
    • 索引列的类型尽量小
    • 可以只对字符串值的前缀建立索引
    • 只有索引列在比较表达式中单独出现才可以适用索引
    • 为了尽可能少的让聚簇索引发生页面分裂和记录移位的情况,建议让主键拥有AUTO_INCREMENT属性。
    • 定位并删除表中的重复和冗余索引
    • 尽量使用覆盖索引进行查询,避免回表带来的性能损耗。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章