MySQL之索引

Posted 北洛

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了MySQL之索引相关的知识,希望对你有一定的参考价值。

高性能的索引策略

正确地创建和使用索引是实现高性能查询的基础。在MySQL之索引(一)这一章中我们介绍了各种类型的索引及其对应的优缺点。现在我们一起来看看如何真正地发挥这些索引的优势。

独立的列

我们通常会看到一些查询不当地使用索引,或者使得mysql无法使用已有的索引。如果查询中的列不是独立的,则MySQL就不会使用索引。“独立的列”是指索引列不能是表达式的一部分,也不能是函数的参数。 

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

mysql> SELECT actor_id FROM actor WHERE actor_id + 1 = 5;

  

很容易看出WHERE中的表达式其实等价于actor_id = 4,但是MySQL无法自动解析这个方程式。这完全是用户行为。我们应该养成简化WHERE条件的习惯,始终将索引列单独放在比较符号的一侧。
下面是另一个常见的错误:

SELECT date_col FROM actor WHERE TO_DAYS(CURRENT_DATE) – TO_DAYS(date_col) <= 10;

  

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

一般情况下某个列前缀的选择性也是足够高的,足以满足查询性能。对于BLOB、TEXT或很长的VARCHAR类型的列,必须使用前缀索引,即只对列的前面几个字符进行索引,因为MySQL不允许索引这些列的完整长度。

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

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

数据集下载:sakila

mysql> CREATE TABLE city_demo (city VARCHAR(50) NOT NULL);
Query OK, 0 rows affected (0.10 sec)

mysql> INSERT INTO city_demo(city) SELECT city from city;
Query OK, 600 rows affected (0.03 sec)
Records: 600  Duplicates: 0  Warnings: 0

  

重复执行下面的SQL五次:

mysql> INSERT INTO city_demo(city) SELECT city FROM city_demo;
Query OK, 600 rows affected (0.03 sec)
Records: 600  Duplicates: 0  Warnings: 0

  

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

mysql> UPDATE city_demo SET city = (SELECT city FROM city ORDER BY RAND() limit 1);
Query OK, 19179 rows affected (16.88 sec)
Rows matched: 19200  Changed: 19179  Warnings: 0

  

现在我们有了示例数据集。数据分布当然不是真实的分布,因为我们使用了RAND(),所以不同的人的结果各不相同,但这个并不重要。首先,我们找到最常见的城市列表:

mysql> SELECT COUNT(*) as cnt, city FROM city_demo GROUP BY city ORDER BY cnt DESC LIMIT 10;
+-----+------------+
| cnt | city       |
+-----+------------+
|  59 | London     |
|  52 | Elista     |
|  49 | Kamyin     |
|  48 | Kolpino    |
|  48 | Tabuk      |
|  47 | al-Qatif   |
|  46 | Tegal      |
|  46 | Ambattur   |
|  46 | Lubumbashi |
|  46 | Karnal     |
+-----+------------+
10 rows in set (0.03 sec)

  

上面每个值都出现了46~59次。现在查找最频繁出现的城市前缀,先从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 |
+-----+------+
| 449 | San  |
| 189 | Sal  |
| 182 | Cha  |
| 169 | al-  |
| 152 | Tan  |
| 149 | Sou  |
| 136 | Man  |
| 130 | Shi  |
| 128 | Bat  |
| 127 | Kam  |
+-----+------+
10 rows in set (0.03 sec)

  

每个前缀都比原来的城市出现的次数更多,因此唯一前缀比唯一城市要少得多。然后我们增加前缀的长度,直到这个前缀的选择性接近完整列的选择性。经过实验后发现前缀长度为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    |
+-----+---------+
|  65 | San Fel |
|  64 | Santiag |
|  59 | London  |
|  59 | Valle d |
|  52 | Elista  |
|  49 | Kamyin  |
|  48 | Kolpino |
|  48 | Tabuk   |
|  47 | al-Qati |
|  46 | Tegal   |
+-----+---------+
10 rows in set (0.03 sec)

  

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

mysql> SELECT COUNT(DISTINCT city)/COUNT(*) FROM city_demo;
+-------------------------------+
| COUNT(DISTINCT city)/COUNT(*) |
+-------------------------------+
|                        0.0312 |
+-------------------------------+
1 row in set (0.02 sec)

  

通常来说,这个例子中如果前缀的选择性能够接近于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 |
+--------+--------+--------+--------+--------+
1 row in set (0.07 sec)

  

