MySQL 是怎样运行 - B+树索引的使用

331 阅读16分钟

前言

根据前面对于InnoDB存储引擎的B+树的索引,可以得出以下结论:

  • 每个索引都对应一棵B+树,B+树分为好多层,最下边一层是叶子节点,其余的是内节点。所有用户记录都存储在B+树的叶子节点,所有目录项记录都存储在内节点。

  • InnoDB存储引擎会自动为主键(如果没有它会自动帮我们添加)建立聚簇索引,聚簇索引的叶子节点包含完整的用户记录。

  • 我们可以为自己感兴趣的列建立二级索引二级索引的叶子节点包含的用户记录由索引列 + 主键组成,所以如果想通过二级索引来查找完整的用户记录的话,需要通过回表操作,也就是在通过二级索引找到主键值之后再到聚簇索引中查找完整的用户记录。

  • B+树中每层节点都是按照索引列值从小到大的顺序排序而组成了双向链表,而且每个页内的记录(不论是用户记录还是目录项记录)都是按照索引列的值从小到大的顺序而形成了一个单链表。如果是联合索引的话,则页面和记录先按照联合索引前边的列排序,如果该列值相同,再按照联合索引后边的列排序。

  • 通过索引查找记录是从B+树的根节点开始,一层一层向下搜索。由于每个页面都按照索引列的值建立了Page Directory(页目录),所以在这些页面中的查找非常快。

B+ 树索引的使用

一、索引的代价

  • 空间上的代价
    • 每建立一个索引都要为它建立一棵B+树,每一棵B+树的每一个节点都是一个数据页,一个页默认会占用16KB的存储空间,一棵很大的B+树由许多数据页组成,那可是很大的一片存储空间呢。
  • 时间代价
    • 每次对表中的数据进行增、删、改操作时,都需要去修改各个B+树索引

二、B+树索引适用的条件:

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)
);
  • 建表
    • 表中的主键是id列,它存储一个自动递增的整数。所以InnoDB存储引擎会自动为id列建立聚簇索引。

    • 我们额外定义了一个二级索引idx_name_birthday_phone_number,它是由3个列组成的联合索引。所以在这个索引对应的B+树的叶子节点处存储的用户记录只保留namebirthdayphone_number这三个列的值以及主键id的值,并不会保存country列的值。

image.png

  • 先按照name列的值进行排序。
  • 如果name列的值相同,则按照birthday列的值进行排序。
  • 如果birthday列的值也相同,则按照phone_number的值进行排序。

