《高性能MySQL》读后感——高性能的索引策略

0.096字数 9029阅读 1032

引子

对于一条SQL,开发同学最先关心的啥? 我觉得还不到这个SQL在数据库的执行过程,而是这条SQL是否能尽快的返回结果,在SQL的生命周期里,每一个环节都有足够的优化空间,但是我们有没有想过,SQL优化的本质是啥?终极目标又是啥?其实优化本质上就是减少SQL对资源的消耗和依赖,正如数据库优化的终极目的是Do nothing in database一样,SQL优化的终极目的是Consume no resource。

数据库资源有两个特性:

  • 首先资源是有限的,大家都抢着用就会有瓶颈的,所以SQL的瓶颈可能是由资源紧张产生的。
  • 其次资源是有代价的,并且代价各异,比如内存的时延100ns, SSD100us,SAS盘10ms,网络更高,那么访问CPU L1/L2/L3 cache的代价就比访问内存的要低,访问内存资源的代价要比访问硬盘资源的代价,所以SQL的瓶颈也可能是访问了代价比较高的资源导致的。

现代计算机体系下,机器上粗粒度的资源就那么几种,无非是CPU,内存,硬盘,和网络。那么我们来看下SQL需要消耗哪些资源:

  • 比较、排序、SQL解析、函数或逻辑运算需要用到CPU;
  • 缓存数据访问,临时数据存放需要用到内存;
  • 冷数据读取,大数据量的排序和关联,数据写入落盘,需要访问硬盘;
  • SQL请求交互,结果集返回需要网络资源。

那么SQL优化思路自然是减少SQL的解析,减少复杂的运算,减少数据处理的规模,减少对物理IO的依赖,减少服务器和客户端的网络交互, 本文的每一节都解决上面的一两点,索引策略的组合最大化提升SQL优化性能:

  • 独立的列: 减少SQL的解析
  • 前缀索引和索引选择性: 减少数据处理的规模,减少对物理IO的依赖
  • 多列索引:减少对物理IO的依赖
  • 选择和是的索引列顺序: 减少数据处理的规模,减少对物理IO的依赖
  • 聚簇索引: 减少数据处理的规模,减少对物理IO的依赖
  • 覆盖索引: 减少对物理IO的依赖
  • 使用索引扫描来做排序: 减少复杂的运算
  • 返回必要的列: 减少对物理IO的依赖,减少服务器和客户端的网络交互

在学习MySQL索引之前,最好先学习MySQL索引背后的数据结构及算法原理

独立的列

独立的列是指索引列不能是表达式的一部分,也不能是函数的参数。

例如:下面这个查询无法使用actor_id列的索引:

mysql> explain select actor_id from actor where actor_id + 1 = 5;
+----+-------------+-------+-------+---------------+---------------------+---------+------+------+--------------------------+
| id | select_type | table | type  | possible_keys | key                 | key_len | ref  | rows | Extra                    |
+----+-------------+-------+-------+---------------+---------------------+---------+------+------+--------------------------+
|  1 | SIMPLE      | actor | index | NULL          | idx_actor_last_name | 137     | NULL |  200 | Using where; Using index |
+----+-------------+-------+-------+---------------+---------------------+---------+------+------+--------------------------+

凭肉眼容易看出where的表达式其实等价于actor_id=4,但是MySQL无法自动解析这个函数。所以应该简化where条件:始终将索引列单独放在比较符号的一侧,使用索引的正确写法如下,此时使用主键索引:

mysql> explain select actor_id from actor where actor_id = 4;
+----+-------------+-------+-------+---------------+---------+---------+-------+------+-------------+
| id | select_type | table | type  | possible_keys | key     | key_len | ref   | rows | Extra       |
+----+-------------+-------+-------+---------------+---------+---------+-------+------+-------------+
|  1 | SIMPLE      | actor | const | PRIMARY       | PRIMARY | 2       | const |    1 | Using index |
+----+-------------+-------+-------+---------------+---------+---------+-------+------+-------------+

下面是另外一个常见的错误:

mysql>select ...  where to_days(current_date)-to_days(date_col) <=10;

前缀索引和索引选择性

