详细介绍了各种高性能的索引使用策略,比如联合索引、索引顺序、聚簇索引、覆盖索引等等,以及常见索引失效的情况。
前面我们已经介绍了各种类型的索引结构及其对应的优缺点:
正确的创建和使用索引是实现高性能查询的基础。我们通常会看到一些查询不当的使用索引,或者使用MySQL无法使用已有的索引,下面要讲的高性能的索引策略就是要避免索引失效,并尽可能的发挥这些索引的优势。
1 独立的列
如果查询中的列不是独立的,则MySQL就不会使用索引。独立的列指索引列不能是表达式的一部分,也不能是函数的参数。
使用表达式:
SELECT actor_id FROM actor WHERE actor_id + 1 = 5
用户很明显能够知道条件是actor_id = 4,但是MySQL无法自动解析这个方程式,也就无法使用索引了,我们应该养成简化WHERE条件的习惯,始终将索引列单独放在比较符号的一侧!
另一个常见错误是使用函数:
SELECT ... WHERE TO_DAYS(CURRENT_DATE) - TO_DAYS(date_col) <= 10
这种也无法使用索引。
2 前缀索引和索引选择性
语法:
ALTER TABLE table_name ADD KEY(column_name(prefix_length));
有时候需要很长的字符列,就会让索引变的大且慢。一个策略是前面文章中提到过的模拟Hash索引。另外一种方式就是使用前缀索引,前缀索引就是指只使用索引列开始的部分字符建立索引。
前缀索引可以大大节约索引空间,从而提高索引效率,但使用前缀索引会降低索引的选择性,索引的选择性是指不重复的索引值数量(也称为基数)和数据表记录总数(#T)的比值,范围从1/#T到1之间,索引选择性越高,说明不重复的索引值占比越高,查询效率越快。因为我们只取字符串的前一部分作为索引,这样索引的选择性肯定会降低的,而唯一索引的选择性就是1,性能自然也是最好的。
一般来说,某个列的前缀索引的选择性足够的高,足以满足查询性能。对于BLOB、TEXT或者很长的VARCHAR类型的列,必须使用前缀索引,因为MySQL不允许索引这些列的完整长度。
很容易就能知道,选择前缀的索引的原则是要选择足够的长度保证索引较高的选择性,前缀索引的选择性应该接近于索引的整个列,但同时又不能太长。可以根据前缀的基数应该接近于完整列的基数,来确定基数的长度,我们可以通过截取不同长度的字符和完整列进行比较,找到合适的长度。另外一个办法就是计算完整列的选择性,并使用前缀的选择性接近完整列的选择性。
前缀索引是一种能使索引更小、更快的有效办法,但还有另一方面的缺点,MySQL无法使用前缀索引做ORDER BY和GROUP BY,也无法使用前缀索引做覆盖扫描。
MySQL对于某个表的某个列的选择性计算方式如下,其中LEFT函数用于截取指定长度的字符串:
SELECT
COUNT(DISTINCT LEFT(column,1))/COUNT(*) as sel1,
COUNT(DISTINCT LEFT(column,2))/COUNT(*) as sel2,
COUNT(DISTINCT LEFT(column,3))/COUNT(*) as sel3,
COUNT(DISTINCT LEFT(column,4))/COUNT(*) as sel4,
COUNT(DISTINCT LEFT(column,5))/COUNT(*) as sel5,
COUNT(DISTINCT LEFT(column,6))/COUNT(*) as sel6,
COUNT(DISTINCT LEFT(column,7))/COUNT(*) as sel7
FROM tablename
3 多列(组合、联合)索引
普通多列索引的语法:
ALTER TABLE table_name ADD INDEX index_name ( `column1`, `column2`, `column3` );
3.1 多个单列索引的问题
如果查询中有多个列的查询条件,那么将每个列都建立独立索引的做法通常是不明智的。早期的MySQL版本,在SQL执行过程中,MySQL只能使用一个索引,会从多个单列索引中选择一个限制最为严格的索引。但是某些情况下,MySQL无法选择出一个有效的独立索引,比如下面的查询:
SELECT id FROM tableName WHERE column1 = 1 OR column2 = 1
即使column1和column2都有独立的索引,但两个单列索引都不是最好的选择,此时MySQL通常会选择全表扫描,除非改写成两个查询UNION的方式(注:MySQL的UNION 会自动去除重复的结果集):
SELECT id FROM tableName WHERE column1 = 1 UNION SELECT id FROM tableName WHERE column2 = 1
改写为UNION之后,将可以使用到两个索引。
在MySQL5.0及其之后的版本中,查询能够自动的使用这两个单列索引进行扫描,并将结果进行合并,也称为“合并索引(index_merge)”。这种算法主要应用于三个场景:
- 对OR条件的合并(UNION),如查询SELECT id FROM tableName WHERE column1 = 1 OR column2 = 1时,如果column1和column2列上分别有索引,可以按照column1和column2条件进行查询,再将查询结果合并(union)操作,得到最终结果。
- 对AND条件的相交(INTERSECT),如查询ELECT id FROM tableName WHERE column1 = 1 AND column2 = 1时,如果column1和column2列上分别有索引,可以按照column1和column2条件进行查询,再将查询结果取交集(INTERSECT)操作,得到最终结果。
- 对AND和OR组合语句的合并和相交求结果。
EXPLAIN中的type和Extra列可以看到索引合并的信息:
虽然MySQL索引合并策略有时候是一种自动优化的结果,但实际上更多的时候说明了表上的索引建的很糟糕,并且这种优化还有很多的问题:
- 当出现多个索引相交操作时(通常有多个AND条件),通常意味着需要一个包含所有相关列的多列(组合、联合)索引,而不是多个独立的单列索引。
- 当出现多个索引联合操作时(通常有多个OR条件),通常需要消耗大量CPU和内存资源在算法的缓存、排序和合并操作上。特别是当其中有些索引的选择性并不高,需要合并扫描返回大量数据的时候
- 更重要的是,优化器不会把这些计算到“查询成本”中,优化器只关心随机页面读取。这会使得查询成本被“低估”,导致该执行计划还不如直接走全表扫描。这样做不但会消耗更多的CPU和内存,还可能会影响查询的并发性,但如果是单独运行这样的查询则往往会忽略对并发性的影响。通常来说,还不如MySQL4.1之前,将查询改写成UNION的方式好。
3.2 使用多列索引
多个列组成的一个索引,称为多列(组合、联合)索引。通常使用联合索引是:
- 为了对SQL语句中的查询条件后的多个查询列(如果有)都应用索引,提升查找效率。
- 为了实现索引覆盖,进而减少回表查询,提升查找效率。
联合索引和单个列的索引结构都是完全一样的B+Tree结构,不同的是,单列索引的关键key只有一个列的值,而联合索引的的关键key中有多个列的值,比如联合索引(a_b_c),等于有了索引:a,a_b,a_b_c三个索引,这样也节省了空间。
假设有一个user表,具有id、age、name、score、birthday字段,其中age、name、score组成联合索引,下图是在InnoDB引擎中user表的联合索引图:
从上面的结构我们能知道,这样相比于三个单例索引就节省了data域空间,并且提升了多条件查询的效率。
4 选择适合的索引列顺序
我们遇到最容易引起困惑的问题就是多列索引的列顺序,正确的顺序依赖于使用该索引的查询,并且同时满足排序和分组的需要(适用于B-Tree索引,Hash或者其他类型的索引并不会想B-Tree索引一样按照顺序存储数据),在多列索引中,索引的顺序非常重要,如果索引的顺序不正确,会导致索引失效。
要讨论索引列的顺序,我们必须知道多列索引的索引存储结构和检索方式。
在一个多列B+Tree索引中,索引列创建时的列顺序意味着首先按照最左列进行排序,当第一列的值相等时,按照第二列进行排序,以此类推……,这样会导致最底层的叶子节点除了会按照第一列从左到右递增排列之外,后续的列都是无序的(或者部分排序),比如第二列只有在第一列某些值相等的范围内递增有序排列,而第三列只能在前两列相等的范围内递增有序排列。这一点在上图中的叶子节点中能够体现出来。简单的说,就是索引创建后,必须按照索引创建顺序。
在多列索引搜索时,同样是按照索引列创建时的列顺序来确定搜索方向的,先比较第一列来确定下一步应该搜索的方向,往左还是往右。如果第一列相同再比较第二列,依次类推。但是如果查询条件中没有第一列,B+Tree就不知道第一步应该从哪个节点查起,这样就不能使用多列索引了。
所以说,联合索引(a,b,c),实际上就等于有了:(a),(a,b),(a,b,c)三个索引。
根据上面的原理,我们能总结出组合索引的最左前缀匹配原则:使用多列索引查询时,MySQL会一直按照多列索引中索引列的定义顺序从左向右精确匹配:
- 向右匹配时遇到某个列x的范围查询比如
>、<、between、like
,就停止匹配,x后续的列退化为线性查找。如果是多值精确匹配比如in、or
,则仍然可以使用后续的索引。 - 向右匹配时遇到某个列x的查询条件不存在,则同样停止匹配,x后续的列退化为线性查找。
- 向右匹配时遇到某个列x使用了函数或表达式,则同样停止匹配,x后续的列退化为线性查找。
- 最左前缀可以是联合索引的最左N个字段,也可以是字符串索引的最左M个字符。
如有索引 (a,b,c,d),查询条件 a=1 and b=2 and c>3 and d=4,则会在每个节点依次命中a、b、c,无法命中d,d是用不到索引的,对于d只能线性查找,即依次匹配。但如果建立(a,b,d,c)的索引则都可以用到,精确查询(=、in、or)的列的顺序在SQL中可以任意调整,MySQL的查询优化器会自动优化where子句的条件顺序。
选择多列索引的列顺序有一个经验法则:将选择性最高的列放在最前列,索引的选择性是指:不重复的索引值数量和记录总数的比值。最大值为 1,此时每个记录都有唯一的索引与其对应。选择性越高,每个记录的区分度越高,查询效率也越高。
但上面的经验不总是有效的,还需要根据查询条件和表数据的特点具体的判断。比如上面的sql中,即使c的选择性比d更高,但是为了使d走索引,那么d列应该排在c列的前面。
5 聚簇(聚集)索引
聚簇索引并不是一种单独的索引类型,而是一种数据存储方式。InnoDB的聚簇索引就主索引的叶子节点 data 域记录着完整的数据记录,即在同一个结构中保存了B-Tree索引和数据行。在一张表上最多只能创建一个聚簇索引,因为无法将同一份真实的数据行存放到多个地方。InnoBD的辅助索引由被称为二级索引。
对应的,MyISAM的索引方式也叫做“非聚簇索引”,之所以这么称呼是为了与InnoDB的聚集索引区分。
聚集索引的优点:
- 可以通过索引把相关数据保存在一起,并且由于索引和数据保存在同一个B-Tree中,数据访问更快。
- 使用覆盖索引扫描的查询可以直接使用页节点中的主键值。
聚集索引的缺点:
- 如果数据全部都放在内存中,则访问的顺序就没那么重要了,聚簇索引也就没什么优势了。
- 插入速度严重依赖于插入顺序。如果按照主键顺序插入数据,那么速度是最快的,且形成一个紧凑的索引结构,如果不是,由于每次插入主键的值近似于随机,因此每次新纪录都要被插到现有索引页得中间某个位置,那么可能会涉及到其他位置的数据位置进行频繁调整,降低效率。因此InnoDB一定建议使用自增逻辑主键。
- 当插入行的主键值要求必须将这一行插入到某个已满的页中时,存储引擎会将该页分裂成两个页面来容纳该行,这就是一次“页分裂”操作,页分裂会导致表占用更多的磁盘空间,并且使索引结构变得不紧凑(原本放在一个页的数据,现在分到两个页中,整体空间利用率降低大约50%),形成很多磁盘碎片,可能导致全表扫描变慢。
- 更新、删除聚簇索引列的代价同样很高,会强制InnoDB将每个被更新的行移动到新的位置,并涉及到其他行的位置移动,同样可能造成页分裂、磁盘碎片化、页合并等问题。
- 二级索引访问需要两次索引查找,而不是一次。因为二级索引叶子节点保存的不是指向行的物理位置的指针,而是行的主键值。二级索引查找行,存储引擎需要找到二级索引的叶子节点获得对应的主键值,然后根据这个值去聚簇索引中查找到对应的行,两次B-Tree查找。
6 覆盖索引
InnoDB引擎如果使用辅助索引,那么根据先在辅助索引树中获取的主键id,然后再到主键索引树检索真正的数据,该过程称为回表查询。回表查询需要更多的磁盘IO,对性能影响很大。
MySQL可以使用索引来直接获取数据,如果索引的叶子节点中已经包含了我们要查询的所有数据,那么还有什么必要再回表查询呢?如果一个索引包含(或者说覆盖)所有需要查询的字段的值,我们就称之为“覆盖索引”,覆盖索引同样并不是一种索引结构,覆盖索引是一种很常用的优化手段。
有了覆盖索引,会带来很多好处,特别是在InnoDB如果二级索引能够覆盖查询,则可以避免对主键索引的二次查询,无序回表,就能够极大的提升性能。
不是所有类型的索引都可以成为覆盖索引,覆盖索引必须要存储索引列的值,而哈希索引、空间索引和全文索引等都不存储索引列的值,所以MySQL只能使用B-Tree索引做覆盖索引。
如果使用了覆盖索引,则在执行计划的Extra列能够看到“Using index”标志。
另外,InnoDB的二级索引的叶子节点包含了主键的值,因此也可以利用这些额外的主键值做索引覆盖,比如如果查询某个二级索引列和主键两个字段,那么也是可以实现索引覆盖的。
《Relational Database index design and the optimizers》(数据库索引设计与优化)书中将覆盖索引称为宽索引,而不能避免回表查询的索引被称为窄索引。
另外,在二级索引里,由于叶子节点会附带有主键key,因此对于相同的索引键值,索引行将会按照附带的主键值顺序存储,因此可以利用二级索引消除排序和避免回表。即,如果有个二级索引(a),主键索引为(b,c),那么实际上二级索引的顺序等价于联合索引(a,b,c)。
参考资料:
- 《 MySQL 技术内幕: InnoDB 存储引擎》
- 《高性能 MySQL》
如有需要交流,或者文章有误,请直接留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!