深入MySQL索引
1.1 索引是什么
-
官方介绍索引是帮助MySQL高效获取数据的数据结构。更通俗的说,数据库索引好比是一本书前面的目录,能加快数据库的查询速度。
-
一般来说索引本身也很大,不可能全部存储在内存中,因此索引往往是存储在磁盘上的文件中的(可能存储在单独的索引文件中,也可能和数据一起存储在数据文件中)。
-
我们通常所说的索引,包括聚集索引、覆盖索引、组合索引、前缀索引、唯一索引等,没有特别说明,默认都是使用B+树结构组织(多路搜索树,并不一定是二叉的)的索引。
1.2 索引的优势和劣势
优势:
-
可以提高数据检索的效率,降低数据库的IO成本,类似于书的目录。
-
通过索引列对数据进行排序,降低数据排序的成本,降低了CPU的消耗。
-
被索引的列会自动进行排序,包括【单列索引】和【组合索引】,只是组合索引的排序要复杂一些。
-
如果按照索引列的顺序进行排序,对应order by语句来说,效率就会提高很多。
劣势:
-
索引会占据磁盘空间
-
索引虽然会提高查询效率,但是会降低更新表的效率。比如每次对表进行增删改操作,MySQL不仅要保存数据,还有保存或者更新对应的索引文件。
二、索引类型
2.1 主键索引
索引列中的值必须是唯一的,不允许有空值。
2.2 普通索引
MySQL中基本索引类型,没有什么限制,允许在定义索引的列中插入重复值和空值。
2.3 唯一索引
索引列中的值必须是唯一的,但是允许为空值。
2.4 全文索引
只能在文本类型CHAR,VARCHAR,TEXT类型字段上创建全文索引。字段长度比较大时,如果创建普通索引,在进行like模糊查询时效率比较低,这时可以创建全文索引。MyISAM和InnoDB中都可以使用全文索引。
2.5 空间索引
MySQL在5.7之后的版本支持了空间索引,而且支持OpenGIS几何数据模型。MySQL在空间索引这方面遵循OpenGIS几何数据模型规则。
2.6 前缀索引
在文本类型如CHAR,VARCHAR,TEXT类列上创建索引时,可以指定索引列的长度,但是数值类型不能指定。
2.7 其他(按照索引列数量分类)
-
单列索引
-
组合索引
组合索引的使用,需要遵循最左前缀匹配原则(最左匹配原则)。一般情况下在条件允许的情况下使用组合索引替代多个单列索引使用。
三、索引的数据结构
3.1 Hash表
Hash表,在Java中的HashMap,TreeMap就是Hash表结构,以键值对的方式存储数据。我们使用Hash表存储表数据Key可以存储索引列,Value可以存储行记录或者行磁盘地址。Hash表在等值查询时效率很高,时间复杂度为O(1);但是不支持范围快速查找,范围查找时还是只能通过扫描全表方式。
显然这种并不适合作为经常需要查找和范围查找的数据库索引使用。
3.2 二叉查找树
二叉树,我想大家都会在心里有个图。
二叉树特点:每个节点最多有2个分叉,左子树和右子树数据顺序左小右大。
这个特点就是为了保证每次查找都可以这折半而减少IO次数,但是二叉树就很考验第一个根节点的取值,因为很容易在这个特点下出现我们并发想发生的情况“树不分叉了”,这就很难受很不稳定。
显然这种情况不稳定的我们再选择设计上必然会避免这种情况的
3.3 平衡二叉树
平衡二叉树是采用二分法思维,平衡二叉查找树除了具备二叉树的特点,最主要的特征是树的左右两个子树的层级最多相差1。在插入删除数据时通过左旋/右旋操作保持二叉树的平衡,不会出现左子树很高、右子树很矮的情况。
使用平衡二叉查找树查询的性能接近于二分查找法,时间复杂度是 O(log2n)。查询id=6,只需要两次IO。
就这个特点来看,可能各位会觉得这就很好,可以达到二叉树的理想的情况了。然而依然存在一些问题:
-
时间复杂度和树高相关。树有多高就需要检索多少次,每个节点的读取,都对应一次磁盘 IO 操作。树的高度就等于每次查询数据时磁盘 IO 操作的次数。磁盘每次寻道时间为10ms,在表数据量大时,查询性能就会很差。(1百万的数据量,log2n约等于20次磁盘IO,时间20*10=0.2s)
-
平衡二叉树不支持范围查询快速查找,范围查询时需要从根节点多次遍历,查询效率不高。
3.4 B树:改造二叉树
前置知识:
MySQL的数据是存储在磁盘文件中的,查询处理数据时,需要先把磁盘中的数据加载到内存中,磁盘IO 操作非常耗时,所以我们优化的重点就是尽量减少磁盘 IO 操作。访问二叉树的每个节点就会发生一次IO,如果想要减少磁盘IO操作,就需要尽量降低树的高度。那如何降低树的高度呢?
假如key为bigint=8字节,每个节点有两个指针,每个指针为4个字节,一个节点占用的空间16个字节(8+4*2=16)。
因为在MySQL的InnoDB存储引擎一次IO会读取的一页(默认一页16K)的数据量,而二叉树一次IO有效数据量只有16字节,空间利用率极低。为了最大化利用一次IO空间,一个简单的想法是在每个节点存储多个元素,在每个节点尽可能多的存储数据。每个节点可以存储1000个索引(16k/16=1000),这样就将二叉树改造成了多叉树,通过增加树的叉树,将树从高瘦变为矮胖。构建1百万条数据,树的高度只需要2层就可以(1000*1000=1百万),也就是说只需要2次磁盘IO就可以查询到数据。磁盘IO次数变少了,查询数据的效率也就提高了。
这种数据结构我们称为B树,B树是一种多叉平衡查找树,如下图主要特点:
-
B树的节点中存储着多个元素,每个内节点有多个分叉。
-
节点中的元素包含键值和数据,节点中的键值从大到小排列。也就是说,在所有的节点都储存数据。
-
父节点当中的元素不会出现在子节点中。
-
所有的叶子结点都位于同一层,叶节点具有相同的深度,叶节点之间没有指针连接。
举个例子,在b树中查询数据的情况:
假如我们查询值等于10的数据。查询路径磁盘块1->磁盘块2->磁盘块5。
第一次磁盘IO:将磁盘块1加载到内存中,在内存中从头遍历比较,10<15,走左路,到磁盘寻址磁盘块2。
第二次磁盘IO:将磁盘块2加载到内存中,在内存中从头遍历比较,7<10,到磁盘中寻址定位到磁盘块5。
第三次磁盘IO:将磁盘块5加载到内存中,在内存中从头遍历比较,10=10,找到10,取出data,如果data存储的行记录,取出data,查询结束。如果存储的是磁盘地址,还需要根据磁盘地址到磁盘中取出数据,查询终止。
相比二叉平衡查找树,在整个查找过程中,虽然数据的比较次数并没有明显减少,但是磁盘IO次数会大大减少。同时,由于我们的比较是在内存中进行的,比较的耗时可以忽略不计。B树的高度一般2至3层就能满足大部分的应用场景,所以使用B树构建索引可以很好的提升查询的效率。
B树索引查询过程
看到这里一定觉得B树就很理想了,但是前辈们会告诉你依然存在可以优化的地方:
B树不支持范围查询的快速查找,你想想这么一个情况如果我们想要查找10和35之间的数据,查找到15之后,需要回到根节点重新遍历查找,需要从根节点进行多次遍历,查询效率有待提高。
如果data存储的是行记录,行的大小随着列数的增多,所占空间会变大。这时,一个页中可存储的数据量就会变少,树相应就会变高,磁盘IO次数就会变大。
3.5 B+树:改造B树
B+树,作为B树的升级版,在B树基础上,MySQL在B树的基础上继续改造,使用B+树构建索引。B+树和B树最主要的区别在于非叶子节点是否存储数据的问题
B树:非叶子节点和叶子节点都会存储数据。
B+树:只有叶子节点才会存储数据,非叶子节点至存储键值。叶子节点之间使用双向指针连接,最底层的叶子节点形成了一个双向有序链表。
B+树数据结构
B+树的最底层叶子节点包含了所有的索引项。从图上可以看到,B+树在查找数据的时候,由于数据都存放在最底层的叶子节点上,所以每次查找都需要检索到叶子节点才能查询到数据。
所以在需要查询数据的情况下每次的磁盘的IO跟树高有直接的关系,但是从另一方面来说,由于数据都被放到了叶子节点,放索引的磁盘块锁存放的索引数量是会跟这增加的,相对于B树来说,B+树的树高理论上情况下是比B树要矮的。
也存在索引覆盖查询的情况,在索引中数据满足了当前查询语句所需要的全部数据,此时只需要找到索引即可立刻返回,不需要检索到最底层的叶子节点。
举个例子:等值查询
假如我们查询值等于9的数据。查询路径磁盘块1->磁盘块2->磁盘块6。
第一次磁盘IO:将磁盘块1加载到内存中,在内存中从头遍历比较,9<15,走左路,到磁盘寻址磁盘块2。
第二次磁盘IO:将磁盘块2加载到内存中,在内存中从头遍历比较,7<9<12,到磁盘中寻址定位到磁盘块6。
第三次磁盘IO:将磁盘块6加载到内存中,在内存中从头遍历比较,在第三个索引中找到9,取出data,如果data存储的行记录,取出data,查询结束。如果存储的是磁盘地址,还需要根据磁盘地址到磁盘中取出数据,查询终止。(这里需要区分的是在InnoDB中Data存储的为行数据,而MyIsam中存储的是磁盘地址。)
过程如图:
B+树根据索引等值查询过程
范围查询:
假如我们想要查找9和26之间的数据。查找路径是磁盘块1->磁盘块2->磁盘块6->磁盘块7。
首先查找值等于9的数据,将值等于9的数据缓存到结果集。这一步和前面等值查询流程一样,发生了三次磁盘IO。
查找到15之后,底层的叶子节点是一个有序列表,我们从磁盘块6,键值9开始向后遍历筛选所有符合筛选条件的数据。
第四次磁盘IO:根据磁盘6后继指针到磁盘中寻址定位到磁盘块7,将磁盘7加载到内存中,在内存中从头遍历比较,9<25<26,9<26<=26,将data缓存到结果集。
主键具备唯一性(后面不会有<=26的数据),不需再向后查找,查询终止。将结果集返回给用户。
可以看到B+树可以保证等值和范围查询的快速查找,MySQL的索引就采用了B+树的数据结构。
四、Mysql的索引实现
介绍完了索引数据结构,那肯定是要带入到Mysql里面看看真实的使用场景的,所以这里分析Mysql的两种存储引擎的索引实现:MyISAM索引和InnoDB索引
4.1 MyIsam索引
以一个简单的user表为例。user表存在两个索引,id列为主键索引,age列为普通索引
CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(20) DEFAULT NULL, `age` int(11) DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE, KEY `idx_age` (`age`) USING BTREE ) ENGINE = MyISAM AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8;
MyIsam_user查询数据
MyISAM的数据文件和索引文件是分开存储的。MyISAM使用B+树构建索引树时,叶子节点中存储的键值为索引列的值,数据为索引所在行的磁盘地址。
主键索引
MyIsam主键索引
表user的索引存储在索引文件user.MYI
中,数据文件存储在数据文件 user.MYD
中。
简单分析下查询时的磁盘IO情况:
根据主键等值查询数据:
select * from user where id = 28;
-
先在主键树中从根节点开始检索,将根节点加载到内存,比较28<75,走左路。(1次磁盘IO)
-
将左子树节点加载到内存中,比较16<28<47,向下检索。(1次磁盘IO)
-
检索到叶节点,将节点加载到内存中遍历,比较16<28,18<28,28=28。查找到值等于30的索引项。(1次磁盘IO)
-
从索引项中获取磁盘地址,然后到数据文件user.MYD中获取对应整行记录。(1次磁盘IO)
-
将记录返给客户端。
磁盘IO次数:3次索引检索+记录数据检索。
根据主键范围查询数据:
select * from user where id between 28 and 47;
-
先在主键树中从根节点开始检索,将根节点加载到内存,比较28<75,走左路。(1次磁盘IO)
-
将左子树节点加载到内存中,比较16<28<47,向下检索。(1次磁盘IO)
-
检索到叶节点,将节点加载到内存中遍历比较16<28,18<28,28=28<47。查找到值等于28的索引项。
根据磁盘地址从数据文件中获取行记录缓存到结果集中。(1次磁盘IO)
我们的查询语句时范围查找,需要向后遍历底层叶子链表,直至到达最后一个不满足筛选条件。
-
向后遍历底层叶子链表,将下一个节点加载到内存中,遍历比较,28<47=47,根据磁盘地址从数据文件中获取行记录缓存到结果集中。(1次磁盘IO)
-
最后得到两条符合筛选条件,将查询结果集返给客户端。
磁盘IO次数:4次索引检索+记录数据检索。
MyIsam索引范围查询过程
备注:以上分析仅供参考,MyISAM在查询时,会将索引节点缓存在MySQL缓存中,而数据缓存依赖于操作系统自身的缓存,所以并不是每次都是走的磁盘,这里只是为了分析索引的使用过程。
辅助索引
在 MyISAM 中,辅助索引和主键索引的结构是一样的,没有任何区别,叶子节点的数据存储的都是行记录的磁盘地址。只是主键索引的键值是唯一的,而辅助索引的键值可以重复。
查询数据时,由于辅助索引的键值不唯一,可能存在多个拥有相同的记录,所以即使是等值查询,也需要按照范围查询的方式在辅助索引树中检索数据。
4.2 InnoDB索引
主键索引(聚簇索引)
每个InnoDB表都有一个聚簇索引 ,聚簇索引使用B+树构建,叶子节点存储的数据是整行记录。一般情况下,聚簇索引等同于主键索引,当一个表没有创建主键索引时,InnoDB会自动创建一个ROWID字段来构建聚簇索引。InnoDB创建索引的具体规则如下:
在表上定义主键PRIMARY KEY,InnoDB将主键索引用作聚簇索引。
如果表没有定义主键,InnoDB会选择第一个不为NULL的唯一索引列用作聚簇索引。
如果以上两个都没有,InnoDB 会使用一个6 字节长整型的隐式字段 ROWID字段构建聚簇索引。该ROWID字段会在插入新行时自动递增。
除聚簇索引之外的所有索引都称为辅助索引。在中InnoDB,辅助索引中的叶子节点存储的数据是该行的主键值都。在检索时,InnoDB使用此主键值在聚簇索引中搜索行记录。
这里以user_innodb为例,user_innodb的id列为主键,age列为普通索引。
CREATE TABLE `user_innodb` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(20) DEFAULT NULL, `age` int(11) DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE, KEY `idx_age` (`age`) USING BTREE ) ENGINE = InnoDB;
user数据
InnoDB的数据和索引存储在一个文件t_user_innodb.ibd中。InnoDB的数据组织方式,是聚簇索引。
主键索引的叶子节点会存储数据行,辅助索引只会存储主键值。
InnoDB主键索引
等值查询数据:
select * from user_innodb where id = 28;
-
先在主键树中从根节点开始检索,将根节点加载到内存,比较28<75,走左路。(1次磁盘IO)
将左子树节点加载到内存中,比较16<28<47,向下检索。(1次磁盘IO)
检索到叶节点,将节点加载到内存中遍历,比较16<28,18<28,28=28。查找到值等于28的索引项,直接可以获取整行数据。将改记录返回给客户端。(1次磁盘IO)
磁盘IO数量:3次。
辅助索引
除聚簇索引之外的所有索引都称为辅助索引,InnoDB的辅助索引只会存储主键值而非磁盘地址。
以表user_innodb的age列为例,age索引的索引结果如下图。
InnoDB辅助索引
底层叶子节点的按照(age,id)的顺序排序,先按照age列从小到大排序,age列相同时按照id列从小到大排序。
使用辅助索引需要检索两遍索引:首先检索辅助索引获得主键,然后使用主键到主索引中检索获得记录。
画图分析等值查询的情况:
select * from t_user_innodb where age=19;
InnoDB辅助索引查询
根据在辅助索引树中获取的主键id,到主键索引树检索数据的过程称为回表查询。
磁盘IO数:辅助索引3次+获取记录回表3次
组合索引
还是以自己创建的一个表为例:表 abc_innodb,id为主键索引,创建了一个联合索引idx_abc(a,b,c)。
CREATE TABLE `abc_innodb` ( `id` int(11) NOT NULL AUTO_INCREMENT, `a` int(11) DEFAULT NULL, `b` int(11) DEFAULT NULL, `c` varchar(10) DEFAULT NULL, `d` varchar(10) DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE, KEY `idx_abc` (`a`, `b`, `c`) ) ENGINE = InnoDB; select * from abc_innodb order by a, b, c, id;
组合索引的数据结构:
组合索引结构1
组合索引的查询过程:
select * from abc_innodb where a = 13 and b = 16 and c = 4;
组合索引的查询过程
最左匹配原则:
最左前缀匹配原则和联合索引的索引存储结构和检索方式是有关系的。
在组合索引树中,最底层的叶子节点按照第一列a列从左到右递增排列,但是b列和c列是无序的,b列只有在a列值相等的情况下小范围内递增有序,而c列只能在a,b两列相等的情况下小范围内递增有序。
就像上面的查询,B+树会先比较a列来确定下一步应该搜索的方向,往左还是往右。如果a列相同再比较b列。但是如果查询条件没有a列,B+树就不知道第一步应该从哪个节点查起。
可以说创建的idx_abc(a,b,c)索引,相当于创建了(a)、(a,b)(a,b,c)三个索引。、
组合索引的最左前缀匹配原则:使用组合索引查询时,mysql会一直向右匹配直至遇到范围查询(>、<、between、like)就停止匹配。
覆盖索引
覆盖索引并不是说是索引结构,覆盖索引是一种很常用的优化手段。*因为在使用辅助索引的时候,我们只可以拿到主键值,相当于获取数据还需要再根据主键查询主键索引再获取到数据。但是试想下这么一种情况,在上面abc_innodb表中的组合索引查询时,如果我只需要abc字段的,那是不是意味着我们查询到组合索引的叶子节点就可以直接返回了,而不需要回表。这种情况就是*覆盖索引。
五、拓展与总结
5.1 索引下推(ICP)
假设有这么个需求,查询表中“名字第一个字是张,性别男,年龄为10岁的所有记录”。那么,查询语句是这么写的:
mysq> select * from tuser where name like '张 %' and age=10 and ismale=1;
根据前面说的“最左前缀原则”,该语句在搜索索引树的时候,只能匹配到名字第一个字是‘张’的记录(即记录ID3),接下来是怎么处理的呢?当然就是从ID3开始,逐个回表,到主键索引上找出相应的记录,再比对age和ismale这两个字段的值是否符合。
但是!MySQL 5.6引入了索引下推优化,可以在索引遍历过程中,对索引中包含的字段先做判断,过滤掉不符合条件的记录,减少回表字数。下面图1、图2分别展示这两种情况。
图 1 中,在 (name,age) 索引里面我特意去掉了 age 的值,这个过程 InnoDB 并不会去看 age 的值,只是按顺序把“name 第一个字是’张’”的记录一条条取出来回表。因此,需要回表 4 次。
图 2 跟图 1 的区别是,InnoDB 在 (name,age) 索引内部就判断了 age 是否等于 10,对于不等于 10 的记录,直接判断并跳过。在我们的这个例子中,只需要对 ID4、ID5 这两条记录回表取数据判断,就只需要回表 2 次。
5.2 or语句是否走索引
一,要求使用的所有字段,都必须建立索引。(重点)
二,数据量太少,制定执行计划时发现全表扫描比索引查找更快。
三,确保mysql版本5.0以上,且查询优化器开启了index_merge_union=on
, 也就是变量optimizer_switch
里存在index_merge_union
且为on
5.3 MySQL中IS NULL、IS NOT NULL、!=能否走索引?
不知道从什么时候开始,网上流传着这么一个说法:
MySQL的WHERE子句中包含 IS NULL、IS NOT NULL、!= 这些条件时便不能使用索引查询,只能使用全表扫描。
这种说法愈演愈烈,甚至被很多同学奉为真理。咱啥话也不说,举个例子。假如我们有个表s1
,结构如下:
CREATE TABLE s1 ( id INT NOT NULL AUTO_INCREMENT, key1 VARCHAR(100), key2 VARCHAR(100), key3 VARCHAR(100), key_part1 VARCHAR(100), key_part2 VARCHAR(100), key_part3 VARCHAR(100), common_field VARCHAR(100), PRIMARY KEY (id), KEY idx_key1 (key1), KEY idx_key2 (key2), KEY idx_key3 (key3), KEY idx_key_part(key_part1, key_part2, key_part3) ) Engine=InnoDB CHARSET=utf8;
这个表里有10000条记录:
mysql> SELECT COUNT(*) FROM s1;
+----------+
| COUNT(*) |
+----------+
| 10000 |
+----------+
1 row in set (0.00 sec)
下边我们直接贴几个图:
上边几个查询语句的WHERE
子句中用了IS NULL
、IS NOT NULL
、!=
NULL值是怎么在记录中存储的
在MySQL中,每一条记录都有它固定的格式,我们以InnoDB
存储引擎的Compact
行格式为例,来看一下NULL
值是怎样存储的。在Compact
行格式下,一条记录是由下边这几个部分构成的:
为了故事的顺利发展,我们新建一个称之为record_format_demo
的表:
CREATE TABLE record_format_demo ( c1 VARCHAR(10), c2 VARCHAR(10) NOT NULL, c3 CHAR(10), c4 VARCHAR(10) ) CHARSET=ascii ROW_FORMAT=COMPACT;
因为我们的重点是NULL
值是如何存储在记录中的,所以重点唠叨一下行格式的NULL值列表
部分,其他的部分可以到小册中查看。存储NULL
值的过程如下:
-
首先统计表中允许存储
NULL
的列有哪些。我们前边说过,主键列、被
NOT NULL
修饰的列都是不可以存储NULL
值的,所以在统计的时候不会把这些列算进去。比方说表record_format_demo
的3个列c1
、c3
、c4
都是允许存储NULL
值的,而c2
列是被NOT NULL
修饰,不允许存储NULL
值。 -
如果表中没有允许存储
NULL
的列,则NULL值列表
也不存在了,否则将每个允许存储NULL
的列对应一个二进制位,二进制位按照列的顺序逆序排列,二进制位表示的意义如下:-
二进制位的值为
1
时,代表该列的值为NULL
。 -
二进制位的值为
0
时,代表该列的值不为NULL
。
-
再一次强调,二进制位按照列的顺序逆序排列,所以第一个列
c1
和最后一个二进制位对应。 -
设计
InnoDB
的大叔规定NULL值列表
必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则在字节的高位补0。表
record_format_demo
只有3个值允许为NULL
-
-
设计
InnoDB
的大叔规定NULL值列表
必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则在字节的高位补0。表
record_format_demo
只有3个值允许为NULL
的列,对应3个二进制位,不足一个字节,所以在字节的高位补0,效果就是这样:以此类推,如果一个表中有9个允许为
NULL
,那这个记录的NULL值列表
部分就需要2个字节来表示了。
假设我们现在向record_format_demo
表中插入一条记录:
INSERT INTO record_format_demo(c1, c2, c3, c4) VALUES('eeee', 'fff', NULL, NULL);
这条记录的c1
、c3
、c4
这3个列中c3
和c4
的值都为NULL
,所以这3个列对应的二进制位的情况就是:
所以这记录的NULL值列表
用十六进制表示就是:0x06
。
键值为NULL的记录是怎么在B+树中存放的
对于InnoDB存储引擎来说,记录都是存储在页面中的(一个页面默认是16KB大小),这些页面可以作为B+
树的节点而组成一个索引,类似这种样子(只是用下边的图举个B+树的例子而已,跟我们上边列举的表没关系):
聚簇索引和二级索引都对应着像上图一样的B+
树(也就是说有多少个索引就有多少棵对应的B+
树),不过:
-
对于聚簇索引索引来说,页面中的记录是按照主键值进行排序的;而对于二级索引来说,页面中的记录是按照给定的索引列的值进行排序的。
-
对于聚簇索引来说,B+树每一层节点(页面)都是按照页中记录的主键值大小进行排序的;而对于二级索引来说,B+树每一层节点(页面)都是按照页中记录的给定的索引列的值进行排序的。
-
对于聚簇索引来说,B+树叶子节点对应的页面中存储的是完整的用户记录(就是一条记录中包含我们定义的所有列值,还包含一些InnoDB自己添加的一些隐藏列);而对于二级索引来说,B+树叶子节点对应的页面中存储的只是
索引列的值 + 主键值
。
按规定,一条记录的主键值不允许存储NULL
值,所以下边语句中的WHERE子句结果肯定为FALSE
:
SELECT * FROM tbl_name WHERE primary_key IS NULL;
像这样的语句优化器自己就能判定出WHERE子句必定为NULL,所以压根儿不会去执行它,不信我们看(Extra信息提示WHERE子句压根儿不成立):
对于二级索引来说,索引列的值可能为NULL
。那对于索引列值为NULL
的二级索引记录来说,它们被放在B+
树的哪里呢?答案是:放在B+树的最左边。比方说我们有如下查询语句:
SELECT * FROM s1 WHERE key1 IS NULL;
那它的查询示意图就如下所示:
从图中可以看出,对于s1
表的二级索引idx_key1
来说,值为NULL
的二级索引记录都被放在了B+
树的最左边,这是因为设计InnoDB
的大叔有这样的规定:
We define the SQL null to be the smallest possible value of a field.
也就是说他们把SQL中的NULL
值认为是列中最小的值。
在通过二级索引idx_key1
对应的B+
树快速定位到叶子节点中符合条件的最左边的那条记录后,也就是本例中id
值为521
的那条记录之后,就可以顺着每条记录都有的next_record
属性沿着由记录组成的单向链表去获取记录了,直到某条记录的key1
列不为NULL。
小贴士: 通过B+树快速定位到叶子节点的记录的过程是靠一个所谓的页目录(Page Directory)做到的,不过这不是本文的重点,大家可以到小册中翻看,都有详细解释。
使不使用索引的依据到底是什么?
那既然IS NULL
、IS NOT NULL
、!=
这些条件都可能使用到索引,那到底什么时候索引,什么时候采用全表扫描呢?
答案很简单:成本。当然,关于如何定量的计算使用某个索引执行查询的成本比较复杂,我们在小册中花了很大的篇幅来唠叨了。不过因为篇幅有限,我们在这里只准备定性的分析一下。对于使用二级索引进行查询来说,成本组成主要有两个方面:
-
读取二级索引记录的成本
-
将二级索引记录执行回表操作,也就是到聚簇索引中找到完整的用户记录的操作所付出的成本。
很显然,要扫描的二级索引记录条数越多,那么需要执行的回表操作的次数也就越多,达到了某个比例时,使用二级索引执行查询的成本也就超过了全表扫描的成本(举一个极端的例子,比方说要扫描的全部的二级索引记录,那就要对每条记录执行一遍回表操作,自然不如直接扫描聚簇索引来的快)。
所以MySQL优化器在真正执行查询之前,对于每个可能使用到的索引来说,都会预先计算一下需要扫描的二级索引记录的数量,比方说对于下边这个查询:
SELECT * FROM s1 WHERE key1 IS NULL;
优化器会分析出此查询只需要查找key1
值为NULL
的记录,然后访问一下二级索引idx_key1
,看一下值为NULL
的记录有多少(如果符合条件的二级索引记录数量较少,那么统计结果是精确的,如果太多的话,会采用一定的手段计算一个模糊的值,当然算法也比较麻烦,我们就不展开说了,小册里有说),这种在查询真正执行前优化器就率先访问索引来计算需要扫描的索引记录数量的方式称之为index dive
。当然,对于某些查询,比方说WHERE子句中有IN条件,并且IN条件中包含许多参数的话,比方说这样:
SELECT * FROM s1 WHERE key1 IN ('a', 'b', 'c', ... , 'zzzzzzz');
这样的话需要统计的key1
值所在的区间就太多了,这样就不能采用index dive
的方式去真正的访问二级索引idx_key1
,而是需要采用之前在背地里产生的一些统计数据去估算匹配的二级索引记录有多少条(很显然根据统计数据去估算记录条数比index dive
的方式精确性差了很多)。
反正不论采用index dive
还是依据统计数据估算,最终要得到一个需要扫描的二级索引记录条数,如果这个条数占整个记录条数的比例特别大,那么就趋向于使用全表扫描执行查询,否则趋向于使用这个索引执行查询。
理解了这个也就好理解为什么在WHERE子句中出现IS NULL
、IS NOT NULL
、!=
这些条件仍然可以使用索引,本质上都是优化器去计算一下对应的二级索引数量占所有记录数量的比值而已。
5.4 为什么用索引会快?
先从 MySQL 的基本存储结构说起
MySQL的基本存储结构是页(记录都存在页里边):
-
各个数据页可以组成一个双向链表
-
每个数据页中的记录又可以组成一个单向链表
-
每个数据页都会为存储在它里边儿的记录生成一个页目录,在通过主键查找某条记录的时候可以在页目录中使用二分法快速定位到对应的槽,然后再遍历该槽对应分组中的记录即可快速找到指定的记录
-
以其他列(非主键)作为搜索条件:只能从最小记录开始依次遍历单链表中的每条记录。
-
所以说,如果我们写select * from user where indexname = 'xxx'这样没有进行任何优化的sql语句,默认会这样做:
-
定位到记录所在的页:需要遍历双向链表,找到所在的页
-
从所在的页内中查找相应的记录:由于不是根据主键查询,只能遍历所在页的单链表了
很明显,在数据量很大的情况下这样查找会很慢!这样的时间复杂度为O(n)。
使用索引之后
索引做了些什么可以让我们查询加快速度呢?其实就是将无序的数据变成有序(相对):
要找到id为8的记录简要步骤:
很明显的是:没有用索引我们是需要遍历双向链表来定位对应的页,现在通过 “目录” 就可以很快地定位到对应的页上了!(二分查找,时间复杂度近似为O(logn))
其实底层结构就是B+树,B+树作为树的一种实现,能够让我们很快地查找出对应的记录。
看到这里,你是不是对于自己的sql语句里面的索引的有了更多优化想法呢。
比如:
避免回表
使用索引覆盖,举个例子:现有User表(id(PK),name(key),sex,address,hobby...)
如果在一个场景下,select id,name,sex from user where name ='zhangsan';
这个语句在业务上频繁使用到,而user表的其他字段使用频率远低于它,在这种情况下,如果我们在建立 name 字段的索引的时候,不是使用单一索引,而是使用联合索引(name,sex)这样的话再执行这个查询语句是不是根据辅助索引查询到的结果就可以获取当前语句的完整数据。
这样就可以有效地避免了回表再获取sex的数据。
这里就是一个典型的使用覆盖索引的优化策略减少回表的情况。
联合索引的使用
联合索引,在建立索引的时候,尽量在多个单列索引上判断下是否可以使用联合索引。联合索引的使用不仅可以节省空间,还可以更容易的使用到索引覆盖。
试想一下,索引的字段越多,是不是更容易满足查询需要返回的数据呢。比如联合索引(a_b_c),是不是等于有了索引:a,a_b,a_b_c三个索引,这样是不是节省了空间,当然节省的空间并不是三倍于(a,a_b,a_b_c)三个索引,因为索引树的数据没变,但是索引data字段的数据确实真实的节省了。
联合索引的创建原则,在创建联合索引的时候因该把频繁使用的列、区分度高的列放在前面,频繁使用代表索引利用率高,区分度高代表筛选粒度大,这些都是在索引创建的需要考虑到的优化场景,也可以在常需要作为查询返回的字段上增加到联合索引中,如果在联合索引上增加一个字段而使用到了覆盖索引,那我建议这种情况下使用联合索引。
联合索引的使用
-
考虑当前是否已经存在多个可以合并的单列索引,如果有,那么将当前多个单列索引创建为一个联合索引。
-