有时候索引很长的字符列,让索引变得大且慢。通常索引开始的部分字符,可以大大节约索引空间,从而提高索引效率。但这样也会降低索引的选择性。 索引的选择性是指:不重复的索引值(也称为基数,Cardinality)和数据表的记录总数(#T)的比值,范围是 1/#T ~ 1。索引的选择性越高则查询效率越高,因为选择性高的索引让MySQL在查找时过滤掉更多的行。唯一索引的选择性是1,这是最好的索引选择性,性能也是最好的。

一般情况下某个列前缀的选择性如果足够高,也是可以满足查询性能。对于BLOB、TEXT或者很长的VARCHAR类型的列,必须使用前缀索引,因为MySQL不允许索引这些列的完整长度。如下所示,varchar(4000)类型的comment列最多只能建前缀长度为255的索引。

mysql> show create table shop;
+-------+-------------------------------------------------------------------------------------------------------------------------------+
| Table | Create Table                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
+-------+-------------------------------------------------------------------------------------------------------------------------------+
| shop  | CREATE TABLE `demo` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '记录ID',
  `comment` varchar(4000) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_comment` (`comment`(255))
) ENGINE=InnoDB AUTO_INCREMENT=20001 DEFAULT CHARSET=utf8              
|
+-------+-------------------------------------------------------------------------------------------------------------------------------+

诀窍在于选择足够长的前缀以保证较高的选择性,同时又不能太长(以便节约空间)。前缀应该足够长,以使得前缀索引的选择性接近于索引整个列。换句话说,前缀的”基数“应该接近于完整列的”基数“。

为了决定前缀的合适长度,需要找到最常见的值的列表,然后和最常见的前缀列表进行比较。在示例数据Sakila没有合适的例子,所以我们从表city生成一个示例表,生成足够的数据用来演示:

mysql> CREATE TABLE city_demo (city VARCHAR(50) NOT NULL);

mysql> INSERT INTO city_demo(city) SELECT city from city;
Records: 600  Duplicates: 0  Warnings: 0

重复执行下面的sql 五次:

mysql> insert into city_demo(city) select city from city_demo;
Records: 600  Duplicates: 0  Warnings: 0

执行下面sql 随机分布数据:

mysql> update city_demo set city = (select city from city order by RAND() limit 1);
Rows matched: 19200  Changed: 19170  Warnings: 0

示例数据集分布是随机生成的,与你的结果会有所不同,但是对于结论没有影响。首先,我们找到最常见的城市列表:

mysql> select count(*) as cnt, city from city_demo group by city order by cnt desc limit 10;
+-----+-------------------+
| cnt | city              |
+-----+-------------------+
|  60 | London            |
|  49 | Skikda            |
|  48 | Izumisano         |
|  47 | Valle de Santiago |
|  47 | Tegal             |
|  46 | Goinia            |
|  46 | Tychy             |
|  46 | Idfu              |
|  46 | Clarksville       |
|  46 | Paarl             |
+-----+-------------------+

注意到,上面每个值都出现了46-60次,现在查找到最频繁出现的城市前缀,先从3个前缀字母开始:

mysql> select count(*) as cnt,left(city,3) as pref from city_demo group by pref order by cnt desc limit 10;
+-----+------+
| cnt | pref |
+-----+------+
| 453 | San  |
| 195 | Cha  |
| 161 | Tan  |
| 157 | Sou  |
| 148 | Shi  |
| 146 | Sal  |
| 145 | al-  |
| 140 | Man  |
| 137 | Hal  |
| 134 | Bat  |
+-----+------+

每个前缀都比原来的城市出现的次数要多,因此唯一前缀比唯一城市要少得多。然后我们增加前缀长度,直到这个前缀的选择性接近完整列的选择性。经过实验发现前缀长度为7时最为合适:

mysql> select count(*) as cnt,left(city,7) as pref from city_demo group by pref order by cnt desc limit 10;
+-----+---------+
| cnt | pref    |
+-----+---------+
|  74 | Valle d |
|  70 | Santiag |
|  61 | San Fel |
|  60 | London  |
|  49 | Skikda  |
|  48 | Izumisa |
|  47 | Tegal   |
|  46 | Tychy   |
|  46 | Goinia  |
|  46 | Idfu    |
+-----+---------+

计算合适的前缀长度的另外一个方法就是计算完整列的选择性,并使前缀的选择性接近于完整列的选择性。下面展示计算完整列的选择性:

mysql> select count(distinct city)/count(*) from city_demo;
+-------------------------------+
| count(distinct city)/count(*) |
+-------------------------------+
|                        0.0312 |
+-------------------------------+

通常来说(尽管也有例外情况),这个例子中如果前缀的选择性能够接近于0.031,基本上就可用了。可以在一个查询中针对不同的前缀长度进行计算,这对于大表非常有用。下面给出了如何在同一个查询中计算不同前缀长度的选择性:

mysql> select 
    -> count(distinct left(city,3))/count(*) as sel3,
    -> count(distinct left(city,4))/count(*) as sel4,
    -> count(distinct left(city,5))/count(*) as sel5,
    -> count(distinct left(city,6))/count(*) as sel6,
    -> count(distinct left(city,7))/count(*) as sel7
    -> from city_demo;
+--------+--------+--------+--------+--------+
| sel3   | sel4   | sel5   | sel6   | sel7   |
+--------+--------+--------+--------+--------+
| 0.0239 | 0.0293 | 0.0305 | 0.0309 | 0.0310 |
+--------+--------+--------+--------+--------+

查询显示当前缀长度达7的时候,再增加前缀长度,选择性提升的幅度已经很小,但是增加的前缀索引占用空间。

只看平均选择性是不够的,也有例外的情况,需要考虑最坏情况下的选择性。平均选择性会让你认为前缀长度为4或者5的索引已经足够了,但如果数据分布很不均匀,可能就会有陷阱。如果观察前缀为4的最常出现城市的次数,可以看到明显不均匀:

mysql> select count(*) as cnt,left(city,4) as pref from city_demo group by pref order by cnt desc limit 5;
+-----+------+
| cnt | pref |
+-----+------+
| 198 | Sant |
| 186 | San  |
| 124 | Sout |
| 106 | Toul |
| 102 | Chan |
+-----+------+

如果前缀是4个字节,则最常出现的前缀的出现次数比最常出现的城市的出现次数要大很多。即这些值的选择性比平均选择性要低。如果有比这个随机生成的示例更真实的数据,就更有可能看到这种现象。例如在真实的城市名上建一个长度为4的前缀索引,对于以“San”和“New”开头的城市的选择性就会非常糟糕,因为很多城市都以这两个词开头。

在上面的示例中,已经找到了合适的前缀长度,下面演示一下如何创建前缀索引:

mysql>alter table city_demo add index idx_city(city(7));

前缀索引是一种能使索引更小更快的有效办法,但另一方面也有其缺点:MySQL无法使用前缀索引做ORDER BY和GROUP BY,也无法使用前缀索引做覆盖扫描

有时候后缀索引(suffix index)也有用途(例如,找到某个域名的所有电子邮件地址)。MySQL原生并不支持反向索引,但是可以把字符串反转后存储,并基于此建立前缀索引。可以通过触发器来维护这种索引。

多列索引

很多人对多列索引的理解都不够。一个常见的错误就是,为每个列创建独立的索引,或者按照错误的顺序创建多列索引。

先来看第一个问题,为每个列创建独立的索引,从show create table 中很容易看到这种情况:

create talbe t (
        c1 int,
        c2 int,
        c3 int,
        key(c1),
        key(c2),
        key(c3)
);

这种索引策略,一般是人们听到一些专家诸如“把where条件里面的列都建上索引”这样模糊的建议导致的。实际上这个建议非常错误。这样一来最好的情况下也只能是“一星”索引(关于三星索引可以参考拙作《高性能MySQL》读后感——B-Tree索引的三星索引说明),其性能比起真正最优的索引可能差几个数量级。有时如果无法设计一个“三星”索引,那么不如忽略掉where子句,集中精力优化索引列的顺序,或者创建一个全覆盖索引。

在多个列上建立独立的单列索引大部分情况下并不能提高MySQL的查询性能。MySQL 5.0和更新的版本引入了一种叫”索引合并”(index merge)策略,一定程度上可以使用表上的多个单列索引来定位指定的行。

索引合并策略有时候是一种优化的结果,但大多数时候说明表索引建得很糟糕:

  • 当出现服务器对多个索引做相交操作时(通常有多个AND条件),通常意味着需要一个包含所有相关列的多列索引,而不是多个独立的单列索引。
  • 当服务器需要对多个索引做联合操作时(通常有多个OR条件),通常需要耗费大量CPU和内存资源在算法的缓存、排序和合并操作上。特别是当其中有些索引的选择性不高,需要合并扫描返回的大量数据的时候。
  • 更重要的是,优化器不会把这些计算到“查询成本”(cost)中,优化器只关心随机页面读取。这使得查询的成本被“低估”,导致该执行计划还不如直接走全表扫描。这样做不但消耗更多的CPU和内存资源,还可能影响查询的并发性,但如果是单独运行这样的查询,则往往忽略对并发性的影响。

如果在explain中看到有索引合并,应该好好检查一下查询和表的结构,看是不是已经是最优的。也可以通过参数optimizer_switch来关闭索引合并功能。也可以使用ignore index提示让优化器忽略掉某些索引。

选择合适的索引列顺序

我们遇到的最容易引起困惑的问题就是索引列的顺序。正确的顺序依赖于使用该索引的查询,并且同时需要考虑如何更好地满足排序和分组的需要(顺便说明,本节内容适用于B-Tree索引;哈希或者其他类型的索引并不会像B-Tree索引一样按顺序存储数据)。

在一个多列B-Tree索引中,索引列的顺序意味着索引首先按照最左列进行排序,其次是第二列,等等。所以,索引可以按照升序或者降序进行扫描,以满足精确符合列顺序order by,group by和distinct等子句的查询需求。

所以多列索引的列顺序至关重要。

对于如何选择索引的顺序有一个经验法则:将选择性最高的列放到索引最前列。这个建议有用吗?在某些场景可能有帮助,但通常不如避免随机IO和排序那么重要,考虑问题需要更全面(场景不同则选择不同,没有一个放之四海皆准的法则。这里只是说明,这个经验法则可能没有你想象的重要)。

当不需要考虑排序和分组时,将选择性最高的列放在前面通常是最好的。这时候索引的作用只是用于优化where条件的查找。在这种情况下,这样设计的索引确实能够最快的过滤出需要的行,对于在where子句中使用了索引部分前缀列的查询来说选择性也更高。然而,性能不只是依赖于所有索引列的选择性(整体基数),和查询条件的具体值也有关系,也就是和值的分布有关。这和前面介绍的选择前缀的长度需要考虑的地方一样。可能需要根据那些运行频率最高的查询来调整索引列的顺序,让这种情况下索引的选择性最高。

以下面的查询为例:

select * from payment where staff_id=2 and customer_id=584;

是应该创建一个(staff_id,customer_id)索引还是应该颠倒一下顺序?可以跑一些查询来确定在这个表中值的分布情况,并确定哪个列的选择性更高。先用下面的查询预测一下,看看各个where条件的分支对应的数据基数有多大:

mysql> select sum(staff_id=2),sum(customer_id=584) from payment \G
*************************** 1. row ***************************
     sum(staff_id=2): 7992
sum(customer_id=584): 30

根据前面的经验法则,应该将索引customer_id放到前面,因为对应条件值的customer_id数量更小。我们再来看看对于这个customer_id的条件值,对应的staff_id列的选择性如何:

mysql> select sum(staff_id=2) from payment where customer_id=584\G
*************************** 1. row ***************************
sum(staff_id=2): 17

这样做有一个地方需要注意,查询的结果非常依赖于特定的具体值。如果按上述办法优化,可能对其他一些条件值的查询不公平,服务器的整体性能可能变得更糟,或者其他某些查询的运行变得不如预期。

如果是从诸如pt-query-digest这样的工具的报告中提取“最差”查询,那么再按上述办法选定的索引顺序往往是非常高效的。如果没有类似的具体查询来运行,那么最好按经验法则来做,因为经验法则考虑的是全局基数和选择性,而不是某个具体查询:

mysql> select count(distinct staff_id)/count(*) as staff_id_selectivity,
    -> count(distinct customer_id)/count(*) as customer_id_selectivity,
    -> count(*)
    -> from payment\G
*************************** 1. row ***************************
   staff_id_selectivity: 0.0001
customer_id_selectivity: 0.0373
               count(*): 16049

customer_id的选择性更高,所以答案是将其作为索引列的第一列:

mysql>alter table payment add index idx_cust_staff_id(customer_id,staff_id);

当使用前缀索引的时候,在某些条件值的基数比正常值高的时候,问题就来了。例如,在某些应用程序中,对于没有登录的用户,都将其用户名记录为”guest”,在记录用户行为的会话表和其他记录用户活动的表中”guest”就成为了一个特殊用户ID。一旦查询涉及这个用户,那么和对于正常用户的查询就大不同了,因为通常有很多会话都是没有登录的。系统账号也会导致类似的问题。一个应用通常都有一个特殊的管理员账号,和普通账号不同,它并不是一个具体的用户,系统中所有的其他用户都是这个用户的好友,所以系统往往通过它向网站的所有用户发送状态通知和其他消息。这个账号的巨大的好友列表很容易导致网站出现服务器性能问题。

覆盖索引

通常大家都会根据查询的where条件来创建合适的索引,不过这只是索引优化的一个方面。设计优秀的索引应该考虑到整个查询,而不单单是where条件部分。索引确实是一种查找数据的高效方式,但是MySQL也可以使用索引来直接获取列的数据,这样就不再索引读取数据行。如果索引的叶子节点中已经包含要查询的数据,那么还有什么必要再回表查询呢?如果一个索引包含(或者说覆盖)所有需要查询的字段的值,我们就称之为“覆盖索引”。

覆盖索引是非常有用的工具,能够极大地提高性能。考虑一下如果查询只需要扫描索引而无须回表,会带来多少好处:

  • 索引条目通常远小于数据行大小,如果只读取索引,那么MySQL访问更少数据量。这对缓存的负载非常重要,因为这种情况下响应时间大部分花费在数据拷贝上。覆盖索引对于IO密集型的应用也有帮助,因为索引比数据还小,更容易全部放入内存中(这对于MyISAM尤其正确,因为MyISAM能压缩索引以变得更小)。
  • 索引按照列值顺序存储(至少在单个页内是如此),对于IO密集型的范围查询会比随机从磁盘读取每一行数据的IO要少得多。
  • 大多数据引擎能更好的缓存索引。比如MyISAM在内存中只缓存索引,数据则依赖于操作系统来缓存,因此访问数据多一次系统调用。
  • 覆盖索引对于InnoDB表特别有用,因为InnoDB使用聚集索引组织数据。InnoDB的二级索引在叶子节点中保存行的主键值,如果二级索引能够覆盖查询,则可以避免对主键索引的二次查询。

在所有这些场景中,在索引中满足查询的成本一般比查询行要小得多。

对于索引覆盖查询(index-covered query),使用EXPLAIN时,在Extra一列中看到“Using index”。例如,在sakila的inventory表中,有一个组合索引(store_id,film_id),对于只需要访问这两列的查询,MySQL就可以使用覆盖索引,如下:

mysql> EXPLAIN SELECT store_id, film_id FROM inventory\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: inventory
         type: index
possible_keys: NULL
          key: idx_store_id_film_id
      key_len: 3
          ref: NULL
         rows: 5341
        Extra: Using index

在大多数引擎中,只有当查询语句所访问的列是索引的一部分时,索引才会覆盖。但是,InnoDB不限于此,InnoDB的二级索引在叶子节点中存储了primary key的值。例如,sakila.actor表使用InnoDB,而且对于是last_name上有二级索引,所以索引能覆盖那些访问actor_id的查询:

mysql> EXPLAIN SELECT actor_id, last_name FROM actor WHERE last_name = 'HOPPER'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: actor
         type: ref
possible_keys: idx_actor_last_name
          key: idx_actor_last_name
      key_len: 137
          ref: const
         rows: 2
        Extra: Using where; Using index

使用索引扫描来做排序

MySQL有两种方式生成有序的结果:

  • 通过排序操作,Explain 的Extra 输出“Using filesort”, MySQL使用文件排序;
  • 通过索引顺序扫描,Explain的type列值为index,MySQL使用索引扫描排序(不要和Extra 列的“Using index”混淆)。

扫描索引本身很快,因为只需从一条记录移动到紧接着的下一条记录。但如果索引不能覆盖查询所需的全部列,就要每扫描一条索引记录得回表查询一次对应的行。这基本上都是随机IO,因此按索引顺序读取数据的速度通常比顺序的全表扫描慢,尤其是在IO密集型。所以,设计索引时让同一个索引既满足排序,又用于查找行,避免随机IO。

当索引的列的顺序和ORDER BY子句的顺序完全一致,并且所有列的排序方向(倒序和正序)都一样时,MySQL才能使用索引来对结果做排序。如果查询需要关联多张表,则只有ORDER BY子句引用的字段全部为第一个表时,才能使用索引来做排序。ORDER BY子句和Where查询的限制是一样的:需要满足索引的最左前缀的要求。

当MySQL不能使用索引进行排序时,就会利用自己的排序算法(快速排序算法)在内存(sort buffer)中对数据进行排序,如果内存装载不下,它会将磁盘上的数据进行分块,再对各个数据块进行排序,然后将各个块合并成有序的结果集(实际上就是外排序,使用临时表)。
对于filesort,MySQL有两种排序算法。
(1)两次扫描算法(Two passes)
实现方式是先将需要排序的字段和可以直接定位到相关行数据的指针信息取出,然后在设定的内存(通过参数sort_buffer_size设定)中进行排序,完成排序之后再次通过行指针信息取出所需的Columns。
注:该算法是4.1之前采用的算法,它需要两次访问数据,尤其是第二次读取操作会导致大量的随机I/O操作。另一方面,内存开销较小。

(2)一次扫描算法(single pass)
该算法一次性将所需的Columns全部取出,在内存中排序后直接将结果输出。
注:从 MySQL 4.1 版本开始使用该算法。它减少了I/O的次数,效率较高,但是内存开销也较大。如果我们将并不需要的Columns也取出来,就会极大地浪费排序过程所需要的内存。在 MySQL 4.1 之后的版本中,可以通过设置 max_length_for_sort_data 参数来控制 MySQL 选择第一种排序算法还是第二种。当取出的所有大字段总大小大于 max_length_for_sort_data 的设置时,MySQL 就会选择使用第一种排序算法,反之,则会选择第二种。为了尽可能地提高排序性能,我们自然更希望使用第二种排序算法,所以在 Query 中仅仅取出需要的 Columns 是非常有必要的。

当对连接操作进行排序时,如果ORDER BY仅仅引用第一个表的列,MySQL对该表进行filesort操作,然后进行连接处理,此时,EXPLAIN输出“Using filesort”;否则,MySQL必须将查询的结果集生成一个临时表,在连接完成之后进行filesort操作,此时,EXPLAIN输出“Using temporary;Using filesort”。

当前导列为常量时,ORDER BY子句可以不满足索引的最左前缀要求。例如,Sakila数据库的表rental在列(rental_date,inventory_id,customer_id)上有名为rental_date的索引,如下表所示。

CREATE TABLE `rental` (
  `rental_id` int(11) NOT NULL AUTO_INCREMENT,
  `rental_date` datetime NOT NULL,
  `inventory_id` mediumint(8) unsigned NOT NULL,
  `customer_id` smallint(5) unsigned NOT NULL,
  `return_date` datetime DEFAULT NULL,
  `staff_id` tinyint(3) unsigned NOT NULL,
  `last_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`rental_id`),
  UNIQUE KEY `rental_date` (`rental_date`,`inventory_id`,`customer_id`),
  KEY `idx_fk_inventory_id` (`inventory_id`),
  KEY `idx_fk_customer_id` (`customer_id`),
  KEY `idx_fk_staff_id` (`staff_id`),
  CONSTRAINT `fk_rental_customer` FOREIGN KEY (`customer_id`) REFERENCES `customer` (`customer_id`) ON UPDATE CASCADE,
  CONSTRAINT `fk_rental_inventory` FOREIGN KEY (`inventory_id`) REFERENCES `inventory` (`inventory_id`) ON UPDATE CASCADE,
  CONSTRAINT `fk_rental_staff` FOREIGN KEY (`staff_id`) REFERENCES `staff` (`staff_id`) ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=16050 DEFAULT CHARSET=utf8;