(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'的记录。

    • 如果很不幸,namebirthday列的值都是相同的,那记录是按照phone_number列的值排序的,所以联合索引中的三个列都可能被用到。

  • where 后面的查询顺序调换是否有影响

    • 没有影响,会自动进行查询优化

(2)最左匹配

  • 其实在我们的搜索语句中也可以不用包含全部联合索引中的列,只包含左边的就行,比方说下边的查询语句:
SELECT * FROM person_info WHERE name = 'Ashburn';

或者包含多个左边的列也行:

SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday = '1990-09-27';
  • 如果我们想使用联合索引中尽可能多的列,搜索条件中的各个列必须是联合索引中从最左边连续的列。比方说联合索引idx_name_birthday_phone_number中列的定义顺序是namebirthdayphone_number,如果我们的搜索条件中只有namephone_number,而没有中间的birthday,比方说这样:
SELECT * FROM person_info WHERE name = 'Ashburn' AND phone_number = '15123983239';
  • 这样只能用到name列的索引,birthdayphone_number的索引就用不上了,因为name值相同的记录先按照birthday的值进行排序,birthday值相同的记录才按照phone_number值进行排序。

(3)最左前缀

  • 比方说我们想查询名字以'As'开头的记录,那就可以这么写查询语句:
SELECT * FROM person_info WHERE name LIKE 'As%';
  • 但是需要注意的是,如果只给出后缀或者中间的某个字符串,比如这样:
SELECT * FROM person_info WHERE name LIKE '%As%';
  • MySQL就无法快速定位记录位置了,因为字符串中间有'As'的字符串并没有排好序,所以只能全表扫描了

(4)匹配范围值

  • 回头看我们idx_name_birthday_phone_number索引的B+树示意图,所有记录都是按照索引列的值从小到大的顺序排好序的,所以这极大的方便我们查找索引列的值在某个范围内的记录。比方说下边这个查询语句:
SELECT * FROM person_info WHERE name > 'Asa' AND name < 'Barlow';
  • 由于B+树中的数据页和记录是先按name列排序的,所以我们上边的查询过程其实是这样的:

    • 通过B+树在叶子节点中找到第一条name值大于Asa的二级索引记录,读取该记录的主键值进行回表操作,获得对应的聚簇索引记录后发送给客户端。

    • 根据上一步找到的记录,沿着记录所在的链表向后查找(同一页面中的记录使用单向链表连接起来,数据页之间用双向链表连接起来)下一条二级索引记录,判断该记录是否符合name < 'Barlow'条件,如果符合,则进行回表操作后发送至客户端。

    • 重复上一步骤,直到某条二级索引记录不符合name <'Barlow'条件为止。

  • 只有对索引最左边的那个列进行范围查找的时候才能用到B+树索引,比方说这样:

SELECT * FROM person_info WHERE name > 'Asa' AND name < 'Barlow' AND birthday > '1980-01-01';
  • 上边这个查询可以分成两个部分:

    1. 通过条件name > 'Asa' AND name < 'Barlow' 来对name进行范围,查找的结果可能有多条name值不同的记录,
    2. 对这些name值不同的记录继续通过birthday > '1980-01-01'条件继续过滤。
  • 这样子对于联合索引idx_name_birthday_phone_number来说,只能用到name列的部分,而用不到birthday列的部分,因为只有name值相同的情况下才能用birthday列的值进行排序,而这个查询中通过name进行范围查找的记录中可能并不是按照birthday列进行排序的,所以在搜索条件中继续以birthday列进行查找时是用不到这个B+树索引的。

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

  • 如果左边的列是精确查找,则右边的列可以进行范围查找,比方说这样:
SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday > '1980-01-01' AND birthday < '2000-12-31' AND phone_number > '15100000000';
  • 这个查询的条件可以分为3个部分:

    1. name = 'Ashburn',对name列进行精确查找,当然可以使用B+树索引了。

    2. birthday > '1980-01-01' AND birthday < '2000-12-31',由于name列是精确查找,所以通过name = 'Ashburn'条件查找后得到的结果的name值都是相同的,它们会再按照birthday的值进行排序。所以此时对birthday列进行范围查找是可以用到B+树索引的。

    3. phone_number > '15100000000',通过birthday的范围查找的记录的birthday的值可能不同,所以这个条件无法再利用B+树索引了,只能遍历上一步查询得到的记录。

  • 同理,下边的查询也是可能用到这个idx_name_birthday_phone_number联合索引的:

SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday = '1980-01-01' AND phone_number > '15100000000';

(6)用于排序

  • 一般情况下,我们只能把记录都加载到内存中,再用一些排序算法,比如快速排序、归并排序、吧啦吧啦排序等等在内存中对这些记录进行排序,有的时候可能查询的结果集太大以至于不能在内存中进行排序的话,还可能暂时借助磁盘的空间来存放中间结果,排序操作完成后再把排好序的结果集返回到客户端
  • MySQL中,把这种在内存中或者磁盘上进行排序的方式统称为文件排序(英文名:filesort),跟文件这个词儿一沾边儿,就显得这些排序操作非常慢了(磁盘和内存的速度比起来,就像是飞机和蜗牛的对比)。但是如果ORDER BY子句里使用到了我们的索引列,就有可能省去在内存或文件中排序的步骤,比如下边这个简单的查询语句:
SELECT * FROM person_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+树索引,这种颠倒顺序就不能使用索引的原因我们上边详细说过了,这就不赘述了。

  • 同理,ORDER BY nameORDER BY name, birthday这种匹配索引左边的列的形式可以使用部分的B+树索引。当联合索引左边列的值为常量,也可以使用后边的列进行排序,比如这样:

SELECT * FROM person_info WHERE name = 'A' ORDER BY birthday, phone_number LIMIT 10;
  • 这个查询能使用联合索引进行排序是因为name列的值相同的记录是按照birthdayphone_number排序的,说了好多遍了都。

不可以使用索引进行排序的几种情况

ASC、DESC混用

对于使用联合索引进行排序的场景,我们要求各个排序列的排序顺序是一致的,也就是要么各个列都是ASC规则排序,要么都是DESC规则排序。

如果我们查询的需求是先按照name列进行升序排列,再按照birthday列进行降序排列的话,比如说这样的查询语句:

SELECT * FROM person_info ORDER BY name, birthday DESC LIMIT 10;

这样如果使用索引排序的话过程就是这样的:

  • 先从索引的最左边确定name列最小的值,然后找到name列等于该值的所有记录,然后从name列等于该值的最右边的那条记录开始往左找10条记录。
  • 如果name列等于最小的值的记录不足10条,再继续往右找name值第二小的记录,重复上边那个过程,直到找到10条记录为止。
排序列包含非同一个索引的列

有时候用来排序的多个列不是一个索引里的,这种情况也不能使用索引进行排序,比方说:

SELECT * FROM person_info ORDER BY name, country LIMIT 10;

namecountry并不属于一个联合索引中的列,所以无法使用索引进行排序

排序列使用了复杂的表达式

要想使用索引进行排序操作,必须保证索引列是以单独列的形式出现,而不是修饰过的形式,比方说这样:

SELECT * FROM person_info ORDER BY UPPER(name) LIMIT 10;

使用了UPPER函数修饰过的列就不是单独的列啦,这样就无法使用索引进行排序啦。

(7)用于分组

下边这个分组查询:

SELECT name, birthday, phone_number, COUNT(*) FROM person_info GROUP BY name, birthday, phone_number
  • 这个查询语句相当于做了3次分组操作:

    1. 先把记录按照name值进行分组,所有name值相同的记录划分为一组。

    2. 将每个name值相同的分组里的记录再按照birthday的值进行分组,将birthday值相同的记录放到一个小分组里,所以看起来就像在一个大分组里又化分了好多小分组。

    3. 再将上一步中产生的小分组按照phone_number的值分成更小的分组,所以整体上看起来就像是先把记录分成一个大分组,然后把大分组分成若干个小分组,然后把若干个小分组再细分成更多的小小分组

覆盖索引

索引下推

三、如何挑选索引

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

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

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

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

考虑列的基数

  • 所以结论就是:最好为那些列的基数大的列建立索引,为基数太小列的建立索引效果可能不好。
  • 比如:男生、女生 这个字段建索引没有任何意义

索引列的类型尽量小

  • 以整数类型为例,有TINYINTMEDIUMINTINTBIGINT这么几种,它们占用的存储空间依次递增,我们这里所说的类型大小指的就是该类型表示的数据范围的大小。能表示的整数范围当然也是依次递增,如果我们想要对某个整数列建立索引的话,在表示的整数范围允许的情况下,尽量让索引列使用较小的类型,比如我们能使用INT就不要使用BIGINT,能使用MEDIUMINT就不要使用INT~ 这是因为:
    • 数据类型越小,在查询时进行的比较操作越快(这是CPU层次的东东)
    • 数据类型越小,索引占用的存储空间就越少,在一个数据页内就可以放下更多的记录,从而减少磁盘I/O带来的性能损耗,也就意味着可以把更多的数据页缓存在内存中,从而加快读写效率。

索引字符串值的前缀

  • 在我们需要为这个字符串列建立索引时,那就意味着在对应的B+树中有这么两个问题:

    • B+树索引中的记录需要把该列的完整字符串存储起来,而且字符串越长,在索引中占用的存储空间越大。
    • 如果B+树索引中索引列存储的字符串很长,那在做字符串比较时会占用更多的时间。
  • 所以索引的设计者提出了个方案 --- 只对字符串的前几个字符进行索引也就是说在二级索引的记录中只保留字符串前几个字符

索引列前缀对排序的影响

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

SELECT * FROM person_info ORDER BY name LIMIT 10;

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

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

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

主键插入顺序

  • 我们知道,对于一个使用InnoDB存储引擎的表来说,在我们没有显式的创建索引时,表中的数据实际上都是存储在聚簇索引的叶子节点的。而记录又是存储在数据页中的,数据页和记录又是按照记录主键值从小到大的顺序进行排序,所以如果我们插入的记录的主键值是依次增大的话,那我们每插满一个数据页就换到下一个数据页继续插,而如果我们插入的主键值忽大忽小的话,这就比较麻烦了,假设某个数据页存储的记录已经满了,它存储的主键值在1~100之间:

image.png

  • 如果此时再插入一条主键值为9的记录,那它插入的位置就如下图:

image.png

  • 把本页中的一些记录移动到新创建的这个页中。页面分裂和记录移位意味着什么?意味着:性能损耗!所以如果我们想尽量避免这样无谓的性能损耗,最好让插入的记录的主键值依次递增,这样就不会发生这样的性能损耗了。所以我们建议:让主键具有AUTO_INCREMENT,让存储引擎自己为表生成主键,而不是我们手动插入 ,比方说我们可以这样定义person_info表:
CREATE TABLE person_info(
    id INT UNSIGNED 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(10), birthday, phone_number)
);    
  • 我们自定义的主键列id拥有AUTO_INCREMENT属性,在插入记录时存储引擎会自动为我们填入自增的主键值。

  • 我们知道,通过idx_name_birthday_phone_number索引就可以对name列进行快速搜索,再创建一个专门针对name列的索引就算是一个冗余索引,维护这个索引只会增加维护的成本,并不会对搜索有什么好处。

  • 另一种情况,我们可能会对某个列重复建立索引,比方说这样:

CREATE TABLE repeat_index_demo (
    c1 INT PRIMARY KEY,
    c2 INT,
    UNIQUE uidx_c1 (c1),
    INDEX idx_c1 (c1)
);  
  • 我们看到,c1既是主键、又给它定义为一个唯一索引,还给它定义了一个普通索引,可是主键本身就会生成聚簇索引,所以定义的唯一索引和普通索引是重复的,这种情况要避免。

总结

  • 上边只是我们在创建和使用B+树索引的过程中需要注意的一些点,后边我们还会陆续介绍更多的优化方法和注意事项,敬请期待。本集内容总结如下:
  1. B+树索引在空间和时间上都有代价。

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

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

    • 只为用于搜索、排序或分组的列创建索引
    • 为列的基数大的列创建索引
    • 索引列的类型尽量小
    • 可以只对字符串值的前缀建立索引
    • 只有索引列在比较表达式中单独出现才可以适用索引
    • 为了尽可能少的让聚簇索引发生页面分裂和记录移位的情况,建议让主键拥有AUTO_INCREMENT属性。
    • 定位并删除表中的重复和冗余索引
    • 尽量使用覆盖索引进行查询,避免回表带来的性能损耗。