学习《MySQL是怎样运行的》,感谢作者!
InnoDB存储引擎的B+树索引:结论 #
- 每个索引对应一颗B+树。B+树有好多层,最下边一层是叶子节点,其余是内节点。所有用户记录都存在B+树的叶子节点,所有目录项记录都存在内节点
- InnoDB 存储引擎会自动为主键建立聚簇索引(如果没有显式指定主键或者没有声明不允许存储NULL的UNIQUE 键,它会自动添加主键) , 聚簇索引的叶子节点包含完整的用户记录
- 我们可以为感兴趣的列建立二级索引,二级索引的叶子节点包含的用户记录由索引列 和主键组成。如果想通过二级索引查找完整的用户记录,需要执行回表操作, 也就是在通过二级索引找到主键值之后,再到聚簇索引中查找完整的用户记录
- B+ 树中的每层节点都按照索引列的值从小到大的顺序排序组成了双向链表,而且每个页内的记录(无论是用户记录还是目录项记录)都按照索引列的值从小到大的顺序形成了一个单向链表。如果是联合索引, 则页面和记录 先按照索引列中前面的列的值排序:如果该列的值相同,再按照索引列中后面的列的值排序。比如, 我们对列c2 和c3建立了联合索引 idx_c2_c3(c2, c3),那么该索引中的页面和记录就先按照c2 列的值进行排序;如果c2 列的值相同, 再按照c3 列的值排序
- 通过索引查找记录时,是从B+ 树的根节点开始一层一层向下搜索的。由于每个页面(无论是内节点页面还是叶子节点页面〉中的记录都划分成了若干个组, 每个组中索引列值最大的记录在页内的偏移量会被当作槽依次存放在页目录中(当然, 规定Supremum 记录比任何用户记录都大) ,因此可以在页目录中通过二分法快速定位到索引列等于某个值的记录
如果大家在阅读上述结论时哪怕有点疑惑, 那么下面的内容就不适合你,请回过头去反复阅读前面的章节
B+树索引示意图的简化 #
#创建新表
mysql> CREATE TABLE single_table(
id INT NOT NULL AUTO_INCREMENT,
key1 VARCHAR(100),
key2 INT,
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),
UNIQUE KEY uk_key2(key2),
KEY idx_key3(key3),
KEY idx_key_part(key_part1,key_part2,key_part3)
) Engine=InnoDB CHARSET = utf8;
如上,建立了1个聚簇索引,4个二级索引
- 为id列建立的聚簇索引
- 为key1列建立的idx_key1二级索引
- 为key2列建立的uk_key2二级索引,而且该索引是唯一二级索引
- 为key3列建立的idx_key3二级索引
- 为key_part1、key_part2、key_part3列建立的idx_key_part二级索引,是一个联合索引
接下来为这个表插入10,000行记录
除了id,其余的列取随机值:该表后面会频繁用到
需要用程序写,这里暂时跳过(不会…,书上也没写)
回顾:B+树包括内节点和叶子节点,以及各个节点中的记录。B+树其实是一个矮矮的大胖子,能够利用B+树快速地定位记录,下面简化一下B+树的示意图:
- 忽略页结构,直接把所有叶子节点中的记录放一起
- 为了方便,把聚簇索引叶子节点的记录称为聚簇索引记录,把二级索引叶子节点称为二级索引记录
回顾一下:
核心要点:把下一层每一页的最小值,放到上一级的目录项记录,以key值+页号这样的组合存在
精简:
如上,聚簇索引记录是按照主键值由小到大的顺序排列的
如下图,通过B+树定位到id值为1438的记录
二级索引idx_key1对应的B+树中保留了叶子结点的记录。以key1排序,如果key1相同,则按照id列排序
为了方便,把聚簇索引叶子节点的记录称为聚簇索引记录,把二级索引叶子节点称为二级索引记录
如果要查找key1值等于某个值的二级索引记录,通过idx_key1对应的B+树,可以很容易定位到第一条key1列的值等于某个值的二级索引记录,然后沿着单向链表向后扫描即可。
索引的代价 #
空间上的代价 #
每建立一个索引,都要为他建立一颗B+树。每一颗B+树的每一个节点都是一个数据页(一个数据页默认占用16KB),而一颗很大的B+树由许多数据页组成,这将占用很大的片存储空间
时间上的代价 #
- 每当对表中数据进行增上改查时,都要修改各个B+树索引
- 执行查询语句前,都要生成一个执行计划。一般情况下,一条查询语句在执行过程中最多用到一个二级索引(有例外,10章),在生成执行计划时需要计算使用不同索引执行查询时所需要的成本,最后选取成本最低的那个索引执行查询(12章:如何计算查询成本)==> 索引太多导致分析时间过长
总结 #
索引越多,存储空间越多,增删改记录或者生成执行计划时性能越差
为了建立又好又少的索引,得先了解索引在查询执行期间到底是如何发挥作用的
应用B+树索引 #
对于某个查询来说,最简单粗暴的执行方案就是扫描表中的所有记录。判断每一条记录是否符合搜索条件。如果符合,就将其发送到客户端,否则就跳过该记录。这种执行方案也称为全表扫描。
对于使用 InnoDB 存储引擎的表来说,全表扫描意味着从聚簇索引第一个叶子节点的第一条记录开始,沿着记录所在的单向链表向后扫描 直到最后一个叶子节点的最后一条记录(叶子节点:页,16KB;即页内最后一条)。虽然全表扫描是一种很笨的执行方案,但却是一种万能的执行方案,所有的查询都可以使用这种方案来执行。
扫描区间和边界条件 #
可以利用B+树查找索引值等于某个值的记录=>减少需要扫描的记录数量。由于*B+树叶子节点中的记录是按照索引列值由小到大的顺序排序的,所以只扫描某个区间或者某些区间中的记录也可以明显减少需要扫描的记录数量。
简单例子 #
例子1(聚簇索引) #
例子:SELECT * FROM single_table WHERE id>=2 AND id <=100
这个语句其实是要找id值在**[2,100]区间中的所有聚簇索引**记录。
- 可以通过聚簇索引对应的B+树快速地定位到id值为2的那条聚簇索引记录,然后沿着记录所在的单向链表向后扫描,直到某条聚簇索引记录的id值不在[2,100]区间中为止(即id不再符合id<=100条件)
- 与扫描全部的聚簇索引记录相比,扫描id 值在**[2,100]** 区间中的记录已经很大程度地减少了需要扫描的记录数量, 所以提升了查询效率。简便起见,我们把这个例子中待扫描记录的id 值所在的区间称为扫描区间,把形成这个扫描区间的搜索条件(也就是id >= 2AND > id <= 100 ) 称为形成这个扫描区间的边界条件.
对于全表扫描来说,相当于扫描id在**(-∞,+∞)** 区间中的记录,也就是说全表扫描对应的扫描区间是**(-∞,+∞)**
例子2(二级索引) #
SELECT * FROM single_table WHERE key2 IN (1438,6328 ) OR (key2 >=38 AND key2 <=79)
可以直接使用全表扫描的方式执行该查询。
但是我们发现该查询的搜索条件涉及key2列,而我们又正好为key2列建立了uk_key2索引。如果使用uk_key2索引执行这个查询,则相当于从下面的3个扫描区间中获取二级索引记录:
- [1438,1438] :对应的边界条件就是key2 IN (1438)
- [6328,6328]:对应的边界条件就是key2 IN (6328)
- [38,79]:对应的边界条件就是key2 >= 38 AND key2 <= 79
这些扫描区间对应到数轴上时,如图
方便起见,我们把像[1438,1438]、[6328, 6328] 这样只包含一个值的扫描区间称为单点扫描区间, 把[38, 79] 这样包含多个值的扫描区间称为范围扫描区间。另外,由于我们的查询列表是 * ,也就是需要读取完整的用户记录,所以从上述扫描区间中每获取一条二级索引记录, 就需要根据该二级索引记录id列的值执行回表操作,也就是到聚簇索引中找到相应的聚簇索引记录。
- 其实我们不仅仅可以使用uk_key2 执行上述查询, 还可以使用idx_key1、idx_key3 、idx_key_part 执行上述查询。以idx_key_1 为例,很显然无法通过搜索条件形成合适的扫描区间来减少需要扫描的idx_key1 二级索引记录的数量,只能扫描idx_keyl 的全部二级索引记录。针对获取到的每一条二级索引记录,都需要执行回表操作来获取完整的用户记录.。我们也可以说,使用idx_key1 执行查询时对应的扫描区间就是**(-∞,+∞)**
- 这样虽然行得通,但我们图啥呢,最简单粗暴的全表扫描方式已经需要扫描全部的聚簇索引记录, 这里除了需要访问全部的聚簇索引记录,还要扫描全部的idx_key1二级索 引记录,这不是费力不讨好么。可见, 在这个过程中并没有减少需要扫描的记录数量,效 率反而比全表扫描差。所以如果想使用某个索引来执行查询,但是又无法通过搜索条件 形成合适的扫描区间来减少需要扫描的记录数量时, 则不考虑使用这个索引执行查询
例3 不是索引的搜索条件都可以成为边界条件 #
SELECT * FROM single_table WHERE key1 < 'a' AND key3 > 'z' AND common_field = 'abc'
- 如果使用idx_key1 执行查询,那么相应的扫描区间就是(-∞,‘a’),形成该扫描区间的边界条件就是key1 < ‘a’。而 key3 > ‘z’ AND common_field = ‘abc’就是普通的搜索条件,这些普通的搜索条件需要在获取到idx_key1的二级索引记录后,再执行回表操作,在获取到完整的用户记录后才能去判断它们是否成立
- 而如果使用idx_key3 执行查询,那么相应的扫描区间就是’z’,形成该扫描区间的边界条件就是key3>‘z’。而key1<‘a’ AND common_field=‘abc’就是普通的搜索条件,这些普通的搜索条件需要在获取到idx_key3的二级索引记录后,再执行回表操作,在获取到完整的用户记录后才能去判断它们是否成立
总结 #
在使用某个索引执行查询时,关键的问题就是通过搜索条件找出合适的扫描区间,然后再到对应的B+ 树中扫描索引列值在这些扫描区间的记录。对于每个扫描区间来说,仅需要通过B+ 树定位到该扫描区间中的第一条记录,然后就可以沿着记录所在的单向链表向后扫描,直到某条记录不符合形成该扫描区间的边界条件为止。其实对于B+ 树索引来说,只要索引列和常数使用**=、<=>、lN、NOT IN、IS NULL、IS NOT NULL、> 、<、=、<=、BETWEEN 、! = (也可以写成< >)或者LIKE 操作符连接起来,就可以产生所谓的扫描区间**。不过有下面几点需要注意:
lN操作符的语义与若干个等值匹配操作符( =)之间用OR 连接起来的语义是一样的,都会产生多个单点扫描区间。比如下面这两个语句的语义效果是一样的:
SELECT * FROM single_table WHERE key2 IN (1438,6328); #与上面的语义效果一样 SELECT * FROM single_table WHERE key2 = 1438 OR key2 = 6328
!= 产生的扫描区间比较有趣,如:
SELECT * FROM single_table key1 != 'a';
此时idx_key1执行查询时对应的扫描区间就是(-∞,‘a’) 和(‘a’,+∞)
LIKE操作符比较特殊,只有在匹配完整的字符串或者匹配字符串前缀时才产生合适的扫描区间
比较字符串的大小,其实就相当于一次比较每个字符的大小。字符串的比较过程如下所示:- 先比较字符串的第一个字符:第一个字符小的那个字符串就比较小
- 如果两个字符串的第一个字符相同,再比较第二个字符;第二个字符比较小的那个字符串就比较小
- 如果两个字符串的前两个字符都相同,那么就接着比较第三个字符:依此类推
对于某个索引列来说,字符串前缀相同的记录在由记录组成的单向链表中肯定是相邻的。
比如我们有一个搜索条件是key1 LIKE ‘a%’。 对于二级索引 idx_key1 来说,所有字符串前缀为’a’的二级索引记录肯定是相邻的。这也就意味着我们只要定位 key1 值的字符串前缀为’a’ 的第一条记录,就可以沿着记录所在的单向链表向后扫描, 直到某条二级索引记录的字符串前缀不为’a’ 为止,如图7-7 所示。很显然 key1 LIKE ‘a%’ 形成的扫描区间相当于**[‘a’,‘b’)**
稍复杂例子 #
日常工作中,一个查询语句中的WHERE子句可能有多个小的搜索条件,这些搜索条件使用AND 或者OR 操作符连接起来。虽然大家都知道这两个操作符的作用,但这里还是要再强调一遍:
- cond1 AND cond2 只有当cond1和cond2都为TRUE 时,整个表达式才为TRUE
- cond1 OR cond2 , 只要cond1 或者cond2 中有一个为TRUE, 整个表达式就为TRUE
在我们执行一个查询语句时,首先需要找出所有可用的索引以及使用它们时对应的扫描区间。下面我们来看一下怎么从包含若干个AND 或OR 的复杂搜索条件中提取出正确的扫描区间:
所有搜索条件都可以生成合适的扫描区间的情况 #
AND结合 #
SELECT * FROM single_table WHERE key2 > 100 AND key2 > 200;
其中,每个小的搜索条件都可以生成一个合适的扫描区间来减少需要扫描的记录数量,最终的扫描区间就是对这两个小的搜索条件形成的扫描区间取交集后的结果,取交集的过程:
上面查询语句使用uk_key2索引执行查询时对应的扫描区间就是**(200,+∞),形成该扫描区间的边界条件就是key2 > 200**
OR结合 #
使用OR操作符将多个搜索条件连接在一起:
SELECT * FROM single_table WHERE key2 > 100 OR key2 > 200
OR意味着需要取各个扫描区间的并集,取并集的过程如图所示:
即,上面的查询语句在使用uk_key2索引执行查询时,对应的扫描区间就是**(100,+∞),形成扫描区间的边界条件就是key2 > 100**
有的搜索条件不能生成合适的扫描区间的情况 #
AND情况 #
有的搜索条件不能生成合适的扫描区间来减少需要扫描的记录数量
SELECT * FROM single_table WHERE key2 > 100 AND common_field = 'abc'
分析:使用uk_key2执行查询时,搜索条件key2>100可以形成扫描区间(100,+∞)。但是由于uk_key2的二级索引并不按照common_field列进行排序(uk_key2二级索引记录中压根儿不包含common_field列),所以仅凭搜索条件common_field = ‘abc’并不能减少需要扫描的二级索引记录数量。即该搜索条件生成的扫描区间其实是**(-∞,+∞)。由于这两个小的搜索条件是使用AND操作符连接,所以对(100,+∞)** 和 (-∞,+∞)这两个搜索区间取交集后得到的结果自然是**(100,+∞)。即使用uk_key2执行上述查询,最终对应的扫描区间就是(100,+∞),形成该扫描区间的条件就是key2>100
简化:使用uk_key2执行查询时,在寻找对应的扫描区间**的过程中,搜索条件 common_field = ‘abc’没起到任何作用,我们可以直接把 common_field = ‘abc’ 搜索条件替换为TRUE,(TRUE对应的扫描区间也是(-∞,+∞)),如下:
SELECT * FROM single_table WHERE key2 > 100 AND TRUE
# 简化之后
SELECT * FROM single_table WHERE key2 > 100
即上面的查询语句使用uk_key2执行查询时对应的扫描区间是**(100,+∞)**
OR情况 #
SELECT * FROM single_table WHERE key2 > 100 OR common_field = 'abc'
#替换之后
SELECT * FROM single_table WHERE key2 > 100 OR TRUE
所以,如果强制使用uk_key2执行查询,由于这两个小的搜索条件是使用OR操作符连接,所以对**(100,+∞)** 和 (-∞,+∞)这两个搜索区间取并集后得到的结果自然是**(-∞,+∞)。也就是需要扫描uk_key2的全部二级索引记录**,并且对于获取到的每一条二级索引记录,都需要执行回表操作。这个代价比执行全表扫描的代价都大。这种情况下,不考虑使用uk_key2来执行查询
从复杂的搜索条件中找出扫描区间 #
SELECT * FROM single_table WHERE (key1 > 'xyz' AND key2 =748) OR (key1<'abc' AND key1 > 'lmn') OR (key1 LIKE '%suf' AND key1 > 'zzz' AND (key2 < 8000 OR common_field = 'abc'))
分析:
- 涉及到的列,以及为哪些列建立了索引
设计key1,key2,common_field这三个列,其中key1列有普通二级索引idx_key1,key2列有唯一二级索引uk_key2 - 对于可能用到的索引,分析它们的扫描区间
假设使用idx_key1执行查询 #
把不能形成合适扫描区间的搜索条件暂时移除掉:直接替换为TRUE
除了有关key2和common_field列的搜索条件不能形成合适的扫描区间,还有key1 LIKE ‘%suf’形成的扫描区间是(-∞,+∞),所以也需要替换成TRUE,这些不能形成合适扫描区间的搜索条件替换成TRUE之后,搜索条件如下所示:
SELECT * FROM single_table WHERE (key1 > 'xyz' AND TRUE) OR (key1<'abc' AND key1 > 'lmn') OR (TRUE AND key1 > 'zzz' AND (TRUE OR TRUE))
#简化
SELECT * FROM single_table WHERE (key1 > 'xyz' ) OR (key1<'abc' AND key1 > 'lmn') OR (key1 > 'zzz' AND (TRUE OR TRUE) )
#进一步简化
SELECT * FROM single_table WHERE (key1 > 'xyz' ) OR (key1<'abc' AND key1 > 'lmn') OR (key1 > 'zzz')
#由于key1<'abc' AND key1 >'lmn' 永远为FALSE,所以进一步简化
SELECT * FROM single_table WHERE (key1 > 'xyz' ) OR (key1 > 'zzz')
#继续简化(取范围大的,即并集)
SELECT * FROM single_table WHERE key1 > 'xyz'
即如果使用idx_key1索引执行查询,则对应扫描区间为(‘xyz’,+∞)。
也就是需要把所有满足key1>‘xyz’条件的所有二级索引记录都取出来,针对获取到的每一条二级索引记录,都要用它的主键值再执行回表操作,在得到完整的用户记录之后再使用其他的搜索条件进行过滤
使用idx_key1执行上述查询时,搜索条件key1 LIKE %suf比较特殊,虽然不能作为形成扫描区间的边界条件,但是idx_key1的二级索引记录是包括key1列的,因此可以*先判断获取到的二级索引记录是否符合这个条件,如果符合再执行回表操作,如果不符合则不执行回表操作。这可减少因回表操作带来的性能损耗,这种优化方式称为索引条件下推
假设使用idx_key2执行查询 #
对于:
SELECT * FROM single_table WHERE (key1 > 'xyz' AND key2 =748) OR (key1<'abc' AND key1 > 'lmn') OR (key1 LIKE '%suf' AND key1 > 'zzz' AND (key2 < 8000 OR common_field = 'abc'))
简化
SELECT * FROM single_table WHERE (TRUE AND key2 =748) OR (TRUE AND TRUE) OR (TRUE AND TRUE AND (key2 < 8000 OR TRUE))
#简化
key2 = 748 OR TRUE
#进一步简化
TRUE
意味着如果要使用uk_key2索引执行查询,则对应的扫描区间就是**(-∞,+∞),即需要扫描uk_key2的全部二级索引记录,针对每一条二级索引记录还需要回表**,所以这种情况下不会使用uk_key2索引
使用联合索引执行查询时对应的扫描区间 #
联合索引的索引包含多个列,B+树中的每一层页面以及每个页面中采用的排序规则较为复杂,以single_table表的idx_key_part联合索引为例,采用的排序规则如下所示:
- 先按照key_part1列的值进行排序
- 在key_part1列的值相同的情况下,再按照key_part2列的值进行排序
- 在key_part1列和key_part2列值都相同的情况下,再按照key_part3列的值进行排序,画一下idx_key_part索引的示意图,如图所示:
对于查询语句Q1(单条件) #
SELECT * FROM single_table WHERE key_part1 = 'a';
由于二级索引记录是先按照key_part1列排序的,所以符合key_part1=‘a’条件的所有记录肯定是相邻的。我们可以定位到符合key_part1=‘a’条件的第一条记录,然后沿着记录所在的单向链表向后扫描(如果本页面中的记录扫描完了,就根据叶子节点的双向链表找到下一个页面中的第一条记录,继续沿着记录所在的单向链表向后扫描。我们之后就不强调叶子节点的双向链表了),直到某条记录不符合key_part=‘a’条件为止(当然,对于获取到的每一条二级索引记录都要执行回表操作)。过程如图所示
也就是说,如果使用idx_key_part索引执行查询语句Q1,对应的扫描区间是**[‘a’,‘a’],形成这个扫描区间的边界条件**就是key_part=‘a’
对于查询条件Q2(顺序2条件) #
SELECT * FROM single_table WHERE key_part1='a' AND key_part2='b';
由于二级索引记录是先按照key_part1列的值排序的, 在key_part1列的值相等的情况下再按照key_part2列进行排序,所以符合key_part1=‘a’ AND key_part2=‘b’条件的二级索引记录肯定是相邻的。 我们可以定位到符合key_part1=‘a’ AND key_part2=‘b’条件的第一条记录,然后沿着记录所在的单向链表向后扫描,直到某条记录不符合key_part1=‘a’条件或者key_part2=‘b’条件为止(当然,对于获取到的每一条二级索引记录都要执行回表操作,这里就不展示了) ,如图7-12 所示。也就是说,如果使用idx_key_part索引执行查询语句Q2 ,可以形成扫描区间**[(‘a’,‘b’),(‘a’,‘b’)]**,形成这个扫描区间的边界条件就是 key_part1=‘a’ AND key_part2=‘b’
[(‘a’,‘b’),(‘a’,‘b’)] 代表在idx_key_part索引中,从第一条符合key_part1=‘a’ AND key_part2=‘b’ 条件的记录开始,到最后一条符合key_part1=‘a’ AND key_part2=‘b’条件的记录为止的所有二级索引记录。
对于查询条件Q3(顺序3条件) #
SELECT * FROM single_table WHERE key_part1='a' AND key_part2='b' AND key_part3='c';
由于二级索引记录是先按照 key_part1列的值排序的,在key_part1列的值相等的情况下再按照key_part2列进行排序:在key_part1和key_part2列的值都相等的情况下, 再按照key_part3列进行排序,所以符合key_part1=‘a’ AND key_part2=‘b’ AND key_part3=‘c’条件的二级索引记录肯定是相邻的。
我们可以定位到符合key_part1=‘a’ AND key_part2 = ‘b’ AND key_part3=‘c’条件的第一条记录,然后沿着记录所在的单向链表向后扫描,直到某条记录不符合key_part1=‘a’条件或者key_part2=‘b’条件或者key_part3条件为止(当然,对于获取到的每一条二级索引记录都要执行回表操作)。这里就不再画示意图了。
如果使用idx_key_part索引执行查询语句Q3 ,可以形成扫描区间**[(‘a’,‘b’,‘c’),(‘a’,‘b’,‘c’)]** ,形成这个扫描区间的边界条件就是key_part1=‘a’ AND key_part2 = ‘b’ AND key_part3=‘c’
对于查询语句Q4(单条件范围) #
SELECT * FROM single_table WHERE key_part1 < 'a';
由于二级索引记录是先按照key_part1列的值进行排序的,所以符合key_part1<‘a’条件的所有记录肯定是相邻的。我们可以定位到符合key_part1<‘a’条件的第一条记录(其实就是idx_key_part 索引第一个叶子节点的第一条记录) ,然后沿着记录所在的单向链表向后扫描,直到某条记录不符合key_part1< ‘a’ 条件为止(当然,对于获取到的每一条二级索引记录都要执行回表操作,这里就不展示了) ,如图7- 13 所示
也就是说,如果使用idx_key_part索引执行查询语句Q4,可以形成扫描区间(-∞,‘a’),形成这个扫描区间的边界条件就是key_part1<‘a’
查询语句Q5(条件1等值,条件2范围) #
SELECT * FROM single_table WHERE key_part1='a' AND key_part2 > 'a' AND key_part2 < 'd';
由于二级索引记录是先按照key_part1列的值进行排序的,在key_part1列的值相等的情况下再按照key_part2列进行排序。也就是说,在符合key_part1=‘a’条件的二级索引记录中,这些记录是按照key_part2 列的值排序的, 那么此时符合key_part1=‘a’ AND key_part2>‘a’ AND key_part2 < ’d’条件的二级索引记录肯定是相邻的。我们可以定位到符合 key_part1=‘a’ AND key_part2>‘a’ AND key_part2 < ’d’条件的第-条记录(其实第一条就是key_part1=‘a’ AND key_part2 = ‘a’ ), 然后沿着记录所在的单向链表向后扫描, 直到某条记录不符合key_part1=‘a’ 或者key_part2>‘a’ 或者**key_part2 < ’d’**条件为止(当然,对于获取到的每一条二级索引记录都要执行回表操作, 这里就不展示了) ,如图7- 1 4 所示
也就是说,如果使用idx_key_part索引执行查询语句Q5,可以形成扫描区间**((‘a’,‘a’),(‘a’,’d’)),形成这个扫描区间的边界条件就是key_part1=‘a’ AND key_part2>‘a’ AND key_part2<’d’**。
查询语句Q6(条件2等值=>用不上索引) #
由于二级索引记录不是直接按照key_part2列的值排序的,所以符合key_part2列的二级索引记录可能并不相邻,也就意味着我们不能通过这个key_part2=‘a’ 搜索条件来减少需要扫描的记录数量。在这种情况下,我们是不会使用idx_key_part 索引执行查询的
查询语句Q7(条件1等值,条件3等值=>只有前面的条件是边界条件) #
SELECT * FROM single_table WHERE key_part='a' AND key_part3='c'
由于二级索引记录是先按照key_part1列的值排序的,所以符合key_part1=‘a’条件的二级索引记录肯定是相邻的。但是对于符合key_part3 =‘c’条件的二级索引记录来说,并不是直接按照key_part3列进行排序的,也就是说我们不能根据搜索条件key_part3=‘c’来进一步减少需要扫描的记录数量。那么,如果使用idx_key_part 索引执行查询,可以定位到符合key_part1 = ‘a’条件的第一条记录,然后沿着记录所在的单向链表向后扫描,直到某条记录不符合key_part1 = ‘a’条件为止。所以在使用idx_key_part索引执行查询语句Q7 的过程中,对应的扫描区间其实是**[‘a’,‘a’],形成该扫描区间的边界条件是 key_part1=‘a’,与key_part3=‘c’**无关。
索引条件下推特性,MySQL5.6中引入,默认开启
针对获取到的每一条二级索引记录,如果没有开启索引条件下推特性,则必须先执行回表操作,在获取到完整的用户记录后再判断key_part3=‘c’条件是否成立。如呆开启了索引条件下推特性,可以立即判断该二级索引记录是否符合key_part3=‘c’条件。如果符合该条件,则再执行回表操作;如果不符合则不执行回农操作,直接跳到下一条二级索引记录。
查询语句Q8(条件1范围,条件2等值=>只有前面的条件是边界条件) #
SELECT * FROM single_table WHERE key_part < 'b' AND key_part2='a'
由于二级索引记录是先按照key_part1列的值排序的,所以符合key_part1<‘b’条件的二级索引记录肯定是相邻的。但是对于符合key_part2 =‘a’条件的二级索引记录来说,并不是直接按照key_part2列进行排序的,也就是说我们不能根据搜索条件key_part2=‘a’来进一步减少需要扫描的记录数量。那么,如果使用idx_key_part 索引执行查询,可以定位到符合key_part1 < ‘b’‘条件的第一条记录(其实就是idx_key_part索引的第一个叶子节点的第一条记录),然后沿着记录所在的单向链表向后扫描,直到某条记录不符合key_part1<‘b’条件为止。如图:
所以在使用idx_key_part索引执行查询语句Q8 的过程中,对应的扫锚区间其实是[- ∞,‘b’],形成该扫描区间的边界条件是key_part1 < ‘b’ , 与 key_part2=‘a’无关
查询语句Q9(条件1范围(包括等号),条件2等值) #
SELECT * FROM single_table WHERE key_part1 <= 'b' AND key_part2='a'
Q8和Q9很像,但是在涉及key_part1条件时,Q8中的条件是key_part1<‘b’,Q9中的条件是key_part1<=‘b’。很显然,符合key_part1=‘b’条件的二级索引记录是相邻的。但是对于符合key_part1<=‘b’条件的二级索引记录来说,并不是直接按照key_part2列排序的。但是,对于符合key_part1=‘b’的二级索引记录来说,是按照key_part2列的值排序的。那么在确定需要扫描的二级索引记录的范围时,当二级索引记录的key_part1列值为’b’ 时,也可以通过key_part2=‘b’ 条件减少需要扫描的二级索引记录范围。也就是说, 当扫描到不符合key_part1=‘b’ AND key_part2=‘a’ 条件的第一条记录时,就可以结束扫描,而不需要将所有key_part1列值为’b’的记录扫描完。
注意,当扫描到记录的列key_part1值为b时,不能直接定位到**key_part2=‘a’的数据了,但是可以扫描到key_part2=‘a’**停止
也就是说,如果使用idx_key_part索引执行查询语句Q9,可以形成扫描区间((-∞,-∞),(‘b’,‘a’)),形成这个扫描区间的边界条件就是key_part1<=‘b’ AND key_part2=‘a’。而在执行查询语句Q8时,我们必须将所有符合**key_part1<‘b’**的记录都扫描完,**key_part2=‘a’**条件在查询语句Q8中并不能起到减少需要扫描的二级索引范围的作用
注意,对于Q9,key_part1<‘b’的记录也是要扫描完的。这里仅仅对key_part1=‘b’起了减少扫描二级索引范围的作用。
索引用于排序 #
我们在编写查询语句时,经常需要使用ORDERBY子句对查询出来的记录按照某种规则进行排序。在一般情况下,我们只能把记录加载到内存中,然后再用一些排序算法在内存中对这些记录进行排序。有时查询的结果集可能太大以至于无法在内存中进行排序,此时就需要暂时借助磁盘的空间来存放中间结果,在排序操作完成后再把排好序的结果集返回客户端。在MySQL 中,这种在内存或者磁盘中进行排序的方式统称为文件排序(fìlesort)。但是,如果ORDERBY子句中使用了索引列,就有可能省去在内存或磁盘中排序的步骤。
举例:
SELECT * FROM single_table ORDER BY key_part1,key_part2,key_part3 LIMIT 10;
这个查询语句的结果集需要先按照key_part1 值排序。如果记录的key_part1 值相同,再按照key_part2值排序,如果记录的key_part1 和key_part2值都相同,再按照key_part3 值排序。大家可以回过头去看看图7-10。
该二级索引的记录本身就是按照上述规则排好序的,所以我们可
以从第一条idx_key_part二级索引记录开始,沿着记录所在的单向链表向后扫描,取10 条二级索引记录即可。当然,针对获取到的每一条二级索引记录都执行一次回表操作,在获取到完整的用户记录之后发送给客户端就好了。这样是不是就变得简单多了,还省去了我们给10000条记录排序的时间–索引就是这么厉害!
关于回表操作: 请注意,本例的查询语句中加了LIMIT 子句,这是因为如果不限制需要获取的记录数量,会导致为大量二级索引记录执行回表操作,这样会影响整体的查询性能。关于回表操作造成的影响,我们稍后再详细唠叨
使用联合索引进行排序时的注意事项 #
ORDER BY子句后面的列顺序也必须按照索引列的顺序给出
如果给出ORDER BY key_part3,key_part2,key_part1的顺序,则无法使用B+树索引。
如果是ORDER BY key_part1 DESC,key_part2 DESC,key_part3 DESC ,那么应该是可以的,也就是
ORDER BY key_part1,key_part2,key_part3
的全反序
之所以颠倒的排序列顺序不能使用索引,原因还是联合索引中页面和记录的排序规则是固定的,也就是先按照key_part1值排序,如果key_part1值相同,再按照key_part2值排序;如果key_part1和key_part2值都相同,再按照key_part2值排序。
如果ORDER BY子句的内容是ORDER BY key_part3 , key_part2 , key_part,那就要求先要key_part3值排序(升序),如果key_part3相同,再按key_part2升序,如果key_part3和key_part3都相同,再按照key_part1升序
同理,这些仅对联合索引的索引列中左边连续的列进行排序的形式(如ORDER BY key_part1
和ORDER BY key_part1,key_part2
),也是可以利用B+树索引的。另外,当连续索引的索引列左边连续的列为常量时,也可以使用联合索引对右边的列进行排序
SELECT * FROM single_table WHERE key_part1='a' AND key_part2='b' ORDER BY key_part3 LIMIT 10
能使用联合索引排序,原因是key_part1值为’a’、key_part2值为’b’的二级索引记录本身就是按照key_part3列的值进行排序的
不能使用索引进行排序的几种情况 #
ASC、DESC混用 #
我们要求各个排序列的排序顺序规则是一致的,要么各个列都是按照ASC(升序),要么都是按照DESC(降序)规则排序
为什么呢:
idx_key_part联合索引中的二级索引记录的排序规则:先key_part1升序,key_part1相同则key_part2升序,如果都相同则key_part3升序
如果
ORDER BY key_part1,key_part2 LIMIT10
,那么直接从联合索引最左边的二级索引记录开始,向右读取10条即可如果
ORDER BY key_part1 DESC,key_part2 DESC LIMIT 10
,可以从联合索引最右边的那条二级索引记录开始,向左读10条注意,这里没有key_part3,也可以的。可以理解成,key_part3不要求排序。而按照
key_part1 DESC,key_part2DESC
顺序的记录一定是连续的
如果是先key_part1列升序,再key_part2列降序,如:
SELECT * FROM single_table ORDER BY key_part1,key_part2 DESC LIMIT 10;
此时联合索引的查询过程如下,算法较为复杂,不能高效地使用索引,所以这种情况下是不会使用联合索引执行排序操作的
MySQL8.0引入了称为Descending Index的特性,支持ORDER BY 子句中ASC、DESC混用的情况
排序列包含非同一个索引的列,这种情况也不能使用索引进行排序 #
SELECT * FROM single_table ORDER BY key1,key2 LIMIT 10
对于idx_key1的二级索引来说,只按照key1列排序。且key1值相同的情况下是不按照key2列的值进行排序的,所以不能使用idx_key1索引执行上述查询
排序列是某个联合索引的索引列,但是这些排序列再联合索引中并不连续 #
SELECT * FROM single_table ORDER BY key_part1,key_part3 LIMIT 10;
key_part1值相同的记录并不按照key_part3排序,所以不能使用idx_key_part执行上述查询
用来形成扫描区间的索引列与排序列不同 #
SELECT * FROM single_table WHERE key1='a' ORDER BY key2 LIMIT 10;
如果使用key1=‘1’作为边界条件来形成扫描区间,也就是再使用idx_key1执行该查询,仅需要扫描key1值为’a’的二级索引记录。此时无法使用uk_key2执行上述查询
5:排序列不是以单独列名的形式出现在ORDER BY 子句中
要想使用索引排序,必须保证索引列是以单独列名的形式(而不是修饰过):
SELECT * FROM single_table ORDER BY UPPER(key1) LIMIT 10;
因为key1列以UPPER(key1)函数调用的形式出现在ORDER BY子句,所以不能使用idx_key1执行上述查询
索引用于分组 #
为了方便统计,会把表中记录按照某些列进行分组,如:
SELECT key_part1,key_part2,key_part3,COUNT(*) FROM single_table GROUP BY key_part1,key_part2,key_part3;
对这些小分组进行统计,上面的查询,即统计每个小小分组包含的记录条数。
- 如果没有idx_key_part索引,就得建立一个用于统计的临时表,在扫描聚簇索引的记录时将统计的中间结果填入这个临时表。当扫描完记录后, 再把临时表中的结果作为结果集发送给客户端。
- 如果有了索引idx_key_part ,恰巧这个分组顺序又与idx_key_part 的索引列的顺序是一致的,而idx_key_part 的二级索引记录又是按照索引列的值排好序的,这就正好了。所以可以直接使用idx_key_part 索引进行分组,而不用再建立临时表了
与使用B+ 树索引进行排序差不多, 分组列的顺序也需要与索引列的顺序一致,也可以只使用索引列中左边连续的列迸行分组
如上,就是统计 (‘0’,‘0’,‘0’)的有几条, (‘0’,‘a’,‘a’)的有几条, (‘0’,‘a’,‘b’)的有几条等
回表的代价 #
SELECT * FROM single_table WHERE key1 > 'a' AND key1 < 'c'
有两种方式来执行上面语句
以全表扫描的方式 #
直接扫描全部的聚簇索引记录, 针对每一条聚簇索引记录,都判断搜索条件是否成立, 如果成立则发送到客户端, 否则跳过该记录.
使用idx_key1执行该查询 #
可以根据搜索条件key1 > ‘a’ AND key1 < ‘c’ 得到对应的扫描区间( ‘a’,‘c’ ),然后扫描该扫描区间中的二级索引记录。由于idx_key1索引的叶子节点存储的是不完整的用户记录,仅包含key1 、id 这两个列,而查询列表是*, 这意味着我们需要获取每条二级索引记录对应的聚簇索引记录, 也就是执行回表操作,在获取到完整的用户记录后再发送到客户端。
分析 #
对于使用InnoDB 存储引擎的表来说, 索引中的数据页都必须存放在磁盘中, 等到需要时再加载到内存中使用。这些数据页会被存放到磁盘中的一个或者多个文件中, 页面的页号对应着该页在磁盘文件中的偏移量。以16KB大小的页面为例,页号为0 的页面对应着这些文件中偏移量为0 的位置,页号为1的页面对应着这些文件中偏移量为16KB 的位置。前面章节讲过, B+ 树的每层节点会使用双向链表连接起来, 上一个节点和下一个节点的页号可以不必相邻。
不过在实际实现中, 设计Inno DB 的大叔还是尽量让同一个索引的叶子节点的页号按照顺序排列,这一点会在稍后讨论表空间时再详细嘴叨
也就是说,idx_key1在扫描区间( ‘a’, ‘c’ )中的二级索引记录所在的页面的页号会尽可能相邻
即使这些页面的页号不相邻, 但起码一个页可以存放很多记录,也就是说在执行完一次页面I/O
后,就可以把很多二级索引记录从磁盘加载到内存中。 总而言之,就是读取在扫描区间( ‘a’, ‘c’ ) 中
的二级索引记录时,所付出的代价还是较小的。不过扫描区间( ‘a’, ‘c’ )中的二级索引记录对应
的id 值的大小是毫无规律的, 我们每读取一条二级索引记录,就需要根据该二级索引记录的id
值到聚簇索引中执行回表操作。如果对应的聚簇索引记录所在的页面不在内存中,就需要将该
页面从磁盘加载到内存中.。由于要读取很多id 值并不连续的聚簇索引记录,而且这些聚簇索引
记录分布在不同的数据页中, 这些数据页的页号也毫无规律,因此会造成大量的随机I/O .
需要执行回表操作的记录越多, 使用二级索引进行查询的性能也就越低,某些查询宁愿使
用全表扫描也不使用二级索引。比如, 假设key1值在’a’~‘c’ 之间的用户记录数量****占全部记录**
数量的99%** 以上,如果使用idx_key1索引,则会有99% 以上的id 值需要执行回表操作。这
不是吃力不讨好么, 还不如直接执行全表扫描
什么时候采用全表扫描, 什么时候使用二级索引+回表的方式 #
这是查询优化器应该做的工作:
查询优化器会事先针对表中的记录计算一些统计数据,然后再利用这些统计数据或者访问表中的少量记录来计算需要执行回表操作的记录数,如果需要执行回表操作的记录数越多,就越倾向于使用全表扫描, 反之则倾向于使用二级索引+回表的方式。当然,查询优化器所做的分析工作没有这么简单, 但大致上是这样一个过程。
一般情况下,可以给查询语句指定LIMIT 子句来限制查询返回的记录数, 这可能会让查 询优化器倾向于选择使用二级索引+回表的方式进行查询, 原因是回表的记录越少, 性能提升 就越高。比如,上面的查询语句可以改写成下面这样
SELECT * FROM single_table WHERE key1 > 'a' AND key1<'c' LIMIT 10
添加了LIMlT10 子句后的查询语句更容易让查询优化器采用二级索引+回表的方式来执行。
对于需要对结果进行排序的查询,如果在采用二级索引执行查询时需要执行回表操作的记
录特别多,也倾向于使用全表扫描+文件排序的方式执行查询。比如下面这个查询语句
SELECT * FROM single_table ORDER BY key1
由于查询列表是 *,如果使用二级索引进行排序,则需要对所有二级索引记录执行回表操作.
这样操作的成本还不如直接遍历聚簇索引然后再进行文件排序低, 所以查询优化器会倾向于使
用全表扫描的方式执行查询。如果添加了LIMIT子句,比如下面这个查询语句:
SELECT * FROM single_table ORDER BY key1 LIMIT 10;
这个查询语句需要执行回表操作的记录特别少,查询优化器就会倾向于使用二级索引+回表的 方式来执行
更好地创建和使用索引 #
只为用于搜索、排序或分组的列创建索引 #
SELECT common_field,key_part3 FROM single_table WHERE key1= 'a';
没必要为common_field,key_part3创建索引
考虑索引列中不重复值的个数 #
前文在唠叨回表的知识时提提到, 在通过二级索引+回表的方式执行查询时,某个扫描区间中包含的二级索引记录数量越多, 就会导致回表操作的代价越大。我们在为某个列创建索引时,需要考虑该列中不重复值的个数占全部记录条数的比例。如果比例太低,则说明该列包含 过多重复值,那么在通过二级索引+回表的方式执行查询时,就有可能执行太多次回表操作
索引列的类型尽量小 #
在定义表结构时,要显式地指定列的类型 以整数类型为例, 有 TINIINT、MEDIUMINT、INT、BIGINT这几种,它们占用的存储空间的大小依次递增。下面所说的类型大小指的就是该类型占用的存储空间的大小。刚才提到的这几个整数类型,它们能表示的整数范围当然也是依次递增。如果想要对某个整数类型的列建立索引,在表示的整数范围允许的情况下,尽量让索引列使用较小的类型,比如能使用INT就不要使用BIGINT。 能使用MEDIUMINT 就不要使用的INT。 因为数据类型越小, 索引占用的存储空间就越少,在一个数据页内就可以存放更多的记录,磁盘1/0 带来的性能损耗也就越小(一次页面I/O 可以将更多的记录加载到内存中) 读写效率也就越高 这个建议对于表的主键来说更加适用,因为不仅聚簇索引会存储主键值,其他所有的二级索引的节点都会存储一份记录的主键值。如果主键使用更小的数据类型,也就意味着能节省更多的存储空间
为列前缀建立索引 #
我们知道,一个字符串其实是由若干个字符组成的。如果在MySQL 中使用utf8 字符集存储字符串,则需要1 - 3 字节来编码一个字符。假如字符串很长,那么在存储这个字符串时就需要占用很大的存储空间。在需要为这个字符串所在的列建立索引时,就意味着在对应的B+ 树中的记录中, 需要把该列的完整字符串存储起来。字符串越长,在索引中占用的存储空间越大。 前文说过, 索引列的字符串前缀其实也是排好序的,所以索引的设计人员提出了一个方案。 只将字符串的前几个字符存放到索引中,也就是说在二级索引的记录中只保留字符串的前几个字符。比如我们可以这样修改idx_key1索引,让索引中只保留字符串的前10个字符:
ALTER TABLE single_table DROP INDEX idx_key1;
ALTER TABLE single_table ADD INDEX idx_key1(key1(10));
再执行下面的语句
SELECT * FROM single_table WHERE key1= 'abcdefghijklmn'
由于在idx_key1 的二级索引记录中只保留字符串的前10 个字符,所以我们只能定位到前缀为’abcdefghij’ 的二级索引记录,在扫描这些二级索引记录时再判断它们是否满足key1=‘abcdefghijklmn’ 条件。当列中存储的字符串包含的字符较多时,这种为列前缀建立索引的方式可以明显减少索引大小。
- 注意,上面说的是扫描这些二级索引记录,是“些”。
- 可以减少索引大小,但不一样减少索引数量。如果有重复的照样会在索引中出现,因为不是UNIQUE约束。二级索引值大小相同时,会按照聚簇索引大小排列
不过,在只对列前缀建立索引的情况下, 下面这个查询语句就不能使用索引来完成排序需求了:
SELECT * FROM single_table ORDER BY key1 LIMIT 10;
因为二级索引idx_key1中不包含完整的key1列信息,所以在仅使用idx_key1索引执行查询时,无法对key1 列前10 个字符相同但其余字符不同的记录进行排序。也就是说,只为列前缀建立索引的方式无法支持使用索引进行排序的需求。上述查询语句只好乖乖地使用全表扫描+文件排序的方式来执行了。
只为列前缀创建索引的过程我们就介绍完了,还是将idx_key1 改回原来的样式:
ALTER TABLE single_table DROP INDEX idx_key1;
ALTER TABLE single_table ADD INDEX idx_key1(key1);
覆盖索引 #
为了彻底告别回表操作带来的性能损耗,建议最好在查询列表中只包含索引列,比如这个查询语句:
SELECT key1,id FROM single_table WHERE key1 > 'a' AND key1 < 'c'
由于只查询key1列和id列的值,这里使用idx_key1索引来扫描(‘a’,‘c’)区间的二级索引记录时,可以直接从获取到的二级索引记录中读出key1列和id列的值,不需要通过id值到聚簇索引执行回表,省去回表操作带来的性能损耗。
把这种已经包含所有需要读取的列的查询方式称为覆盖索引
排序操作也优先使用覆盖索引进行查询:
SELECT * FROM single_table ORDER BY key1
虽然这个查询语句中没有LIMIT子旬,但是由于可以采用覆盖索引,所以查询优化器会直接使用idx_key1索引进行排序 而不需要执行回表操作。 当然,如果业务需要查询索引列以外的列,那还是以保证业务需求为重。如无必要, 最好仅把业务中需要的列放在查询列表中,而不是简单地以*替代
让索引列以列名的形式在搜索条件中单独出现 #
注意,是单独
如下面两个语义一样的搜索条件
SELECT * FROM single_table WHERE key2 * 2 < 4;
SELECT * FROM single_table WHERE key2 < 4/2;
- 在第一个查询语句的搜索条件中, key2列并不是以单独列名的形式出现的,而是以key2 * 2这样的表达式的形式出现的。 MySQL 并不会尝试简化key2*2<4 表达式,而是直接认为这个搜索条件不能形成合适的扫描区间来减少需要扫描的记录数量,所以该查询语句只能以全表扫锚的方式来执行。
- 在第二个查询语句的搜索条件中, key2 列是以单独列名的形式出现的, MySQL 可以分析出如果使用uk_key2 执行查询,对应的扫描区间就是(-∞,2) ,这可以减少需要扫描的记录数量。 所以MySQL 可能使用uk_key2 来执行查询。 所以,如果想让某个查询使用索引来执行,请让索引列以列名的形式单独出现在搜索条件中
新插入记录时主键大小对效率的影响 #
我们知道,对于一个使用lnnoDB 存储引擎的表来说,在没有显式创建索引时, 表中的数据实际上存储在聚簇索引的叶子节点中,而且B+ 树的每一层数据页以及页面中的记录都是按照主键值从小到大的顺序排序的。如果新插入记录的主键值是依次增大的话,则每插满一个数据页就换到下一个数据页继续插入。如果新插入记录的主键值忽大忽小,就比较麻烦了
假设某个数据页存储的聚簇索引记录已经满了, 它存储的主键值在1 - 100之间,如图:
此时,如果再插入一条主键值为8的记录,则它插入的位置如图:
可这个数据页已经满了啊, 新记录该插入到哪里呢?我们需要把当前页面分裂成两个页面, 把本页中的一些记录移动到新创建的页中。页面分裂意味着什么?意味着性能损耗!所以, 如果想尽量避免这种无谓的性能损耗,最好让插入记录的主键值依次递增。就像single_table的主键id 列具有AUTO_INCREMENT 属性那样。 MySQL 会自动为新插入的记录生成递增的主键值
冗余和重复索引 #
针对single_table 表, 可以单独针对key_part1列建立一个idx_key_part1索引
ALTER TABLE single_table ADD INDEX idx_key_part(key_part1);
其实现在我们已经有了一个针对key_part1、key_part2 、key_part3列建立的联合索引idx_key_part。idx_key_part索引的二级索引记录本身就是按照key_part1 列的值排序的, 此时再单独为key_part1列建立一个索引其实是没有必要的。我们可以把这个新建的idx_key_part1索引看作是一个冗余索引, 该冗余索引是没有必要的
有时,我们可能会对同一个列创建多个索引,比如这两个添加索引的语句:
ALTER TABLE single_table ADD UNIQUE KEY uk_id(id);
ALTER TABLE single_table ADD INDEX idx_id(id);
我们针对id 列又建立了一个唯一二级索引uk_id,. 还建立了一个普通二级索引idx_id。 可是id 列本身就是single_table 表的主键, InnoDB 自动为该列建立了聚簇索引, 此时uk_id 和idx_id 就是重复的,这种重复索引应该避免