MySQL使用rental_date索引为下面的查询排序,从EXPLAIN中看出没有出现filesort

mysql> EXPLAIN SELECT rental_id, staff_id FROM rental WHERE rental_date = '2005-05-25' ORDER BY inventory_id, customer_id\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: rental
         type: ref
possible_keys: rental_date
          key: rental_date
      key_len: 8
          ref: const
         rows: 1
        Extra: Using where

即使ORDER BY 字句不满足最左前缀索引,也可以用于查询排序,因为索引的第一列被指定为常数。

下面这个查询可以利用索引排序,是因为查询为索引的第一列提供了常量条件,用第二列进行排序,将两列组合在一起,就形成了索引的最左前缀:
... where rental_date = '2005-05-25' order by inventory_id desc;
下面这个查询也没问题,因为order by使用的就是索引的最左前缀:
... where rental_data > '2005-05-25' order by rental_date,inventory_id;

下面一些不能使用索引做排序的查询:

  • 下面这个查询使用两种不同的排序方向:
    ... where rental_date = '2005-05-25' order by inventory_id desc,customer_id asc;
  • 下面这个查询的order by 子句中引用一个不在索引中的列(staff_id):
    ... where rental_date = '2005-05-25' order by inventory_id,staff_id;
  • 下面这个查询的where 和 order by的列无法组合成索引的最左前缀:
    ... where rental_date = '2005-05-25' order by customer_id;
  • 下面这个查询在索引列的第一列是范围条件,所以MySQL无法使用索引的其余列:
    ... where rental_date > '2005-05-25' order by customer_id;
  • 这个查询在inventory_id列上有多个等于条件。对于排序来说,这也是一种范围查询:
    ... where rental_date = '2005-05-25' and inventory_id in(1,2) order by customer_id;

