前言
根据前面对于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+
树的叶子节点处存储的用户记录只保留name
、birthday
、phone_number
这三个列的值以及主键id
的值,并不会保存country
列的值。
-
- 先按照
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'
的记录。 -
如果很不幸,
name
和birthday
列的值都是相同的,那记录是按照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
中列的定义顺序是name
、birthday
、phone_number
,如果我们的搜索条件中只有name
和phone_number
,而没有中间的birthday
,比方说这样:
SELECT * FROM person_info WHERE name = 'Ashburn' AND phone_number = '15123983239';
- 这样只能用到
name
列的索引,birthday
和phone_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';
-
上边这个查询可以分成两个部分:
- 通过条件
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+
树索引的。
(5)精确匹配某一列并范围匹配另外一列
- 如果左边的列是精确查找,则右边的列可以进行范围查找,比方说这样:
SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday > '1980-01-01' AND birthday < '2000-12-31' AND phone_number > '15100000000';
-
这个查询的条件可以分为3个部分:
-
name = 'Ashburn'
,对name
列进行精确查找,当然可以使用B+
树索引了。 -
birthday > '1980-01-01' AND birthday < '2000-12-31'
,由于name
列是精确查找,所以通过name = 'Ashburn'
条件查找后得到的结果的name
值都是相同的,它们会再按照birthday
的值进行排序。所以此时对birthday
列进行范围查找是可以用到B+
树索引的。 -
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 name
、ORDER BY name, birthday
这种匹配索引左边的列的形式可以使用部分的B+
树索引。当联合索引左边列的值为常量,也可以使用后边的列进行排序,比如这样:
SELECT * FROM person_info WHERE name = 'A' ORDER BY birthday, phone_number LIMIT 10;
- 这个查询能使用联合索引进行排序是因为
name
列的值相同的记录是按照birthday
,phone_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;
name
和country
并不属于一个联合索引中的列,所以无法使用索引进行排序
排序列使用了复杂的表达式
要想使用索引进行排序操作,必须保证索引列是以单独列的形式出现,而不是修饰过的形式,比方说这样:
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次分组操作:
-
先把记录按照
name
值进行分组,所有name
值相同的记录划分为一组。 -
将每个
name
值相同的分组里的记录再按照birthday
的值进行分组,将birthday
值相同的记录放到一个小分组里,所以看起来就像在一个大分组里又化分了好多小分组。 -
再将上一步中产生的小分组按照
phone_number
的值分成更小的分组,所以整体上看起来就像是先把记录分成一个大分组,然后把大分组
分成若干个小分组
,然后把若干个小分组
再细分成更多的小小分组
。
-
覆盖索引
索引下推
三、如何挑选索引
只为用于搜索、排序或分组的列创建索引
也就是说,只为出现在WHERE
子句中的列、连接子句中的连接列,或者出现在ORDER BY
或GROUP BY
子句中的列创建索引。而出现在查询列表中的列就没必要建立索引了:
SELECT birthday, country FROM person_name WHERE name = 'Ashburn';
像查询列表中的birthday
、country
这两个列就不需要建立索引,我们只需要为出现在WHERE
子句中的name
列创建索引就可以了。
考虑列的基数
- 所以结论就是:最好为那些列的基数大的列建立索引,为基数太小列的建立索引效果可能不好。
- 比如:男生、女生 这个字段建索引没有任何意义
索引列的类型尽量小
- 以整数类型为例,有
TINYINT
、MEDIUMINT
、INT
、BIGINT
这么几种,它们占用的存储空间依次递增,我们这里所说的类型大小
指的就是该类型表示的数据范围的大小。能表示的整数范围当然也是依次递增,如果我们想要对某个整数列建立索引的话,在表示的整数范围允许的情况下,尽量让索引列使用较小的类型,比如我们能使用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
之间:
- 如果此时再插入一条主键值为
9
的记录,那它插入的位置就如下图:
- 把本页中的一些记录移动到新创建的这个页中。页面分裂和记录移位意味着什么?意味着:性能损耗!所以如果我们想尽量避免这样无谓的性能损耗,最好让插入的记录的主键值依次递增,这样就不会发生这样的性能损耗了。所以我们建议:让主键具有
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+
树索引的过程中需要注意的一些点,后边我们还会陆续介绍更多的优化方法和注意事项,敬请期待。本集内容总结如下:
-
B+
树索引在空间和时间上都有代价。 -
B+
树索引适用于下边这些情况:- 全值匹配
- 匹配左边的列
- 匹配范围值
- 精确匹配某一列并范围匹配另外一列
- 用于排序
- 用于分组
-
在使用索引时需要注意下边这些事项:
- 只为用于搜索、排序或分组的列创建索引
- 为列的基数大的列创建索引
- 索引列的类型尽量小
- 可以只对字符串值的前缀建立索引
- 只有索引列在比较表达式中单独出现才可以适用索引
- 为了尽可能少的让
聚簇索引
发生页面分裂和记录移位的情况,建议让主键拥有AUTO_INCREMENT
属性。 - 定位并删除表中的重复和冗余索引
- 尽量使用
覆盖索引
进行查询,避免回表
带来的性能损耗。