InnoDB 索引深入剖析
InnoDB页
将数据划分为若干个页(page),以页作为磁盘和内存之间交互的基本单位,InnoDB中页的大小一般为 16KB。也就是在一般情况下,一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。
InnoDB行格式
我们平时是以记录为单位来向表中插入数据的,这些记录在磁盘上的存放方式也被称为行格式或者记录格式。
在行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表,各变长字段数据占用的字节数按照列的顺序逆序存放 。变长字段长度列表中只存储值为 非NULL 的列内容占用的长度,值为 NULL 的列的长度是不储存的 。并不是所有记录都有这个变长字段长度列表部分,比方说表中所有的列都不是变长的数据类型的话,这一部分就不需要有。
【2】NULL值列表:表中的某些列可能存储 NULL值,如果把这些 NULL值都放到记录的真实数据中存储会很占地方,所以Compact行格式把这些值为 NULL的列统一管理起来,存储到 NULL值列表中,它的处理过程是这样的:
① 首先统计表中允许存储 NULL的列有哪些;
② 如果表中没有允许存储 NULL 的列,则NULL值列表也不存在了;
【3】记录头信息
1 #先创建一个表: 2 mysql> CREATE TABLE page_demo( 3 -> c1 INT, 4 -> c2 INT, 5 -> c3 VARCHAR(10000), 6 -> PRIMARY KEY (c1) 7 -> ) CHARSET=ascii ROW_FORMAT=Compact; 8 Query OK, 0 rows affected (0.03 sec)
主键的生成策略
优先使用用户自定义主键作为主键,如果用户没有定义主键,则选取一个 Unique键作为主键,如果表中连 Unique键都没有定义的话,则 InnoDB会为表默认添加一个名为 row_id的隐藏列作为主键。
这个新创建的 page_demo表有3个列,其中 c1和 c2列是用来存储整数的,c3列是用来存储字符串的。需要注意的是,我们把 c1 列指定为主键,所以在具体的行格式中 InnoDB就没必要为我们去创建那个所谓的 row_id 隐藏列了。简化后的行格式示意图就是这样:
InnoDB数据页结构
数据页代表的这块 16KB大小的存储空间可以被划分为多个部分,不同部分有不同的功能,各个部分如图所示:
每当我们插入一条记录,都会从Free Space 部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到 User Records部分,当 Free Space部分的空间全部被 User Records部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了,这个过程的图示如下:
1 #先创建一个表: 2 mysql> CREATE TABLE page_demo( 3 -> c1 INT, 4 -> c2 INT, 5 -> c3 VARCHAR(10000), 6 -> PRIMARY KEY (c1) 7 -> ) CHARSET=ascii ROW_FORMAT=Compact; 8 Query OK, 0 rows affected (0.03 sec) 9 10 #向page_demo表中插入几条记录: 11 mysql> INSERT INTO page_demo VALUES(1, 100, 'aaaa'), 12 (2, 200, 'bbbb'), 13 (3, 300, 'cccc'), 14 (4, 400, 'dddd'); 15 Query OK, 4 rows affected (0.00 sec) 16 Records: 4 Duplicates: 0 Warnings: 0
那么,这些记录的示意图就是:
mysql> DELETE FROM page_demo WHERE c1 = 2; Query OK, 1 row affected (0.02 sec)
mysql> INSERT INTO page_demo VALUES(2, 200, 'bbbb'); Query OK, 1 row affected (0.00 sec)
Page Directory(页目录)
现在我们了解了记录在页中按照主键值由小到大顺序串联成一个单链表,那如果我们想根据主键值查找页中的某条记录该咋办呢?设计 InnoDB的大叔们为我们的记录也制作了一个类似的目录,他们的制作过程是这样的:
【1】将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。
【2】每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的 n_owned属性表示该记录拥有多少条记录,也就是该组内共有几条记录。
【3】将每个组的最后一条记录的地址偏移量单独提取出来,用作查找。
注意:这个页目录是为主键服务的。
我们再添加 12条记录看看效果:
1 mysql> INSERT INTO page_demo VALUES(5, 500, 'eeee'), (6, 600, 'ffff'), (7, 700, 'gggg'), (8, 800, 'hhhh'), (9, 900, 'iiii'), (10, 1000, 'jjjj'), (11, 1100, 'kkkk'), (12, 1200, 'llll'), (13, 1300, 'mmmm'), (14, 1400, 'nnnn'), (15, 1500, 'oooo'), (16, 1600, 'pppp'); 2 Query OK, 12 rows affected (0.00 sec) 3 Records: 12 Duplicates: 0 Warnings: 0
因为把16条记录的全部信息都画在一张图里太占地方,让人眼花缭乱的,所以只保留了用户记录头信息中的 n_owned和next_record属性。因为各个槽代表的记录的主键值都是从小到大排序的,所以我们可以使用所谓的二分法来进行快速查找。所以在一个数据页中查找指定主键值的记录的过程分为两步:
【1】通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最大的那条记录。
【2】通过记录的 next_record属性遍历该槽所在的组中的各个记录。
比方说我们想找主键值为6的记录,过程是这样的:计算中间槽的位置:(0+4)/2=2,所以查看槽2对应记录的主键值为8,又因为8 > 6,所以设置high=2,low保持不变。重新计算中间槽的位置:(0+2)/2=1,所以查看槽1对应的主键值为4,又因为4 < 6,所以设置low=1,high保持不变。因为high – low的值为1,所以确定主键值为 6的记录在槽2对应的组中。我们可以很轻易的拿到槽1对应的记录(主键值为4),该条记录的下一条记录就是槽2中主键值最小的记录,该记录的主键值为5。所以我们可以从这条主键值为5的记录出发,遍历槽2中的各条记录。注意:若查到数据在槽2的分组中,由于槽2是指向最后一个记录,所以需要向上找一个槽位,定位到上一个槽位最后一行,然后再向下找。
File Header(文件头部)
File Header针对各种类型的页都通用,也就是说不同类型的页都会以 File Header作为第一个组成部分,它描述了一些针对各种页都通用的一些信息,比方说这个页的编号是多少,它的上一个页、下一个页是谁等。
FIL_PAGE_OFFSET:每一个页都有一个单独的页号,就跟你的身份证号码一样,InnoDB通过页号来可以唯一定位一个页。
FIL_PAGE_PREV和FIL_PAGE_NEXT:FIL_PAGE_PREV 和 FIL_PAGE_NEXT就分别代表本页的上一个和下一个页的页号。这样通过建立一个双向链表把许许多多的页就都串联起来了
索引的代价
【空间上的代价】:每建立一个索引都要为它建立一棵 B+树,每一棵 B+树的每一个节点都是一个数据页,一个页默认会占用16KB的存储空间。
【时间上的代价】:每次对表中的数据进行增、删、改操作时,都需要去修改各个 B+树索引。
B+树每层节点都是按照索引列的值从小到大的顺序排序而组成了双向链表。不论是叶子节点中的记录,还是内节点中的记录(也就是不论是用户记录还是目录项记录)都是按照索引列的值从小到大的顺序而形成了一个单向链表。而增、删、改操作可能会对节点和记录的排序造成破坏,所以存储引擎需要额外的时间进行一些记录移位,页面分裂、页面回收啥的操作来维护好节点和记录的排序。
B+树索引适用的条件
首先,B+树索引并不是万能的,并不是所有的查询语句都能用到我们建立的索引。下边介绍几个我们可能使用 B+树索引来进行查询的情况。
1 CREATE TABLE person_info( 2 id INT NOT NULL auto_increment, 3 name VARCHAR(100) NOT NULL, 4 birthday DATE NOT NULL, 5 phone_number CHAR(11) NOT NULL, 6 country varchar(100) NOT NULL, 7 PRIMARY KEY (id), 8 KEY idx_name_birthday_phone_number (name, birthday, phone_number) 9 );
person_info表会为聚簇索引和 idx_name_birthday_phone_number索引建立 2棵B+树。在记录结构中只保留 name、birthday、phone_number、id这四个列的真实数据值,所以示意图就长这样:
全值匹配
如果我们的搜索条件中的列和索引列一致的话,这种情况就称为全值匹配,比方说下边这个查找语句:
SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday = '1990-09-27' AND phone_number = '15123983239';
【1】因为 B+树的数据页和记录先是按照 name列的值进行排序的,所以先可以很快定位 name列的值是 Ashburn的记录位置。
【2】在 name列相同的记录里又是按照 birthday列的值进行排序的,所以在 name列的值是 Ashburn的记录里又可以快速定位birthday列的值是 ’1990-09-27’的记录。
【3】如果很不幸,name和 birthday列的值都是相同的,那记录是按照 phone_number列的值排序的,所以联合索引中的三个列都可能被用到。
调换name、birthday、phone_number这几个搜索列的顺序对查询的执行过程是没有影响的。
匹配左边的列
SELECT * FROM person_info WHERE name = 'Ashburn';
或者包含多个左边的列也行:
SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday = '1990-09-27';
只有左边的列才能匹配,下边的语句就用不到这个B+树索引:
SELECT * FROM person_info WHERE birthday = '1990-09-27';
因为 B+树的数据页和记录先是按照 name列的值排序的,在 name列的值相同的情况下才使用 birthday列进行排序,也就是说name列的值不同的记录中 birthday的值可能是无序的。如果我们想使用联合索引中尽可能多的列,搜索条件中的各个列必须是联合索引中从最左边连续的列。
匹配列前缀
person_info表上建立的联合索引 idx_name_birthday_phone_number会先用 name列的值进行排序。也就是说这些字符串的前n个字符,也就是前缀都是排好序的,所以对于字符串类型的索引列来说,我们只匹配它的前缀也是可以快速定位记录的,比方说我们想查询名字以’As’开头的记录,那就可以这么写查询语句:
SELECT * FROM person_info WHERE name LIKE 'As%';
由于所有记录都是由链表连起来的(记录之间用单链表,数据页之间用双链表),所以他们之间的记录都可以很容易的取出来。找到这些记录的主键值,再到聚簇索引中回表查找完整的记录。如果对多个列同时进行范围查找的话,只有对索引最左边的那个列进行范围查找的时候才能用到 B+树索引,比方说这样:
SELECT * FROM person_info WHERE name > ‘Asa’ AND name < ‘Barlow’ AND birthday > '1980-01-01';
上边这个查询可以分成两个部分:
【1】通过条件name > ‘Asa’ AND name < ‘Barlow’来对name进行范围。
【2】对这些name值不同的记录继续通过birthday > ‘1980-01-01’条件继续过滤。
对于联合索引 idx_name_birthday_phone_number来说,只能用到 name列的部分,而用不到 birthday列的部分,因为只有name值相同的情况下才能用 birthday列的值进行排序,而这个查询中通过 name进行范围查找的记录中可能并不是按照 birthday列进行排序的,所以在搜索条件中继续以 birthday列进行查找时是用不到这个 B+树索引的。
精确匹配某一列并范围匹配另外一列
虽然对多个列都进行范围查找时只能用到最左边那个索引列,但是如果左边的列是精确查找,则右边的列可以进行范围查找,比方说这样:
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+树索引了,只能遍历上一步查询得到的记录。
用于排序
有的时候可能查询的结果集太大以至于不能在内存中进行排序的话,还可能暂时借助磁盘的空间来存放中间结果,排序操作完成后再把排好序的结果集返回到客户端。在MySQL中,把这种在内存中或者磁盘上进行排序的方式统称为文件排序(英文名:filesort)。
但是如果ORDER BY子句里使用到了我们的索引列,就有可能省去在内存或文件中排序的步骤,比如下边这个简单的查询语句:
SELECT * FROM person_info ORDER BY name, birthday, phone_number LIMIT 10;
因为这个 B+树索引本身就是按照上述规则排好序的,所以直接从索引中提取数据,然后进行回表操作取出该索引中不包含的列就好了。
使用联合索引进行排序注意事项
ORDER BY的子句后边的列的顺序也必须按照索引列的顺序给出,如果给出 ORDER BY phone_number, birthday, name的顺序,那也是用不了 B+树索引。
不可以使用索引进行排序的几种情况
【1】ASC、DESC混用:对于使用联合索引进行排序的场景,我们要求各个排序列的排序顺序是一致的,也就是要么各个列都是 ASC规则排序,要么都是 DESC规则排序。
【2】WHERE 子句中出现非排序使用到的索引列:如果 WHERE子句中出现了非排序使用到的索引列,那么排序依然是使用不到索引的,比方说这样:
SELECT * FROM person_info WHERE country = ‘China’ ORDER BY name LIMIT 10;
这个查询只能先把符合搜索条件 country = ‘China’的记录提取出来后再进行排序,是使用不到索引。
【3】排序列包含非同一个索引的列:有时候用来排序的多个列不是一个索引里的,这种情况也不能使用索引进行排序,比方说:
SELECT * FROM person_info ORDER BY name, country LIMIT 10;
【4】排序列使用了复杂的表达式:要想使用索引进行排序操作,必须保证索引列是以单独列的形式出现,而不是修饰过的形式,比方说这样:
SELECT * FROM person_info ORDER BY UPPER(name) LIMIT 10;