压缩(前缀压缩)索引

MyISAM使用前缀压缩来减少索引的大小,让更多的索引可以放入内存中,这在某些情况下能极大地提高性能。默认只压缩字符串,但通过参数设置也可以对整数做压缩。MyISAM压缩每个索引块的方法是,先完全保存索引块中的第一个值,然后将其他值和第一个值进行比较得到相同前缀的字节数和剩余的不同后缀部分,把这部分存储起来即可。例如:索引块中的第一个值是“perform”,第二个值是“performance”,那么第二个值的前缀压缩后存储的是类似“7,ance”这样的形式。MyISAM对行指针也采用类似的前缀压缩方式。

压缩块使用更少的空间,代价是某些操作可能更慢。因为每个值的压缩前缀都依赖前面的值,所以MyISAM查找时无法在索引块使用二分查找而只能从头开始扫描。正序的扫描速度还不错,但是如果是倒序扫描——例如ORDER BY DESC——就不是很好。所以在块中查找某一行的操作平均都需要扫描半个索引块。

测试表明,对于CPU密集型应用,因为扫描需要随机查找,压缩索引使得MyISAM在索引查找上要慢好几倍。压缩索引的倒序扫描就更慢了。压缩索引需要在CPU内存资源与磁盘之间做权衡。压缩索引可能只需要十分之一大小的磁盘空间,如果是IO密集型应用,对某些查询带来的好处会比成本多很多。