查询显示当前前缀长度到达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 |
+-----+------+
| 194 | San  |
| 192 | Sant |
| 117 | Sout |
|  89 | Chan |
|  87 | Toul |
+-----+------+
5 rows in set (0.03 sec)

  

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

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

mysql> ALTER TABLE city_demo ADD INDEX idx_city(city(7));
Query OK, 0 rows affected (0.10 sec)
Records: 0  Duplicates: 0  Warnings: 0

  

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

多列索引

很多人对多列索引的理解都不够。一个常见的错误就是,为每个列创建独立的索引,或者按照错误的顺序创建多列索引。先来看第一个问题,为每个列创建独立的索引,从show create table 中很容易看到这种情况:

CREATE TABLE t (
	c1 int,
	c2 int,
	c3 int,
	key(c1),
	key(c2),
	key(c3)
);

  

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

三星索引理论

Lahdenmaki和Leach的三星索引理论:

  • 一星:索引将相关的记录放到一起。
  • 二星:索引中的数据顺序和查找中的排列顺序一致。
  • 三星:索引中的列包含了查询中需要的全部列。

在多个列上建立独立的单列索引大部分情况下并不能提高MySQL的查询性能。MySQL5.0和更新的版本引入了一种叫“索引合并”(index merge)策略,一定程度上可以使用表上的多个单列索引来定位指定的行。更早版本的MySQL只能使用其中某一个单列索引,然而这种情况下没有哪一个独立的单列索引是非常有效的。例如,表film_actor在字段film_id和actor_id上各有一个单列索引。但对于下面这个查询WHERE条件,这两个单列索引都不是好的选择:

SELECT film_id,actor_id FROM film_actor WHERE actor_id =1 OR film_id =1;

  

在老的MySQL版本中,MySQL对这个查询会使用全表扫描。除非改写成如下的两个查询UNION方式:

SELECT film_id,actor_id FROM film_actor WHERE actor_id =1 UNION ALL SELECT film_id,actor_id FROM film_actor WHERE film_id = 1 AND actor_id <> 1;

  

但在5.0和更新的版本中,查询能够同时使用这两个单列索引进行扫描,并讲结果进行合并。这种算法有三个变种:OR条件的联合(union);AND条件相交(intersection),组合前两种情况的联合及相交;下面的查询就是使用了两个索引扫描联合,通过EXPLAIN中的Extra列可以看到这点:

mysql> EXPLAIN SELECT film_id,actor_id FROM film_actor WHERE actor_id =1 OR film_id =1\\G;
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: film_actor
   partitions: NULL
         type: index_merge
possible_keys: PRIMARY,idx_fk_film_id
          key: PRIMARY,idx_fk_film_id
      key_len: 2,2
          ref: NULL
         rows: 29
     filtered: 100.00
        Extra: Using union(PRIMARY,idx_fk_film_id); Using where
1 row in set, 1 warning (0.00 sec)

  

MySQL会使用这类技术优化复杂查询,所以在某些语句的Extra列中还可以看到嵌套操作。

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

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

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

选择合适的索引列顺序

我们遇到的最容易引起困惑的问题就是索引列的顺序。正确的顺序依赖于使用该索引的查询,并且同时需要考虑如何更好地满足排序和分组的需要。

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

所以多列索引的顺序至关重要。在“三星索引”系统中,列顺序也决定了一个索引是否能够成为一个真正的“三星索引”。 

对于如何选择索引的列顺序有一个经验法则:将选择性最高的列放到索引最前列。这个建议有用吗?在某些场景可能有帮助,但通常不如避免随机IO和排序那么重要。

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

以下面的查询为例:

mysql> 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
1 row in set (0.01 sec)

  

根据前面的经验法则,应该将索引列custom_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
1 row in set (0.00 sec)

  

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

如果是从诸如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
1 row in set (0.01 sec)

  

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

mysql> ALTER TABLE payment ADD KEY(customer_id, staff_id);
Query OK, 0 rows affected (0.13 sec)
Records: 0  Duplicates: 0  Warnings: 0

  

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

这实际上是一个非常典型的问题。任何的异常用户,不仅仅是那些用于管理应用的设计糟糕的账号会有同样的问题;那些拥有大量好友、图片、状态、收藏的用户,也会有前面提到的系统账号同样的问题。

以上是关于MySQL之索引的主要内容,如果未能解决你的问题,请参考以下文章

Mysql实战篇之普通索引和唯一索引--01

mysql 优化之索引的使用

MySQL之索引

MySQL 之索引类型

MySQL 之索引类型

mysql之高性能索引