可以在CREATE TABLE 语句中指定pack_keys参数来控制索引压缩的方式。

冗余和重复索引

MySQL允许在相同列上创建多个索引,MySQL需要单独维护重复的索引,并且优化器在优化查询的时候需要逐个地进行考虑,影响查询性能。

重复索引是指在相同的列上按照相同的顺序创建相同类型的索引。应该避免这样创建重复索引,发现以后也应该立即移除。

如下面的代码,创建一个主键,先加上唯一限制,然后再加上索引以供查询使用。事实上,MySQL的唯一限制和主键限制都是通过索引实现的,因此,实际上在相同的列上创建了三个重复的索引。通常并没有理由这样做,除非在同一列上创建不同类型的索引来满足不同的查询需求。

create table test(
  id int not null primary key,
  a int not null,
  b int not null,
  unique(id),
  index(id)
) engine=InnoDB;

冗余索引和重复索引有一些不同。如果创建了索引(a,b),再创建索引(a)就是冗余索引,因为这只是前一个索引的前缀索引。因此索引(a,b)也可以当作索引(a)来使用(这种冗余只是对B-Tree索引来说的)。但是如果再创建索引(b,a),则不是冗余索引,索引(b)也不是,因为b不是索引(a,b)的最左前缀列。另外其他不同类型的索引(例如哈希索引或者全文索引)也不会是B-Tree索引的冗余索引,而无论覆盖的索引列是什么。

冗余索引通常发生在为表添加新索引的时候。例如,有人可能会增加一个新的索引(a,b)而不是扩展已有的索引(a)。还有一种情况是将一个索引扩展为(a,id),其中id是主键,对于InnoDB来说主键列已经包含在二级索引中了,所以这也是冗余的。

大多数情况下都不需要冗余索引,应该尽量扩展已有的索引而不是创建新索引。但也有时候出于性能方面的考虑需要冗余索引,因为扩展已有的索引会导致其变得太大,从而影响其他使用该索引的查询的性能。

例如:如果在整数列上有一个索引,现在需要额外增加一个很长的varchar列来扩展该索引,那性能可能会急剧下降。特别是有查询把这个索引当作覆盖索引,或者这是MyISAM表并且有很多范围查询(由于MyISAM的前缀压缩)的时候。

举例,MyISAM引擎,表userinfo有100W行记录,每个state_id值大概2W行,在state_id列有一个索引对下面的查询有用,假设查询名为Q1:

mysql> select count(*) from userinfo where state_id=5;

查询测试结果:QPS=115。还有一个相关查询检索几个列的值,而不是统计行数,假设名为Q2:

mysql> select state_id,city,address from userinfo where state_id=5;

查询测试结果:QPS<10。提升该查询的性能可以扩展索引为为(state_id, city, address),让索引覆盖查询:

mysql> ALTER TABLE userinfo DROP KEY state_id, 
    ->     ADD KEY state_id_2 (state_id, city, address);

如果把state_id索引扩展为(state_id,city,address),那么第二个查询的性能更快了,但是第一个查询却变慢了,如果要两个查询都快,那么就必须要把state_id列索引进行冗余了。但如果是innodb表,不冗余state_id列索引对第一个查询的影响并不明显,因为innodb没有使用索引压缩,

MyISAM和InnoDB表使用不同索引策略的查询QPS测试结果(以下测试数据仅供参考):

只有state_id列索引 只有state_id_2索引 同时有state_id和state_id_2
MyISAM, Q1 114.96 25.40 112.19
MyISAM, Q2 9.97 16.34 16.37
InnoDB, Q1 108.55 100.33 107.97
InnoDB, Q2 12.12 28.04 28.06

上表结论:

  • 对于MyISAM引擎,把state_id扩展为state_id_2(state_id,city,address),Q2的QPS更高,覆盖索引起作用;但是Q1的QPS下降明显,受MyISAM的前缀压缩影响需要从索引块头开始扫描。
  • 对于InnoDB引擎,把state_id扩展为state_id_2(state_id,city,address),Q2的QPS更高,覆盖索引起作用;但是Q1的QPS下降不明显,因为InnoDB没有使用索引压缩。
  • MyISAM引擎需要建state_id和state_id_2索引,才能保证Q1/Q2性能最佳;而InnoDB引擎只需state_id_2索引就能保证Q1/Q2性能最佳,从这里看出,索引压缩也并不是最好的。

有两个索引的缺点是索引成本更高,下表是在不同的索引策略时插入InnoDB和MyISAM表100W行数据的速度(以下测试数据仅供参考):

只有state_id列索引 同时有state_id和state_id_2
InnoDB, 对有两个索引都有足够的内容的时候 80秒 136秒
MyISAM, 只有一个索引有足够的内容的时候 72秒 470秒

可以看到,不论什么引擎,索引越多,插入速度越慢,特别是新增索引后导致达到了内存瓶颈的时候,所以,要避免冗余索引和重复索引。

在删除索引的时候要非常小心:如果在InnoDB引擎表上有where a=5 order by id 这样的查询,那么索引(a)就会很有用,索引(a,b)实际上是(a,b,id)索引,这个索引对于where a=5 order by id 这样的查询就无法使用索引做排序,而只能使用文件排序(filesort)。
举例说明,表shop表结构如下:

CREATE TABLE `shop` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '记录ID',
  `shop_id` int(11) NOT NULL COMMENT '商店ID',
  `goods_id` int(11) NOT NULL COMMENT '物品ID',
  `pay_type` tinyint(1) NOT NULL COMMENT '支付方式',
  `price` decimal(10,2) NOT NULL COMMENT '物品价格',
  `comment` varchar(4000) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `shop_id` (`shop_id`,`goods_id`),
  KEY `price` (`price`),
  KEY `pay_type` (`pay_type`),
  KEY `idx_comment` (`comment`(255))
) ENGINE=InnoDB AUTO_INCREMENT=20001 DEFAULT CHARSET=utf8 COMMENT='商店物品表'

如下情况,使用pay_type索引:

mysql> explain select * from shop where pay_type = 2 order by id\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: shop
         type: ref
possible_keys: pay_type
          key: pay_type
      key_len: 1
          ref: const
         rows: 9999
        Extra: Using where

如下情况,虽然使用shop_id索引,但是无法使用索引做排序,EXPLAIN出现filesort:

mysql> explain select * from shop where shop_id = 2 order by id\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: shop
         type: ref
possible_keys: shop_id
          key: shop_id
      key_len: 4
          ref: const
         rows: 1
        Extra: Using where; Using filesort

如下情况,当WHERE 条件覆盖索引shop_id的所有值时,使用索引做排序,EXPLAIN没有filesort:

mysql> explain select * from shop where shop_id = 2 and goods_id = 2 order by id\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: shop
         type: const
possible_keys: shop_id
          key: shop_id
      key_len: 8
          ref: const,const
         rows: 1
        Extra: 

索引和锁

索引可以让查询锁定更少的行。如果你的查询从不访问那些不需要的行,那么就会锁定更少的行,从两个方面来看这对性能都有好处。首先,虽然InnoDB的行锁效率很高,内存使用也很少,但是锁定行的时候仍然会带来额外开销;其次,锁定超过需要的行会增加锁争用并减少并发性。

InnoDB只有在访问行的时候才会对其加锁,而索引能够减少InnoDB访问的行数,从而减少锁的数量。但这只有当InnoDB在存储引擎层能够过滤掉所有不需要的行时才有效。如果索引无法过滤掉无效的行,那么在InnoDB检索到数据并返回给服务器层以后,MySQL服务器才能应用 where子句。这时已经无法避免锁定行了:InnoDB已经锁住这些行,到适当的时候才释放。在MySQL5.1及更新的版本中,InnoDB可以在服务器端过滤掉行后就释放锁。

下面的例子再次使用Sakila很好的解释这些情况:

图1 索引和锁(1)

这条查询只返回2~4行数据,实际上获取1~4行排他锁。InnoDB锁住第1行,因为MySQL为该查询选择的执行计划是索引范围扫描:

mysql> explain select actor_id from actor where actor_id < 5 and actor_id <> 1 FOR UPDATE\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: actor
         type: range
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 2
          ref: NULL
         rows: 3
        Extra: Using where; Using index

换句话说,底层存储引擎的操作是“从索引的开头获取满足条件 actor_id < 5 的记录”,服务器并没有告诉InnoDB可以过滤第1行的WHERE 条件。Explain的Extra出现“Using Where”表示MySQL服务器将存储引擎返回行以后再应用WHERE 过滤条件。

我们来证明第1行确实是被锁定,保持这个终端链接不关闭,然后我们打开另一个终端。如图2,这个查询会挂起,直到第1个事务释放第1行的锁。


图2 索引和锁(2)

按照这个例子,即使使用索引,InnoDB也可能锁住一些不需要的数据。如果不能使用索引查找和锁定行的话,结果会更糟。MySQL会全表扫描并锁住所有的行,而不管是不是需要。

推荐阅读更多精彩内容