MySQL的索引

导航:

  MySQL高性能的三大神器。因为这三章是相辅相成,融汇贯通这三章之后,才有高性能MySQL良好的基础《推荐好书高性能MySQL》

  1.MySQL Schema与数据类型优化

  2.MySQL的索引

  3.MySQL查询性能优化

  索引(在MySQL中也叫做“键(key)”)是存储引擎用于快速找到记录的一种数据结构。这是索引的基本功能,除此之外,本章还将讨论索引其他一些方面有用的属性。

  索引对于良好的性能非常关键。尤其是当表中的数据量越来越大时,索引对性能的影响愈发重要。在数据量较小且负载较低时,不恰当的索引对性能的影响可能还不明显,但当数据量逐渐增大时,性能则会急剧下降

  不过,索引却经常被忽略,有时候甚至被误解,所以在实际案例中经常会遇到由糟糕索 引导致的问题。这也是我们把索引优化放在了靠前的章节,甚至比查询优化还靠前的原因。索引优化应该是对査询性能优化最有效的手段了。索引能够轻易将查询性能提高几个数量级,“最优”的索引有时比一个“好的”索引性能要好两个数量级。创建一个真正“最优”的索引经常需要重写查询,所以,本章和下一章的关系非常紧密。

 

1.索引基础

  要理解MySQL中索引是如何工作的,最简单的方法就是去看看一本书的“索引”部分: 如果想在一本书中找到某个特定主题,一般会先看书的“索引”,找到对应的页码。

  在MySQL中,存储引擎用类似的方法使用索引,其先在索引中找到对应值,然后根据匹配的索引记录找到对应的数据行。假如要运行下面的査询:

mysql> SELECT first_name FROM sakila.actor WHERE actor_id = 5;

  如果在actor_id列上建有索引,则MySQL将使用该索引找到actor_id为5的行,也就是说,MySQL先在索引上按值进行査找,然后返回所有包含该值的数据行。

  索引可以包含一个或多个列的值。如果索引包含多个列,那么列的顺序也十分重要,因为MySQL只能高效地使用索引的最左前缀列。创建一个包含两个列的索引,和创建两个只包含一列的索引是大不相同的,下面将详细介绍。

 

  如果使用的是ORM,是否还需要关心索引?

  简而言之:是的,仍然需要理解索引,即使是使用对象关系映射(ORM)工具。

  ORM工具能够生产符合逻辑的、合法的查询(多数时候),除非只是生成非常基本的查询(例如仅是根据主键查询),否则它很难生成适合索引的查询。无论是多么复杂的ORM工具,在精妙和复杂的索引面前都是“浮云”。读完本章后面的内容以后,你就会同意这个观点的!很多时候,即使是查询优化技术专家也很难兼顾到各种情况,更别说ORM了。

 

1.1 索引的类型

  索引有很多种类型,可以为不同的场景提供更好的性能。在MySQL中,索引是在存储引擎层而不是服务器层实现的。所以,并没有统一的索引标准:不同存储引擎的索引的工作方式并不一样,也不是所有的存储引擎都支持所有类型的索引。即使多个存储引擎支持同一种类型的索引,其底层的实现也可能不同。

  下面我们先来看看MySQL支持的索引类型,以及它们的优点和缺点。

 

  B-Tree索引

  当人们谈论索引的时候,如果没有特别指明类型,那多半说的是B-Tree索引,它使用B-Tree数据结构来存储数据。大多数MySQL引擎都支持这种索引。Archive引擎是一个例外:5.1之前Archive不支持任何索引,直到5.1才开始支持单个自增列(AUT0_INCREMENT)的索引。

  我们使用术语“B-Tree”,是因为MySQL在CREATE TABLE和其他语句中也使用该关键字。

  不过,底层的存储引擎也可能使用不同的存储结构,例如,NDB集群存储引擎内部实际 上使用了T-Tree结构存储这种索引,即使其名字是BTREE;InnoDB则使用的是B+Tree,各种数据结构和算法的变种不在这里的讨论范围之内。

  存储引擎以不同的方式使用B-Tree索引,性能也各有不同,各有优劣。例如,MyISAM使用前缀压缩技术使得索引更小,但InnoDB则按照原数据格式进行存储。再如MyISAM索引通过数据的物理位置引用被索引的行,而InnoDB则根据主键引用被索引的行。

  B-Tree通常意味着所有的值都是按顺序存储的,并且每一个叶子页到根的距离相同。图 5-1展示了B-Tree索引的抽象表示,大致反映了InnoDB索引是如何工作的。MyISAM使用的结构有所不同,但基本思想是类似的。

  B-Tree索引能够加快访问数据的速度,因为存储引擎不再需要进行全表扫描来获取需要的数据,取而代之的是从索引的根节点(图示并未画出)开始进行搜索。根节点的槽中存放了指向子节点的指针,存储引擎根据这些指针向下层査找。通过比较节点页的值和要査找的值可以找到合适的指针进入下层子节点,这些指针实际上定义了子节点页中值的上限和下限。最终存储引擎要么是找到对应的值,要么该记录不存在。

  叶子节点比较特别,它们的指针指向的是被索引的数据,而不是其他的节点页(不同引擎的“指针”类型不同)。图5-1中仅绘制了一个节点和其对应的叶子节点,其实在根节点和叶子节点之间可能有很多层节点页。树的深度和表的大小直接相关。

  B-Tree对索引列是顺序组织存储的,所以很适合査找范围数据。例如,在一个基于文本域的索引树上,按字母顺序传递连续的值进行査找是非常合适的,所以像“找出所有以I到K开头的名字”这样的査找效率会非常髙。

  假设有如下数据表:

CREATE TABLE People (
   last_name  varchar(50)    not null,
   first_name varchar(50)    not null,
   dob        date           not null,
   gender     enum('m', 'f')not null,
   key(last_name, first_name, dob)
);

  对于表中的每一行数据,索引中包含了 last_name、first_name和dob列的值,图5-2显示了该索引是如何组织数据的存储的。

  请注意,索引对多个值进行排序的依据是CREATE TABLE语句中定义索引时列的顺序。看一下最后两个条目,两个人的姓和名都一样,则根据他们的出生日期来排列顺序。

  可以使用B-Tree索引的查询类型。B-Tree索引适用于全键值、键值范围或键前缀査找。其中键前缀查找只适用于根据最左前缀的査找。前面所述的索引对如下类型的査询有效。

  全值匹配

全值匹配指的是和索引中的所有列进行匹配,例如前面提到的索引可用于查找姓名为Cuba Allen、出生于 1960-01-01 的人。

  匹配最左前缀

前面提到的索引可用于査找所有姓为Allen的人,即只使用索引的第一列。

  匹配列前缀

也可以只匹配某一列的值的开头部分。例如前面提到的索引可用于査找所有以J开头的姓的人。这里也只使用了索引的第一列。

  匹配范围值

例如前面提到的索引可用于査找姓在Allen和Barrymore之间的人。这里也只使用了索引的第一列。

  精确匹配某一列并范围匹配另外一列

前面提到的索引也可用于査找所有姓为Allen,并且名字是字母K开头(比如Kim、Karl等)的人。即第一列Jlast_name全匹配,第二列first_name范围匹配。

  只访问索引的查询

B-Tree通常可以支持“只访问索引的査询”,即査询只需要访问索引,而无须访问 数据行。后面我们将单独讨论这种“覆盖索引”的优化。

  因为索引树中的节点是有序的,所以除了按值査找之外,索引还可以用于査询中的ORDER BY操作(按顺序査找)。一般来说,如果B-Tree可以按照某种方式査找到值,那么也可以按照这种方式用于排序。所以,如果ORDER BY子句满足前面列出的几种査询类型,则这个索引也可以满足对应的排序需求。

  下面是一些关于B-Tree索引的限制:

  • 如果不是按照索引的最左列开始査找,则无法使用索引。例如上面例子中的索引无法用于査找名字为Bill的人,也无法査找某个特定生日的人,因为这两列都不是最左数据列。类似地,也无法査找姓氏以某个字母结尾的人。
  • 不能跳过索引中的列。也就是说,前面所述的索引无法用于査找姓为Smith并且在某个特定日期出生的人。如果不指定名(first_name),则MySQL只能使用索引的第一列。
  • 如果查询中有某个列的范围查询,则其右边所有列都无法使用索引优化査找。例如有査WHERE last_name=’Smith' AND first_name LIKE 'J%’ AND dob = ‘1976-12-23’,这个査询只能使用索引的前两列,因为这里LIKE是一个范围条件(但是服务器可以把其余列用于其他目的)。如果范围査询列值的数量有限,那么可以通过使用多个等于条件来代替范围条件。在本章的索引案例学习部分,将演示一个详细的案例。

  到这里读者应该可以明白,前面提到的索引列的顺序是多么的重要:这些限制都和索引列的顺序有关。在优化性能的时候,可能需要使用相同的列但顺序不同的索引来满足不同类型的査询需求。

  也有些限制并不是B-Tree本身导致的,而是MySQL优化器和存储引擎使用索引的方式导致的,这部分限制在未来的版本中可能就不再是限制了。

 

  哈希索引

  哈希索引(hash index)基于哈希表实现,只有精确匹配索引所有列的査询才有效。对于每一行数据,存储引擎都会对所有的索引列计算一个哈希码(hash code),哈希码是一个较小的值,并且不同键值的行计算出来的哈希码也不一样。哈希索引将所有的哈希码存储在索引中,同时在哈希表中保存指向每个数据行的指针。

  在MySQL中,只有Memory引擎显式支持哈希索引。这也是Memory引擎表的默认索引类型,Memory引擎同时也支持B-Tree索引。值得一提的是,Memory引擎是支持非唯一哈希索引的,这在数据库世界里面是比较与众不同的。如果多个列的哈希值相同,索引会以链表的方式存放多个记录指针到同一个哈希条目中。

  下面来看一个例子。假设有如下表:

CREATE TABLE testhash (
   fname VARCHAR(50) NOT NULL,
   lname VARCHAR(50) NOT NULL,
   KEY USING HASH(fname)
) ENGINE=MEMORY;

  表中包含如下数据:

mysql> SELECT * FROM testhash;
+--------+-----------+
| fname  | lname     |
+--------+-----------+
| Arjen  | Lentz     |
| Baron  | Schwartz  |
| Peter  | Zaitsev   |
| Vadim  | Tkachenko |
+--------+-----------+

  假设索引使用假想的哈希函数f(),它返回下面的值(都是示例数据,非真实数据):

f('Arjen')= 2323
f('Baron')= 7437
f('Peter')= 8784
f('Vadim')= 2458

  则哈希索引的数据结构如下:

槽(Slot)    值(Value)
2323           指向第1行的指针
2458           指向第4行的指针
7437           指向第2行的指针
8784           指向第3行的指针

  注意每个槽的编号是顺序的,但是数据行不是。现在,来看如下査询:

mysql> SELECT lname FROM testhash WHERE fname='Peter';

  MySQL先计算'Peter'的哈希值,并使用该值寻找对应的记录指针。因为f('Peter')=8784,所以MySQL在索引中查找8784,可以找到指向第3行的指针,最后一步是比较第三行的值是否为'Peter',以确保就是要査找的行。

  因为索引自身只需存储对应的哈希值,所以索引的结构十分紧凑,这也让哈希索引査找的速度非常快。然而,哈希索引也有它的限制:

  • 哈希索引只包含哈希值和行指针,而不存储字段值,所以不能使用索引中的值来避免读取行。不过,访问内存中的行的速度很快,所以大部分情况下这一点对性能的影响并不明显。
  • 哈希索引数据并不是按照索引值顺序存储的,所以也就无法用于排序。
  • 哈希索引也不支持部分索引列匹配査找,因为哈希索引始终是使用索引列的全部内容来计算哈希值的。例如,在数据列(A,B)上建立哈希索引,如果查询只有数据列A,则无法使用该索引。
  • 哈希索引只支持等值比较査询,包括=、IN()、<=> (注意 <> 和 <=> 是不同的操作)。也不支持任何范围査询,例如WHERE price > 100。
  • 访问哈希索引的数据非常快,除非有很多哈希冲突(不同的索引列值却有相同的哈希值)。当出现哈希冲突的时候,存储引擎必须遍历链表中所有的行指针,逐行进行比较,直到找到所有符合条件的行。
  • 如果哈希冲突很多的话,一些索引维护操作的代价也会很高。例如,如果在某个选择性很低(哈希冲突很多)的列上建立哈希索引,那么当从表中删除一行时,存储引擎需要遍历对应哈希值的链表中的每一行,找到并删除对应行的引用,冲突越多,代价越大。

  因为这些限制,哈希索引只适用于某些特定的场合。而一旦适合哈希索引,则它带来的性能提升将非常显著。举个例子,在数据仓库应用中有一种经典的“星型”schema,需要关联很多査找表,哈希索引就非常适合査找表的需求。

  除了Memory引擎外,NDB集群引擎也支持唯一哈希索引,且在NDB集群引擎中作用非常特殊,但这不属于这里讲解的范围。

  InnoDB引擎有一个特殊的功能叫做“自适应哈希索引(adaptive hash index)”。当InnoDB注意到某些索引值被使用得非常频繁时,它会在内存中基于B-Tree索引之上再创建一个哈希索引,这样就让B-Tree索引也具有哈希索引的一些优点,比如快速的哈希査找。这是一个完全自动的、内部的行为,用户无法控制或者配置,不过如果有必要,完全可以关闭该功能。

  创建自定义哈希索引。如果存储引擎不支持哈希索引,则可以模拟像InnoDB—样创建哈希索引,这可以享受一些哈希索引的便利,例如只需要很小的索引就可以为超长的键创建索引。

  思路很简单:在B-Tree基础上创建一个伪哈希索引。这和真正的哈希索引不是一回事, 因为还是使用B-Tree进行査找,但是它使用哈希值而不是键本身进行索引査找。你需要做的就是在査询的WHERE子句中手动指定使用哈希函数。

  下面是一个实例,例如需要存储大量的URL,并需要根据URL进行搜索査找。如果使用B-Tree来存储URL,存储的内容就会很大,因为URL本身都很长。正常情况下会有如下査询:

mysql> SELECT id FROM url WHERE url="http://www.mysql.com";

  若删除原来URL列上的索引,而新增一个被索引的url_crc列,使用CRC32做哈希,就可以使用下面的方式査询:

mysql> SELECT id FROM url WHERE url="http://www.mysql.com"
    ->    AND url_crc=CRC32("http://www.mysql.com");

  这样做的性能会非常高,因为MySQL优化器会使用这个选择性很高而体积很小的基于url_crc列的索引来完成査找(在上面的案例中,索引值为1560514994)。即使有多个记录有相同的索引值,査找仍然很快,只需要根据哈希值做快速的整数比较就能找到索引条目,然后一一比较返回对应的行。另外一种方式就是对完整的URL字符串做索引,那样会非常慢。

  这样实现的缺陷是需要维护哈希值。可以手动维护,也可以使用触发器实现。下面的案 例演示了触发器如何在插入和更新时维护url_crc列。首先创建如下表:

CREATE TABLE pseudohash (
   id int unsigned NOT NULL auto_increment,
   url varchar(255) NOT NULL,
   url_crc int unsigned NOT NULL DEFAULT 0,
   PRIMARY KEY(id)
);

  然后创建触发器。先临时修改一下语句分隔符,这样就可以在触发器定义中使用分号:

DELIMITER //
 
CREATE TRIGGER pseudohash_crc_ins BEFORE INSERT ON pseudohash FOR EACH ROW BEGIN
SET NEW.url_crc=crc32(NEW.url);
END;
//
 
CREATE TRIGGER pseudohash_crc_upd BEFORE UPDATE ON pseudohash FOR EACH ROW BEGIN
SET NEW.url_crc=crc32(NEW.url);
END;
//
 
DELIMITER ;

  剩下的工作就是验证一下触发器如何维护哈希索引:

mysql> INSERT INTO pseudohash (url) VALUES ('http://www.mysql.com');
mysql> SELECT * FROM pseudohash;
+----+----------------------+------------+
| id | url                  | url_crc    |
+----+----------------------+------------+
|  1 | http://www.mysql.com | 1560514994 |
+----+----------------------+------------+
mysql> UPDATE pseudohash SET url='http://www.mysql.com/' WHERE id=1;
mysql> SELECT * FROM pseudohash;
+----+---------------------- +------------+
| id | url                   | url_crc    |
+----+---------------------- +------------+
|  1 | http://www.mysql.com/ | 1558250469 |
+----+---------------------- +------------+

  如果采用这种方式,记住不要使用SHA1()和MD5()作为哈希函数。因为这两个函数计算 出来的哈希值是非常长的字符串,会浪费大量空间,比较时也会更慢。SHA1()和MD5()是强加密函数,设计目标是最大限度消除冲突,但这里并不需要这样髙的要求。简单哈希函数的冲突在一个可以接受的范围,同时又能够提供更好的性能。

  如果数据表非常大,CRC32()会出现大量的哈希冲突,则可以考虑自己实现一个简单的 64位哈希函数。这个自定义函数要返回整数,而不是字符串。一个简单的办法可以使用MD5()函数返回值的一部分来作为自定义哈希函数。这可能比自己写一个哈希算法的性能要差,不过这样实现最简单:

mysql> SELECT CONV(RIGHT(MD5('http://www.mysql.com/'), 16), 16, 10) AS HASH64;
+---------------------+
| HASH64              |
+---------------------+
| 9761173720318281581 |
+---------------------+

  处理哈希冲突。当使用哈希索引进行査询的时候,必须在WHERE子句中包含常量值:

mysql> SELECT id FROM url WHERE url_crc=CRC32("http://www.mysql.com")
    ->    AND url="http://www.mysql.com";

  一旦出现哈希冲突,另一个字符串的哈希值也恰好是1560514994,则下面的查询是无法 正确工作的。

mysql> SELECT id FROM url WHERE url_crc=CRC32("http://www.mysql.com");

  因为所谓的“生日悖论”,出现哈希冲突的概率的增长速度可能比想象的要快得多。CRC32()返回的是32位的整数,当索引有93 000条记录时出现冲突的概率是1%。例如我们将/usr/share/dict/words中的词导入数据表并进行CRC32()计算,最后会有98 569行。这就已经出现一次哈希冲突了,冲突让下面的查询返回了多条记录:

mysql> SELECT word, crc FROM words WHERE crc = CRC32('gnu');
+---------+------------+
| word    | crc        |
+---------+------------+
| codding | 1774765869 |
| gnu     | 1774765869 |
+---------+------------+

  正确的写法应该如下:

mysql> SELECT word, crc FROM words WHERE crc = CRC32('gnu')AND word = 'gnu';
+------+------------+
| word | crc        |
+------+------------+
| gnu  | 1774765869 |
+------+------------+

  要避免冲突问题,必须在WHERE条件中带入哈希值和对应列值。如果不是想査询具体值,例如只是统计记录数(不精确的),则可以不带入列值,直接使用CRC32()的哈希值査询即可。还可以使用如FNV64()函数作为哈希函数,这是移植自Percona Server的函数,可以以插件的方式在任何MySQL版本中使用,哈希值为64位,速度快,且冲突比CRC32()要少很多。

 

  空间数据索引(R_Tree)

  MyISAM表支持空间索引,可以用作地理数据存储。和B-Tree索引不同,这类索引无须前缀查询。空间索引会从所有维度来索引数据。査询时,可以有效地使用任意维度来组合査询。必须使用MySQL的GIS相关函数如MBRCONTAINS()等来维护数据。MySQL的GIS支持并不完善,所以大部分人都不会使用这个特性。开源关系数据库系统中对GIS的解决方案做得比较好的是PostgreSQL的PostGIS。

 

  全文索引

  全文索引是一种特殊类型的索引,它査找的是文本中的关键词,而不是直接比较索引中 的值。全文搜索和其他几类索引的匹配方式完全不一样。它有许多需要注意的细节,如停用词、词干和复数、布尔搜索等。全文索引更类似于搜索引擎做的事情,而不是简单的WHERE条件匹配。

  在相同的列上同时创建全文索引和基于值的B-Tree索引不会有冲突,全文索引适用于MATCH AGAINST操作,而不是普通的WHERE条件操作。

 

  其他索引类别

  还有很多第三方的存储引擎使用不同类型的数据结构来存储索引。例如TokuDB使用分 形树索引(fractal tree index),这是一类较新开发的数据结构,既有B-Tree的很多优点,也避免了B-Tree的一些缺点。如果通读完本章,可以看到很多关于InnoDB的主题,包括聚簇索引、覆盖索引等。多数情况下,针对InnoDB的讨论也都适用于TokuDB。

  ScaleDB使用Patricia tries(这个词不是拼写错误),其他一些存储引擎技术如InfiniDB和Infobright则使用了一些特殊的数据结构来优化某些特殊的査询。

 

2.索引的优点

  索引可以让服务器快速地定位到表的指定位置。但是这并不是索引的唯一作用,到目前为止可以看到,根据创建索引的数据结构不同,索引也有一些其他的附加作用。

  最常见的B-Tree索引,按照顺序存储数据,所以MySQL可以用来做ORDER BY和GROUP BY操作。因为数据是有序的,所以B-Tree也就会将相关的列值都存储在一起。最后,因为索引中存储了实际的列值,所以某些査询只使用索引就能够完成全部査询。据此特性,总结下来索引有如下三个优点:

    1.索引大大减少了服务器需要扫描的数据量。

    2.索引可以帮助服务器避免排序和临时表。

    3.索引可以将随机I/O变为顺序I/O。

  “索引”这个主题完全值得单独写一本书,如果想深入理解这部分内容,强烈建议阅读由Tapio Lahdenmaki和Mike Leach编写的Relational Database Index Design and the Optimizers(Wiley出版社)一书,该书详细介绍了如何计算索引的成本和作用、如何评估査询速度、如何分析索引维护的代价和其带来的好处等。

  Lahdenmaki和Leach在书中介绍了如何评价一个索引是否适合某个査询的“三星系统” (three-star system):索引将相关的记录放到一起则获得一星;如果索引中的数据顺序和査找中的排列顺序一致则获得二星;如果索引中的列包含了査询中需要的全部列则获得“三星”。后面将会介绍这些原则。

 

  讨论:索引是最好的解决方案吗?

  索引并不总是最好的工具。总的来说,只有当索引帮助存储引擎快速查找到记录带来的好处大于其带来的额外工作时,索引才是有效的。对于非常小的表,大部分情况下简单的全表扫描更高效。对于中到大型的表,索引就非常有效。但对于特大型的表,建立和使用索引的代价将随之增长。这种情况下,则需要一种技术可以直接区分出查询需要的一组数据,而不是一条记录一条记录地匹配。例如可以使用分区技术。

  如果表的数量特别多,可以建立一个元数据信息表,用来查询需要用到的某些特性。例如执行那些需要聚合多个应用分布在多个表的数据的查询,则需要记录“哪个用户的信息存储在哪个表中”的元数据,这样在查询时就可以直接忽略那些不包含指定用户信息的表。对于大型系统,这是一个常用的技巧。事实上,Infobright就是使用类似的实现。对于TB级别的数据,定位单条记录的意义不大,所以经常会使用块级别元数据技术来替代索引。

 

3.高性能的索引策略

  正确地创建和使用索引是实现高性能査询的基础。前面已经介绍了各种类型的索引及其对应的优缺点。现在一起来看看如何真正地发挥这些索引的优势。

  高效地选择和使用索引有很多种方式,其中有些是针对特殊案例的优化方法,有些则是针对特定行为的优化。使用哪个索引,以及如何评估选择不同索引的性能影响的技巧,则需要持续不断地学习。接下来的几个小节将理解如何髙效地使用索引。

 

3.1 独立的列

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

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

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

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

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

mysql> SELECT ... WHERE TO_DAYS(CURRENT_DATE) - TO_DAYS(date_col) <= 10;

 

3.2 前缀索引和索引选择性

  有时候需要索引很长的字符列,这会让索引变得大且慢。一个策略是前面提到过的模拟哈希索引。但有时候这样做还不够,还可以做些什么呢?

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

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

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

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

CREATE TABLE sakila.city_demo(city VARCHAR(50) NOT NULL);
INSERT INTO sakila.city_demo(city) SELECT city FROM sakila.city;
-- Repeat the next statement five times:
INSERT INTO sakila.city_demo(city) SELECT city FROM sakila.city_demo;
-- Now randomize the distribution (inefficiently but conveniently):
UPDATE sakila.city_demo
   SET city = (SELECT city FROM  sakila.city ORDER BY RAND() LIMIT 1);

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

mysql> SELECT COUNT(*) AS cnt, city
    -> FROM sakila.city_demo GROUP BY city ORDER BY cnt DESC LIMIT 10;
+-----+----------------+
| cnt | city           |
+-----+----------------+
|  65 | London         |
|  49 | Hiroshima      |
|  48 | Teboksary      |
|  48 | Pak Kret       |
|  48 | Yaound         |
|  47 | Tel Aviv-Jaffa |
|  47 | Shimoga        |
|  45 | Cabuyao        |
|  45 | Callao         |
|  45 | Bislig         |
+-----+----------------+

  注意到,上面每个值都出现了 45〜65次。现在査找到最频繁出现的城市前缀,先从3 个前缀字母开始:

mysql> SELECT COUNT(*) AS cnt, LEFT(city, 3) AS pref
    -> FROM sakila.city_demo GROUP BY pref ORDER BY cnt DESC LIMIT 10;
+-----+------+
| cnt | pref |
+-----+------+
| 483 | San  |
| 195 | Cha  |
| 177 | Tan  |
| 167 | Sou  |
| 163 | al-  |
| 163 | Sal  |
| 146 | Shi  |
| 136 | Hal  |
| 130 | Val  |
| 129 | Bat  |
+-----+------+

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

mysql> SELECT COUNT(*) AS cnt, LEFT(city, 7) AS pref
    -> FROM sakila.city_demo GROUP BY pref ORDER BY cnt DESC LIMIT 10;
+-----+---------+
| cnt | pref    |
+-----+---------+
|  70 | Santiag |
|  68 | San Fel |
|  65 | London  |
|  61 | Valle d |
|  49 | Hiroshi |
|  48 | Teboksa |
|  48 | Pak Kre |
|  48 | Yaound  |
|  47 | Tel Avi |
|  47 | Shimoga |
+-----+---------+

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

mysql> SELECT COUNT(DISTINCT city)/COUNT(*) FROM sakila.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 sakila.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 sakila.city_demo GROUP BY pref ORDER BY cnt DESC LIMIT 5;
+-----+------+
| cnt | pref |
+-----+------+
| 205 | San  |
| 200 | Sant |
| 135 | Sout |
| 104 | Chan |
|  91 | Toul |
+-----+------+

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

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

mysql> ALTER TABLE sakila.city_demo ADD KEY (city(7));

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

  一个常见的场景是针对很长的十六进制唯一ID使用前缀索引。在前面的章节中已经讨论了很多有效的技术来存储这类ID信息,但如果使用的是打包过的解决方案,因而无法修改存储结构,那该怎么办?例如使用vBulletin或者其他基于MySQL的应用在存储网站的会话(SESSION)时,需要在一个很长的十六进制字符串上创建索引。此时如果采用长度为8的前缀索引通常能显著地提升性能,并且这种方法对上层应用完全透明。

  提示:有时候后缀索引(suffix index)也有用途(例如,找到某个域名的所有电子邮件地址)。MysQL原生并不支持反向索引,但是可以把字符串反转后存储,并于此建立前缀索引。可以通过触发器来维护这种索引。参考之前节中“创建自定义哈希索引”部分的相关内容。

 

3.3 多列索引

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

  下一小节单独讨论索引列的顺序问题。先来看第一个问题,为每个列创建独立的索引,从SHOW CREATE TABLE中很容易看到这种情况:

CREATE TABLE t (
  c1 INT,
  c2 INT,
  c3 INT,
  KEY(c1),
  KEY(c2),
  KEY(c3)
);

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

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

mysql> SELECT film_id, actor_id FROM sakila.film_actor
    -> WHERE actor_id = 1 OR film_id = 1;

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

mysql> SELECT film_id, actor_id FROM sakila.film_actor WHERE actor_id = 1
    -> UNION ALL
    -> SELECT film_id, actor_id FROM sakila.film_actor WHERE film_id = 1
    ->    AND actor_id <> 1;

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

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

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

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

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

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

 

3.4 选择合适的索引列顺序

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

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

  所以多列索引的列顺序至关重要。在Lahdenmaki和Leach的“三星索引”系统中,列顺序也决定了一个索引是否能够成为一个真正的“三星索引”。在本章的后续部分我们将通过大量的例子来说明这一点。

  对于如何选择索引的列顺序有一个经验法则:将选择性最高的列放到索引最前列。这个 建议有用吗?在某些场景可能有帮助,但通常不如避免随机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 KEY(customer_id, staff_id);

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

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

  下面是一个我们遇到过的真实案例,在一个用户分享购买商品和购买经验的论坛上,这 个特殊表上的査询运行得非常慢:

mysql> SELECT COUNT(DISTINCT threadId) AS COUNT_VALUE
    -> FROM Message
    -> WHERE (groupId = 10137) AND (userId = 1288826) AND (anonymous = 0)
    -> ORDER BY priority DESC, modifiedDate DESC

  这个査询看似没有建立合适的索引,所以客户咨询我们是否可以优化。EXPLAIN的结果 如下:

id: 1
select type:  SIMPLE
table: Message
type: ref
key: ix_groupId_userId 
key_len: 18
ref: const,const
rows:  1251162
Extra:  Using where

  MySQL为这个査询选择了索引(groupId, userId),如果不考虑列的基数,这看起来是 一个非常合理的选择。但如果考虑一下user ID和group ID条件匹配的行数,可能就会有不同的想法了:

mysql> SELECT COUNT(*), SUM(groupId = 101B7),
-> SUM(userId = 1288826), SUM(anonymous = 0)
-> FROM Message\G
************************** 1. row *************************
count(*): 4142217 
sum(groupId = 10137) : 4092654 
sum(userId = 1288826): 1288496 
sum(anonymous = 0)   : 4141934

  从上面的结果来看符合组(groupId)条件几乎满足表中的所有行,符合用户(userid)条件的有130万条记录——也就是说索引基本上没什么用。因为这些数据是从其他应用中迁移过来的,迁移的时候把所有的消息都赋予了管理员组的用户。这个案例的解决办法是修改应用程序代码,区分这类特殊用户和组,禁止针对这类用户和组执行这个査询。

  从这个小案例可以看到经验法则和推论在多数情况是有用的,但要注意不要假设平均情况下的性能也能代表特殊情况下的性能,特殊情况可能会摧毁整个应用的性能。

  最后,尽管关于选择性和基数的经验法则值得去研究和分析,但一定要记住别忘了WHERE子句中的排序、分组和范围条件等其他因素,这些因素可能对査询的性能造成非常大的影响。

 

3.5 聚簇索引

  聚簇索引并不是一种单独的索引类型,而是一种数据存储方式。具体的细节依赖于其实现方式,但InnoDB的聚簇索引实际上在同一个结构中保存了B-Tree索引和数据行。

  当表有聚簇索引时,它的数据行实际上存放在索引的叶子页(leafpage)中。术语“聚簇”表示数据行和相邻的键值紧凑地存储在一起。因为无法同时把数据行存放在两个不同的地方,所以一个表只能有一个聚簇索引(不过,覆盖索引可以模拟多个聚簇索引的情况,本章后面将详细介绍)。

  因为是存储引擎负责实现索引,因此不是所有的存储引擎都支持聚簇索引。本节我们主要关注InnoDB,但是这里讨论的原理对于任何支持聚簇索引的存储引擎都是适用的。

  图5-3展示了聚簇索引中的记录是如何存放的。注意到,叶子页包含了行的全部数据,但是节点页只包含了索引列。在这个案例中,索引列包含的是整数值。

  一些数据库服务器允许选择哪个索引作为聚簇索引,但直到mysql5.6之前,还没有任何一个MySQL内建的存储引擎支持这一点。InnoDB将通过主键聚集数据,这也就是说图5-3中的“被索引的列”就是主键列。

  如果没有定义主键,InnoDB会选择一个唯一的非空索引代替。如果没有这样的索引,InnoDB会隐式定义一个主键来作为聚簇索引。InnoDB只聚集在同一个页面中的记录。包含相邻键值的页面可能会相距甚远。

  聚簇主键可能对性能有帮助,但也可能导致严重的性能问题。所以需要仔细地考虑聚簇索引,尤其是将表的存储引擎从InnoDB改成其他引擎的时候(反过来也一样)。

  聚集的数据有一些重要的优点:

  • 可以把相关数据保存在一起。例如实现电子邮箱时,可以根据用户ID来聚集数据,这样只需要从磁盘读取少数的数据页就能获取某个用户的全部邮件。如果没有使用聚簇索引,则每封邮件都可能导致一次磁盘I/O。
  • 数据访问更快。聚簇索引将索引和数据保存在同一个B-Tree中,因此从聚簇索引中获取数据通常比在非聚簇索引中査找要快。
  • 使用覆盖索引扫描的査询可以直接使用页节点中的主键值。

  如果在设计表和查询时能充分利用上面的优点,那就能极大地提升性能。同时,聚簇索引也有一些缺点:

  • 聚簇数据最大限度地提高了I/O密集型应用的性能,但如果数据全部都放在内存中,则访问的顺序就没那么重要了,聚簇索引也就没什么优势了。
  • 插入速度严重依赖于插入顺序。按照主键的顺序插入是加载数据到InnoDB表中速度最快的方式。但如果不是按照主键顺序加载数据,那么在加载完成后最好使用OPTIMIZE TABLE命令重新组织一下表。
  • 更新聚簇索引列的代价很高,因为会强制InnoDB将每个被更新的行移动到新的位置。
  • 基于聚簇索引的表在插入新行,或者主键被更新导致需要移动行的时候,可能面临“页分裂(page split)”的问题。当行的主键值要求必须将这一行插入到某个已满的页中时,存储引擎会将该页分裂成两个页面来容纳该行,这就是一次页分裂操作。页分裂会导致表占用更多的磁盘空间。
  • 聚簇索引可能导致全表扫描变慢,尤其是行比较稀疏,或者由于页分裂导致数据存储不连续的时候。
  • 二级索引(非聚簇索引)可能比想象的要更大,因为在二级索引的叶子节点包含了引用行的主键列。
  • 二级索引访问需要两次索引査找,而不是一次。

  最后一点可能让人有些疑惑,为什么二级索引需要两次索引査找?答案在于二级索引中保存的“行指针”的实质。要记住,二级索引叶子节点保存的不是指向行的物理位置的指针,而是行的主键值。

  这意味着通过二级索引査找行,存储引擎需要找到二级索引的叶子节点获得对应的主键值,然后根据这个值去聚簇索引中査找到对应的行。这里做了重复的工作:两次B-Tree査找而不是一次(顺便提一下,并不是所有的非聚簇索引都能做到一次索引查询就找到行。当行更新的时候可能无法存储在原来的位置,这会导致表中出现行的碎片化或者移动行并在原位置保存“向前指针”,这两种情况都会导致在查找行时需要更多的工作)。对于InnoDB,自适应哈希索引能够减少这样的重复工作。

 

  InnoDB和MyISAM的数据分布对比

  聚簇索引和非聚簇索引的数据分布有区别,以及对应的主键索引和二级索引的数据分布也有区别,通常会让人感到困扰和意外。来看看InnoDB和MyISAM是如何存储下面这个表的:

CREATE TABLE layout_test ( 
  col1 int NOT NULL, 
  col2 int NOT NULL,
  PRIMARY KEY(col1),
  KEY(col2)
);

  假设该表的主键取值为1〜10 000,按照随机顺序插入并使用OPTIMIZE TABLE命令做 了优化。换句话说,数据在磁盘上的存储方式已经最优,但行的顺序是随机的。列col2的值是从1〜100之间随机赋值,所以有很多重复的值。

  MyISAM的数据分布。MyISAM的数据分布非常简单,所以先介绍它。MyISAM按照数据插入的顺序存储在磁盘上,如图5-4所示。

  在行的旁边显示了行号,从0开始递增。因为行是定长的,所以MyISAM可以从表的开头跳过所需的字节找到需要的行(MyISAM并不总是使用图5-4中的“行号”,而是根据定长还是变长的行使用不同策略)。

  这种分布方式很容易创建索引。下面显示的一系列图,隐藏了页的物理细节,只显示索 引中的“节点”,索引中的每个叶子节点包含“行号”。图5-5显示了表的主键。

  这里忽略了一些细节,例如前一个B-Tree节点有多少个内部节点,不过这并不影响对非聚簇存储引擎的基本数据分布的理解。

  那col2列上的索引又会如何呢?有什么特殊的吗?回答是否定的:它和其他索引没有什么区别。图5-6显示了col2列上的索引。

  事实上,MyISAM中主键索引和其他索引在结构上没有什么不同。主键索引就是一个名为PRIMARY的唯一非空索引。

  InnoDB的数据分布。因为InnoDB支持聚簇索引,所以使用非常不同的方式存储同样的 数据。InnoDB以如图5-7所示的方式存储数据。

  第一眼看上去,感觉该图和前面的图5-5没有什么不同,但再仔细看细节,会注意到该图显示了整个表,而不是只有索引。因为在InnoDB中,聚簇索引“就是”表,所以不像MyISAM那样需要独立的行存储。

  聚簇索引的每一个叶子节点都包含了主键值、事务ID、用于事务和MVCC的回滚指针以及所有的剩余列(在这个例子中是col2)。如果主键是一个列前缀索引,InnoDB也会包含完整的主键列和剩下的其他列。

  还有一点和MyISAM的不同是,InnoDB的二级索引和聚簇索引很不相同。InnoDB二级索引的叶子节点中存储的不是“行指针”,而是主键值,并以此作为指向行的“指针”。这样的策略减少了当出现行移动或者数据页分裂时二级索引的维护工作。使用主键值当作指针会让二级索引占用更多的空间,换来的好处是,InnoDB在移动行时无须更新二级索引中的这个“指针”。

  图5-8显示了示例表的col2索引。每一个叶子节点都包含了索引列(这里是col2),紧接着是主键值(col1)。

  图5-8展示了B-Tree的叶子节点结构,但我们故意省略了非叶子节点这样的细节。InnoDB的非叶子节点包含了索引列和一个指向下级节点的指针(下一级节点可以是非叶子节点,也可以是叶子节点)。这对聚簇索引和二级索引都适用。

  图5-9是描述InnoDB和MyISAM如何存放表的抽象图。从图5-9中可以很容易看出InnoDB和MyISAM保存数据和索引的区别。

  如果还没有理解聚簇索引和非聚簇索引有什么区别、为何有这些区别及这些区别的重要性,也不用担心。随着学习的深入,尤其是学完本章剩下的部分以及下一章以后,这些问题就会变得越发清楚。这些概念有些复杂,需要一些时间才能完全理解。 

 

  在InnoDB表中按主键顺序插入行

  如果正在使用InnoDB表并且没有什么数据需要聚集,那么可以定义一个代理键(surrogate key)作为主键,这种主键的数据应该和应用无关,最简单的方法是使用AUTO_INCREMENT自增列。这样可以保证数据行是按顺序写入,对于根据主键做关联操作的性能也会更好。

  最好避免随机的(不连续且值的分布范围非常大)聚簇索引,特别是对于I/O密集型的应用。例如,从性能的角度考虑,使用UUID来作为聚簇索引则会很糟糕:它使得聚簇索引的插入变得完全随机,这是最坏的情况,使得数据没有任何聚集特性。

  为了演示这一点,我们做如下两个基准测试。第一个使用整数ID插入userinfo表:

CREATE TABLE userinfo (
   id              int unsigned NOT NULL AUTO_INCREMENT,
   name            varchar(64) NOT NULL DEFAULT '',
   email           varchar(64) NOT NULL DEFAULT '',
   password        varchar(64) NOT NULL DEFAULT '',
   dob             date DEFAULT NULL,
   address         varchar(255) NOT NULL DEFAULT '',
   city            varchar(64) NOT NULL DEFAULT '',
   state_id        tinyint unsigned NOT NULL DEFAULT '0',
   zip             varchar(8) NOT NULL DEFAULT '',
   country_id      smallint unsigned NOT NULL DEFAULT '0',
   gender          ('M','F')NOT NULL DEFAULT 'M',
   account_type    varchar(32) NOT NULL DEFAULT '',
   verified        tinyint NOT NULL DEFAULT '0',
   allow_mail      tinyint unsigned NOT NULL DEFAULT '0',
   parrent_account int unsigned NOT NULL DEFAULT '0',
   closest_airport varchar(3) NOT NULL DEFAULT '',
   PRIMARY KEY (id),
   UNIQUE  KEY email (email),
   KEY     country_id (country_id),
   KEY     state_id (state_id),
   KEY     state_id_2 (state_id,city,address)
) ENGINE=InnoDB

  注意到使用了自增的整数ID作为主键。

  第二个例子是userinfo_uuid表。除了主键改为UUID,其余和前面的userinfo表完全相同。

CREATE TABLE userinfo_uuid ( 
    uuid varchar(36) NOT NULL,
    ...

  我们测试了这两个表的设计。首先,我们在一个有足够内存容纳索引的服务器上向这两 个表各插入100万条记录。然后向这两个表继续插入300万条记录,使索引的大小超过服务器的内存容量。表5-1对测试结果做了比较。

5-1:InnoDB表插入数据的测试结果
表名 行数 时间(秒) 索引大小(MB)
userinfo 1000000 137 342
userinfo_uuid 1000000 180 544
userinfo 3000000 1233 1036
userinfo_uuid 3000000 4525 1707

  注意到向UUID主键插入行不仅花费的时间更长,而且索引占用的空间也更大。这一方面是由于主键字段更长;另一方面毫无疑问是由于页分裂和碎片导致的。

  为了明白为什么会这样,来看看往第一个表中插入数据时,索引发生了什么变化。图5-10显示了插满一个页面后继续插入相邻的下一个页面的场景。

  如图5-10所示,因为主键的值是顺序的,所以InnoDB把每一条记录都存储在上一条记录的后面。当达到页的最大填充因子时(InnoDB默认的最大填充因子是页大小的15/16,留出部分空间用于以后修改),下一条记录就会写入新的页中。一旦数据按照这种顺序的方式加载,主键页就会近似于被顺序的记录填满,这也正是所期望的结果(然而,二级索引页可能是不一样的)。

  对比一下向第二个使用了UUID聚簇索引的表插入数据,看看有什么不同,图5-11显示了结果。

  因为新行的主键值不一定比之前插入的大,所以InnoDB无法简单地总是把新行插入到索引的最后,而是需要为新的行寻找合适的位置——通常是已有数据的中间位置——并且分配空间。这会增加很多的额外工作,并导致数据分布不够优化。下面是总结的一些缺点:

  • 写入的目标页可能已经刷到磁盘上并从缓存中移除,或者是还没有被加载到缓存中,InnoDB在插入之前不得不先找到并从磁盘读取目标页到内存中。这将导致大量的随机I/O。
  • 因为写入是乱序的,InnoDB不得不频繁地做页分裂操作,以便为新的行分配空间。页分裂会导致移动大量数据,一次插入最少需要修改三个页而不是一个页。
  • 由于频繁的页分裂,页会变得稀疏并被不规则地填充,所以最终数据会有碎片。

  在把这些随机值载入到聚簇索引以后,也许需要做一次OPTIMIZE TABLE来重建表并优化页的填充。

  从这个案例可以看出,使用InnoDB时应该尽可能地按主键顺序插入数据,并且尽可能 地使用单调增加的聚簇键的值来插入新行。

 

  讨论:顺序的主键什么时候会造成更坏的结果?

  对于高并发工作负载,在InnoDB中按主键顺序插入可能会造成明显的争用。主键的上界会成为“热点”。因为所有的插入都发生在这里,所以并发插入可能导致间隙锁竞争。另一个热点可能是AUTO_INCREMENT锁机制;如果遇到这个问题,则可能需要考虑重新设计表或者应用,或者更改innodb autoinc_lock_mode配置。如果你的服务器版本还不支持innodb autoinc_lock_mode参数,可以升级到新版本的InnoDB,可能对这种场景会工作得更好。

 

3.6 覆盖索引

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

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

  • 索引条目通常远小于数据行大小,所以如果只需要读取索引,那MySQL就会极大地减少数据访问量。这对缓存的负载非常重要,因为这种情况下响应时间大部分花费在数据拷贝上。覆盖索引对于I/O密集型的应用也有帮助,因为索引比数据更小,更容易全部放入内存中(这对于MyISAM尤其正确,因为MyISAM能压缩索引以变得更小)。
  • 因为索引是按照列值顺序存储的(至少在单个页内是如此),所以对于I/O密集型的范围査询会比随机从磁盘读取每一行数据的I/O要少得多。对于某些存储引擎,例如MyISAM和Percona XtraDB,甚至可以通过OPTIMIZE命令使得索引完全顺序排列,这让简单的范围査询能使用完全顺序的索引访问。
  • 一些存储引擎如MyISAM在内存中只缓存索引,数据则依赖于操作系统来缓存,因此要访问数据需要一次系统调用。这可能会导致严重的性能问题,尤其是那些系统调用占了数据访问中的最大开销的场景。
  • 由于InnoDB的聚簇索引,覆盖索引对InnoDB表特别有用。InnoDB的二级索引在叶子节点中保存了行的主键值,所以如果二级主键能够覆盖査询,则可以避免对主键索引的二次査询。

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

  不是所有类型的索引都可以成为覆盖索引。覆盖索引必须要存储索引列的值,而哈希索引、空间索引和全文索引等都不存储索引列的值,所以MySQL只能使用B-Tree索引做覆盖索引。另外,不同的存储引擎实现覆盖索引的方式也不同,而且不是所有的引擎都支持覆盖索引(mysql5.5之前,Memory存储引擎就不支持覆盖索引)。

  当发起一个被索引覆盖的査询(也叫做索引覆盖査询)时,在EXPLAIN的Extra列可以看到“Usingindex”的信息。例如,表sakila.inventory有一个多列索引(store_id,film_id)。MySQL如果只需访问这两列,就可以使用这个索引做覆盖索引,如下所示:

mysql> EXPLAIN SELECT store_id, film_id FROM sakila.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: 4673
        Extra: Using index

  索引覆盖査询还有很多陷阱可能会导致无法实现优化。MySQL査询优化器会在执行查 询前判断是否有一个索引能进行覆盖。假设索引覆盖了WHERE条件中的字段,但不是整个査询涉及的字段。如果条件为假(false), MySQL5.5和更早的版本也总是会回表获取数据行,尽管并不需要这一行且最终会被过滤掉。

  来看看为什么会发生这样的情况,以及如何重写査询以解决该问题。从下面的査询开始:

mysql> EXPLAIN SELECT * FROM products WHERE actor='SEAN CARREY'
    -> AND title like '%APOLLO%'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: products
         type: ref
possible_keys: ACTOR,IX_PROD_ACTOR
          key: ACTOR
      key_len: 52
          ref: const
         rows: 10
        Extra: Using where

  这里索引无法覆盖该査询,有两个原因:

  • 没有任何索引能够覆盖这个査询。因为査询从表中选择了所有的列,而没有任何索引覆盖了所有的列。不过,理论上MySQL还有一个捷径可以利用:WHERE条件中的列是有索引可以覆盖的,因此MySQL可以使用该索引找到对应的actor并检査title是否匹配,过滤之后再读取需要的数据行。
  • MySQL不能在索引中执行LIKE操作。这是底层存储引擎API的限制,MySQL5.5和更早的版本中只允许在索引中做简单比较操作(例如等于、不等于以及大于)。MySQL能在索引中做最左前缀匹配的LIKE比较,因为该操作可以转换为简单的比较操作,但是如果是通配符开头的LIKE査询,存储引擎就无法做比较匹配。这种情况下,MySQL服务器只能提取数据行的值而不是索引值来做比较。

  也有办法可以解决上面说的两个问题,需要重写査请并巧妙地设计索引。先将索引扩展 至覆盖三个数据列(artist, title, prod_id),然后按如下方式重写査询:

mysql> EXPLAIN SELECT *
    -> FROM products
    ->    JOIN (
    ->       SELECT prod_id
    ->       FROM products
    ->       WHERE actor='SEAN CARREY' AND title LIKE '%APOLLO%'
    ->    ) AS t1 ON (t1.prod_id=products.prod_id)\G
*************************** 1. row ***************************
           id: 1
  select_type: PRIMARY
        table: <derived2>
               ...omitted...
*************************** 2. row ***************************
           id: 1
  select_type: PRIMARY
        table: products
               ...omitted...
*************************** 3. row ***************************
           id: 2
  select_type: DERIVED
        table: products
         type: ref
possible_keys: ACTOR,ACTOR_2,IX_PROD_ACTOR
          key: ACTOR_2
      key_len: 52
          ref:
         rows: 11
        Extra: Using where; Using index

  我们把这种方式叫做延迟关联(deferred join),因为延迟了对列的访问。在査询的第一阶段MySQL可以使用覆盖索引,在FROM子句的子査询中找到匹配的prod_id,然后根据这些prod_id值在外层査询匹配获取需要的所有列值。虽然无法使用索引覆盖整个査询,但总算比完全无法利用索引覆盖的好。

  这样优化的效果取决于WHERE条件匹配返回的行数。假设这个products表有100万行, 我们来看一下上面两个査询在三个不同的数据集上的表现,每个数据集都包含100万行:

    1.第一个数据集,Sean Carrey出演了 30 000部作品,其中有20 000部的标题中包含了Apollo。

    2.第二个数据集,Sean Carrey出演了 30 000部作品,其中40部的标题中包含了 Apollo。

    3.第三个数据集, Sean Carrey出演了 50部作品,其中10部的标题中包含了 Apollo。 使用上面的三种数据集来测试两种不同的査询,得到的结果如表5-2所示。

表5_2:索引覆盖查询和非覆盖查询的测试结果
数据集 原查询 优化后的查询
示例1 每秒5次査询 每秒5次査询
示例2 每秒7次査询 每秒35次査询
示例3 每秒2 400次査询 每秒2 000次査询

  下面是对结果的分析:

  • 在示例 1 中,查询返回了一个很大的结果集,因此我们看不到优化的效果。大部分时间用于读取和发送数据。
  • 示例 2,其中第二个条件过滤后只返回了很少的结果集,优化的效果非常明显:这这个数据集上性能提高了5倍,优化后的查询效率来自只需要读取 40 完整行,而不是第一个查询中的 30,000。
  • 示例 3 显示了子查询效率低下的情况。索引过滤后留下的结果集非常小,以至于子查询比从表中读取所有数据更昂贵。

  在大多数存储引擎中,索引只能涵盖访问属于索引一部分的列的查询。然而,InnoDB 实际上可以更进一步地进行这种优化。回想一下 InnoDB 的二级索引在它们的叶节点上保存主键值。这意味着 InnoDB 的二级索引实际上具有 InnoDB 可以有效地利用这些“额外”的主键列来覆盖查询。

  例如,该sakila.actor表使用 InnoDB,并在last_name字段有二级索引,因此该索引的列不包括主键 actor_id,但也能够用于对actor_id做覆盖查询:

mysql> EXPLAIN SELECT actor_id, last_name
    -> FROM sakila.actor WHERE last_name = 'HOPPER'\G
**************************** 1. 行 **************** *********** 
           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版本的改进

  上面提到的很多限制都是由于存储引擎API设计所导致的,目前的API设计不允许MySQL将过滤条件传到存储引擎层。如果MySQL在后续版本能够做到这一点,则可以把查询发送到数据上,而不是像现在这样只能把数据从存储引擎拉到服务器层,再根据查询条件过滤。MySQL5.6之后版本包含了在存储引擎API上所做的一个重要的改进,其被称为“索引条件推送(index condition pushdown)”。这个特性将大大改善现在的查询执行方式,如此一来上面介绍的很多技巧也就不再需要了。

 

3.7 使用索引扫描来做排序

  MySQL有两种方式可以生成有序的结果:通过排序操作;或者按索引顺序扫描;如果EXPLAIN出来的type列的值为“index”,则说明MySQL使用了索引扫描来做排序(不要和Extra列的“Using index”搞混淆了)。

  扫描索引本身是很快的,因为只需要从一条索引记录移动到紧接着的下一条记录。但如果索引不能覆盖査询所需的全部列,那就不得不每扫描一条索引记录就都回表查询一次对应的行。这基本上都是随机I/0,因此按索引顺序读取数据的速度通常要比顺序地全表扫描慢,尤其是在I/O密集型的工作负载时。

  MySQL可以使用同一个索引既满足排序,又用于査找行。因此,如果可能,设计索引时应该尽可能地同时满足这两种任务,这样是最好的。

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

  有一种情况下ORDER BY子句可以不满足索引的最左前缀的要求,就是前导列为常量的时候。如果WHERE子句或者JOIN子句中对这些列指定了常量,就可以“弥补”索引的不足。

  例如,Sakila示例数据库的表rental在列(rental_date,inventory_id,customer_id) 上有名为rental_date的索引。

CREATE TABLE rental (
   ...
   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),
   ...
);

  MySQL可以使用rental_date索引为下面的查询做排序,从EXPLAIN中可以看到没有出现文件排序(filesort)操作:

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

  即使ORDER BY子句不满足索引的最左前缀的要求,也可以用于査询排序,这是因为索引的第一列被指定为一个常数。

  还有更多可以使用索引做排序的査询示例。下面这个查询可以利用索引排序,是因为査 询为索引的第一列提供了常量条件,而使用第二列进行排序,将两列组合在一起,就形成了索引的最左前缀:

... WHERE rental_date = '2005-05-25' ORDER BY inventory_id DESC;

  下面这个査询也没问题,因为ORDER BY使用的两列就是索引的最左前缀:

... WHERE rental_date > '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子句中引用了一个不在索引中的列:
... 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 inventory_id, customer_id;
  • 这个査询在inventory_id列上有多个等于条件。对于排序来说,这也是一种范围査询:
... WHERE rental_date = '2005-05-25' AND inventory_id IN(1,2) ORDER BY customer_id;

  下面这个例子理论上是可以使用索引进行关联排序的,但由于优化器在优化时将film_actor表当作关联的第二张表,所以实际上无法使用索引:

mysql> EXPLAIN SELECT actor_id, title FROM sakila.film_actor
    -> INNER JOIN sakila.film USING(film_id) ORDER BY actor_id\G
+------------+----------------------------------------------+
| table      | Extra                                        |
+------------+----------------------------------------------+
| film       | Using index; Using temporary; Using filesort |
| film_actor | Using index                                  |
+------------+----------------------------------------------+

  使用索引做排序的一个最重要的用法是当査询同时有ORDER BY和LIMIT子句的时候。后面会具体介绍这些内容。

 

3.8 压缩(前缀压缩)索引

  MyISAM使用前缀压缩来减少索引的大小,从而让更多的索引可以放入内存中,这在某些情况下能极大地提髙性能。默认只压缩字符串,但通过参数设置也可以对整数做压缩。

  MyISAM压缩每个索引块的方法是,先完全保存索引块中的第一个值,然后将其他值和第一个值进行比较得到相同前缀的字节数和剩余的不同后缀部分,把这部分存储起来即可。例如,索引块中的第一个值是“perform”,第二个值是“performance”,那么第二个值的前缀压缩后存储的是类似“7,ance”这样的形式。MyISAM对行指针也采用类似的前缀压缩方式。

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

  测试表明,对于CPU密集型应用,因为扫描需要随机査找,压缩索引使得MyISAM在索引査找上要慢好几倍。压缩索引的倒序扫描就更慢了。压缩索引需要在CPU内存资源与磁盘之间做权衡。压缩索引可能只需要十分之一大小的磁盘空间,如果是I/O密集型应用,对某些査询带来的好处会比成本多很多。可以在CREATE TABLE语句中指定PACK_KEYS参数来控制索引压缩的方式。

 

3.9 冗余和重复索引

  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;

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

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

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

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

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

  考虑一下前面“在InnoDB中按主键顺序插入行”一节提到的userinfo表。这个表有1 000 000行,对每个state_id值大概有20 000条记录。在state_id列有一个索引对下面的査询有用,假设査询名为Q1 :

mysql> SELECT count(*) FROM userinfo WHERE state_id=5;

  一个简单的测试表明该査询的执行速度大概是每秒115次(QPS)。还有一个相关查询需 要检索几个列的值,而不是只统计行数,假设名为Q2:

mysql> SELECT state_id, city, address FROM userinfo WHERE state_id=5;

  对于这个査询,测试结果QPS小于10(这里使用了全内存的案例,如果表逐渐变大,导致工作负载变成I/O密集型时,性能测试结果差距会更大。对于C0UNT()查询,覆盖索引性能提升100倍也是很有可能的。)。提升该査询性能的最简单办法就是扩展索引为 (state_id, city, address),让索引能覆盖査询:

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

  索引扩展后,Q2运行得更快了,但是Q1却变慢了。如果我们想让两个査询都变得更快,就需要两个索引,尽管这样一来原来的单列索引是冗余的了。表5-3显示这两个査询在不同的索引策略下的详细结果,分别使用MyISAM和InnoDB存储引擎。注意到只有state_id_2索引时,InnoDB引擎上的査询Q1的性能下降并不明显,这是因为InnoDB没有使用索引压缩。

表5-3:使用不同索引策略的SELECT查询的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

  有两个索引的缺点是索引成本更高。表5-4显示了向表中插入100万行数据所需要的时间。

5-4:在使用不同索引策略时插入100万行数据的速度
  只有state_id 同时有state_id和state_id_2
InnoDB,对两个索引都有足够的内容 80秒 136秒
MyISAM,只有一个索引有足够的内容 72秒 470秒

  可以看到,表中的索引越多插入速度会越慢。一般来说,增加新索引将会导致INSERT、UPDATE、DELETE等操作的速度变慢,特别是当新增索引后导致达到了内存瓶颈的时候。

  解决冗余索引和重复索引的方法很简单,删除这些索引就可以,但首先要做的是找出这样的索引。可以通过写一些复杂的访问INF0RMATI0N_SCHEMA表的査询来找,不过还有两个更简单的方法。可使用Shlomi Noach的commmon_schema中的一些视图来定位,common_schema是一系列可以安装到服务器上的常用的存储和视图。这比自己编写査询要快而且简单。另外也可以使用Percona Toolkit中的pt-duplicate-key-checker,该工具通过分析表结构来找出冗余和重复的索引。对于大型服务器来说,使用外部的工具可能更合适些;如果服务器上有大量的数据或者大量的表,査询INF0RMATI0N_SCHEMA表可能会导致性能问题。

  在决定哪些索引可以被删除的时候要非常小心。回忆一下,在前面的InnoDB的示例表中,因为二级索引的叶子节点包含了主键值,所以在列(A)上的索引就相当于在(A,ID)上的索引。如果有像WHERE A = 5 ORDER BY ID这样的査询,这个索引会很有作用。但如果将索引扩展为(A,B),则实际上就变成了(A,B,ID),那么上面査询的ORDER BY子句就无法使用该索引做排序,而只能用文件排序了。所以,建议使用Percona工具箱中的pt-upgrade工具来仔细检査计划中的索引变更。

 

3.10 未使用的索引

  除了冗余索引和重复索引,可能还会有一些服务器永远不用的索引。这样的索引完全是累赘,建议考虑删除。有两个工具可以帮助定位未使用的索引。最简单有效的办法是在Percona Server或者MariaDB中先打开userstates服务器变量(默认是关闭的),然后让服务器正常运行一段时间,再通过査询INF0RMATI0N_SCHEMA.INDEX_STATISTICS就能査到每个索引的使用频率。

  另外,还可以使用Percona Toolkit中的pt-index-usage,该工具可以读取查询日志,并对日志中的每条査询进行EXPLAIN操作,然后打印出关于索引和査询的报告。这个工具不仅可以找出哪些索引是未使用的,还可以了解査询的执行计划一一例如在某些情况有些类似的査询的执行方式不一样,这可以帮助你定位到那些偶尔服务质量差的査询,优化它们以得到一致的性能表现。该工具也可以将结果写入到MySQL的表中,方便查询结果。

 

3.11 索引和锁

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

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

  通过下面的例子再次使用数据库Sakila很好地解释了这些情况:

mysql> SET AUTOCOMMIT=0;
mysql> BEGIN;
mysql> SELECT actor_id FROM sakila.actor WHERE actor_id < 5
    ->    AND actor_id <> 1 FOR UPDATE;
+----------+
| actor_id |
+----------+
|        2 |
|        3 |
|        4 |
+----------+

  这条查询仅仅会返回2〜4之间的行,但是实际上获取了 1〜4之间的行的排他锁。InnoDB会锁住第1行,这是因为MySQL为该査询选择的执行计划是索引范围扫描:

mysql> EXPLAIN SELECT actor_id FROM sakila.actor
    -> WHERE actor_id < 5 AND actor_id <> 1 FOR UPDATE;
+----+-------------+-------+-------+---------+--------------------------+
| id | select_type | table | type  | key     | Extra                    |
+----+-------------+-------+-------+---------+--------------------------+
|  1 | SIMPLE      | actor | range | PRIMARY | Using where; Using index |
+----+-------------+-------+-------+---------+--------------------------+

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

  下面的第二个査询就能证明第1行确实已经被锁定,尽管第一个査询的结果中并没有这个第1行。保持第一个连接打开,然后开启第二个连接并执行如下査询:

mysql> SET AUTOCOMMIT=0;
mysql> BEGIN;
mysql> SELECT actor_id FROM sakila.actor WHERE actor_id = 1 FOR UPDATE;

  这个查询将会挂起,直到第一个事务释放第1行的锁。这个行为对于基于语句的复制的正常运行来说是必要的。 

  就像这个例子显示的,即使使用了索引,InnoDB也可能锁住一些不需要的数据。如果不能使用索引査找和锁定行的话问题可能会更糟糕,MySQL会做全表扫描并锁住所有的行,而不管是不是需要。

  关于InnoDB、索引和锁有一些很少有人知道的细节:InnoDB在二级索引上使用共享 (读)锁,但访问主键索引需要排他(写)锁。这消除了使用覆盖索引的可能性,并且使得SELECT FOR UPDATE比LOCK IN SHARE MODE或非锁定査询要慢很多。

 

4.索引案例学习

  理解索引最好的办法是结合示例,所以这里准备了一个索引的案例。

  假设要设计一个在线约会网站,用户信息表有很多列,包括国家、地区、城市、性别、 眼睛颜色,等等。网站必须支持上面这些特征的各种组合来搜索用户,还必须允许根据用户的最后在线时间、其他会员对用户的评分等对用户进行排序并对结果进行限制。如何设计索引满足上面的复杂需求呢? 

  出人意料的是第一件需要考虑的事情是需要使用索引来排序,还是先检索数据再排序。使用索引排序会严格限制索引和査询的设计。例如,如果希望使用索引做根据其他会员对用户的评分的排序,则WHERE条件中的age BETOEEN 18 AND 25就无法使用索引。如果MySQL使用某个索引进行范围查询,也就无法再使用另一个索引(或者是该索引的后续字段)进行排序了。如果这是很常见的WHERE条件,那么我们当然就会认为很多査询需要做排序操作(例如文件排序filesort)。

 

4.1 支持多种过滤条件

  现在需要看看哪些列拥有很多不同的取值,哪些列在WHERE子句中出现得最频繁。在有更多不同值的列上创建索引的选择性会更好。一般来说这样做都是对的,因为可以让MySQL更有效地过滤掉不需要的行。

  country列的选择性通常不高,但可能很多査询都会用到。sex列的选择性肯定很低,但也会在很多査询中用到。所以考虑到使用的频率,还是建议在创建不同组合索引的时候将(sex,country)列作为前缀。

  但根据传统的经验不是说不应该在选择性低的列上创建索引的吗?那为什么这里要将两个选择性都很低的字段作为索引的前缀列?我们的脑子坏了?

  我们的脑子当然没坏。这么做有两个理由:第一点,如前所述几乎所有的査询都会用到sex列。前面曾提到,几乎每一个査询都会用到sex列,甚至会把网站设计成每次都只能按某一种性别搜索用户。更重要的一点是,索引中加上这一列也没有坏处,即使査询没有使用sex列也可以通过下面的“诀窍”绕过。

  这个“诀窍”就是:如果某个査询不限制性别,那么可以通过在査询条件中新增AND SEX IN(‘m’,’f’)来让MySQL选择该索引。这样写并不会过滤任何行,和没有这个条件时返回的结果相同。但是必须加上这个列的条件,MySQL才能够匹配索引的最左前缀。这个“诀转”在这类场景中非常有效,但如果列有太多不同的值,就会让IN()列表太长,这样做就不行了。

  这个案例显示了一个基本原则:考虑表上所有的选项。当设计索引时,不要只为现有的 査询考虑需要哪些索引,还需要考虑对査询进行优化。如果发现某些査询需要创建新索引,但是这个索引又会降低另一些査询的效率,那么应该想一下是否能优化原来的查询。应该同时优化査询和索引以找到最佳的平衡,而不是闭门造车去设计最完美的索引。

  接下来,需要考虑其他常见WHERE条件的组合,并需要了解哪些组合在没有合适索引的情况下会很慢。(sex,country,age)上的索引就是一个很明显的选择,另外很有可能还需要(sex,country,region,age)和(sex,country,region,city,age)这样的组合索引。

  这样就会需要大量的索引。如果想尽可能重用索引而不是建立大量的组合索引,可以使用前面提到的IN()的技巧来避免同时需要(sex,country,age)和(sex,country,region,age)的索引。如果没有指定这个字段捜索,就需要定义一个全部国家列表,或者国家的全部地区列表,来确保索引前缀有同样的约束(组合所有国家、地区、性别将会是一个非常大的条件)。

  这些索引将满足大部分最常见的搜索査询,但是如何为一些生僻的搜索条件(比如has_pictures、eye color、hair color和education)来设计索引呢?这些列的选择性高、使用也不频繁,可以选择忽略它们,让MySQL多扫描一些额外的行即可。另一个可选的方法是在age列的前面加上这些列,在査询时使用前面提到过的IN()技术来处理捜索时没有指定这些列的场景。

  你可能已经注意到了,我们一直将age列放在索引的最后面。age列有什么特殊的地方吗?为什么要放在索引的最后?我们总是尽可能让MySQL使用更多的索引列,因为査询只能使用索引的最左前缀,直到遇到第一个范围条件列。前面提到的列在WHERE子句中都是等于条件,但是age列则多半是范围査询(例如査找年龄在18〜25岁之间的人)。

  当然,也可以使用IN()来代替范围査询,例如年龄条件改写为IN(18, 19, 20, 21,22, 23, 24, 25),但不是所有的范围査询都可以转换。这里描述的基本原则是,尽可能将需要做范围査询的列放到索引的后面,以便优化器能使用尽可能多的索引列。

  前面提到可以在索引中加入更多的列,并通过IN()的方式覆盖那些不在WHERE子句中的列。但这种技巧也不能滥用,否则可能会带来麻烦。因为每额外增加一个IN()条件,优化器需要做的组合都将以指数形式增加,最终可能会极大地降低查询性能。考虑下面的WHERE子句:

WHERE eye_color   IN('brown','blue','hazel')
   AND hair_color IN('black','red','blonde','brown')
   AND sex        IN('M','F')

  优化器则会转化成4 x 3 x 2 = 24种组合,执行计划需要检査WHERE子句中所有的24种组合。对于MySQL来说,24种组合并不是很夸张,但如果组合数达到上千个则需要特别小心。老版本的MySQL在IN()组合条件过多的时候会有很多问题。査询优化可能需要花很多时间,并消耗大量的内存。新版本的MySQL在组合数超过一定数量后就不再进行执行计划评估了,这可能会导致MySQL不能很好地利用索引。

 

4.2 避免多个范围条件

  假设我们有一个last_online列并希望通过下面的査询显示在过去几周上线过的用户:

WHERE  eye_color   IN('brown','blue','hazel')
   AND hair_color  IN('black','red','blonde','brown')
   AND sex         IN('M','F')
   AND last_online > DATE_SUB(NOW(), INTERVAL 7 DAY)
   AND age         BETWEEN 18 AND 25

  这个査询有一个问题:它有两个范围条件,last_online列和age列,MySQL可以使用last_online列索引或者age列索引,但无法同时使用它们。 

  如果条件中只有last_online而没有age,那么我们可能考虑在索引的后面加上last_online列。这里考虑如果我们无法把age字段转换为一个IN()的列表,并且仍要求对于同时有last_online和age这两个维度的范围査询的速度很快,那该怎么办?答案是,很遗憾没有一个直接的办法能够解决这个问题。但是我们能够将其中的一个范围查询转换为一个简单的等值比较。为了实现这一点,我们需要事先计算好一个active列,这个字段由定时任务来维护。当用户每次登录时,将对应值设置为1,并且将过去连续七天未曾登录的用户的值设置为0。

  这个方法可以让MySQL使用(active,sex,country,age)索引。active列并不是完全精确的,但是对于这类査询来说,对精度的要求也没有那么高。如果需要精确数据,可以把last_online列放到WHERE子句,但不加入到索引中。这和本章前面通过计算URL哈希值来实现URL的快速查找类似。所以这个査询条件没法使用任何索引,但因为这个条件的过滤性不髙,即使在索引中加入该列也没有太大的帮助。换个角度来说,缺乏合适的索引对该査询的影响也不明显。

  到目前为止,我们可以看到:如果用户希望同时看到活跃和不活跃的用户,可以在查询中使用IN()列表。我们已经加入了很多这样的列表,但另外一个可选的方案就只能是为不同的组合列创建单独的索引。至少需要建立如下的索引:(active,sex,country,age), (active,country,age), (sex,country,age)和(country,age)。这些索引对某个具体的査询来说可能都是更优化的,但是考虑到索引的维护和额外的空间占用的代价,这个可选方案就不是一个好策略了。 

  在这个案例中,优化器的特性是影响索引策略的一个很重要的因素。如果未来版本的MySQL能够实现松散索引扫描,就能在一个索引上使用多个范围条件,那也就不需要为上面考虑的这类査询使用IN()列表了。

 

  讨论:什么是范围条件

  从EXPLAIN的输出很难区分MySQL是要查询范围值,还是查询列表值。EXPLAIN使用同样的词“range”来描述这两种情况。例如,从type列来看,MySQL会把下面这种查询当作是“range”类型:

mysql> EXPLAIN SELECT actor_id FROM sakila.actor
    -> WHERE actor_id > 45\G
************************* 1. row *************************
           id: 1
  select_type: SIMPLE
        table: actor
         type: range

  但是下面这条查询呢?

mysql> EXPLAIN SELECT actor_id FROM sakila.actor
    -> WHERE actor_id IN(1, 4, 99)\G
************************* 1. row *************************
           id: 1
  select_type: SIMPLE
        table: actor
         type: range

  从EXPLAIN的结果是无法区分这两者的,但可以从值的范围和多个等于条件来得出不同。在我们看来,第二个查询就是多个等值条件查询。

  我们不是挑剔:这两种访问效率是不同的。对于范围条件查询,MySQL无法再使用范围列后面的其他索引列了,但是对于“多个等值条件查询”则没有这个限制。

 

4.3 优化排序

  在这个学习案例中,最后要介绍的是排序。使用文件排序对小数据集是很快的,但如果一个査询匹配的结果有上百万行的话会怎样?例如如果WHERE子句只有sex列,如何排序?

  对于那些选择性非常低的列,可以增加一些特殊的索引来做排序。例如,可以创建(sex,rating)索引用于下面的査询:

mysql> SELECT <cols> FROM profiles WHERE sex='M' ORDER BY rating LIMIT 10;

  这个査询同时使用了ORDER BY和LIMIT,如果没有索引的话会很慢。

  即使有索引,如果用户界面上需要翻页,并且翻页翻到比较靠后时査询也可能非常慢。 下面这个查询就通过ORDER BY和LIMIT偏移量的组合翻页到很后面的时候:

mysql> SELECT <cols> FROM profiles WHERE sex='M' ORDER BY rating LIMIT 100000, 10;

  无论如何创建索引,这种査询都是个严重的问题。因为随着偏移量的增加,MySQL需要花费大量的时间来扫描需要丢弃的数据。反范式化、预先计算和缓存可能是解决这类查询的仅有策略。一个更好的办法是限制用户能够翻页的数量,实际上这对用户体验的影响不大,因为用户很少会真正在乎搜索结果的第10 000页。 

  优化这类索引的另一个比较好的策略是使用延迟关联,通过使用覆盖索引査询返回需要的主键,再根据这些主键关联原表获得需要的行。这可以减少MySQL扫描那些需要丢弃的行数。下面这个査询显示了如何高效地使用(sex,rating)索引进行排序和分页:

mysql> SELECT <cols> FROM profiles INNER JOIN (
    ->    SELECT <primary key cols> FROM profiles
    ->    WHERE x.sex='M' ORDER BY rating LIMIT 100000, 10
    -> ) AS x USING(<primary key cols>);

 

5.维护索引和表

  即使用正确的类型创建了表并加上了合适的索引,工作也没有结束:还需要维护表和索 引来确保它们都正常工作。维护表有三个主要的目的:找到并修复损坏的表,维护准确的索引统计信息,减少碎片。

 

5.1 找到并修复损坏的表

  表损坏(corruption)是很糟糕的事情。对于MyISAM存储引擎,表损坏通常是系统崩溃导致的。其他的引擎也会由于硬件问题、MySQL本身的缺陷或者操作系统的问题导致索引损坏。

  损坏的索引会导致查询返回错误的结果或者莫须有的主键冲突等问题,严重时甚至还会导致数据库的崩溃。如果你遇到了古怪的问题——例如一些不应该发生的错误——可以尝试运行CHECK TABLE来检査是否发生了表损坏(注意有些存储引擎不支持该命令;而有些引擎则支持以不同的选项来控制完全检査表的方式)。CHECK TABLE通常能够找出大多数的表和索引的错误。

  可以使用REPAIR TABLE命令来修复损坏的表,但同样不是所有的存储引擎都支持该命令。如果存储引擎不支持,也可通过一个不做任何操作(no-op)的ALTER操作来重建表,例如修改表的存储引擎为当前的引擎。下面是一个针对InnoDB表的例子:

mysql> ALTER TABLE innodb_tbl ENGINE=INNODB;

  此外,也可以使用一些存储引擎相关的离线工具,例如myisamchk或者将数据导出一份,然后再重新导入。不过,如果损坏的是系统区域,或者是表的“行数据”区域,而不是索引,那么上面的办法就没有用了。在这种情况下,可以从备份中恢复表,或者尝试从损坏的数据文件中尽可能地恢复数据。

  如果InnoDB引擎的表出现了损坏,那么一定是发生了严重的错误,需要立刻调査一下 原因。InnoDB—般不会出现损坏。InnoDB的设计保证了它并不容易被损坏。如果发生损坏,一般要么是数据库的硬件问题例如内存或者磁盘问题(有可能),要么是由于数据库管理员的错误例如在MySQL外部操作了数据文件(有可能),抑或是InnoDB本身的缺陷(不太可能)。常见的类似错误通常是由于尝试使用rsync备份InnoDB导致的。不存在什么査询能够让InnoDB表损坏,也不用担心暗处有“陷阱”。如果某条査询导致InnoDB数据的损坏,那一定是遇到了bug,而不是査询的问题。

  如果遇到数据损坏,最重要的是找出是什么导致了损坏,而不只是简单地修复,否则很有可能还会不断地损坏。可以通过设置innodb_force_recovery参数进入InnoDB的强制恢复模式来修复数据,更多细节可以参考MySQL手册。另外,还可以使用开源的InnoDB数据恢复工具箱(InnoDB Data Recovery Toolkit)直接从InnoDB数据文件恢复出数据(下载地址: http://www.percona.com/software/mysql-innodb-data-recovery-tools/)。

 

5.2 更新索引统计信息

  MySQL的査询优化器会通过两个API来了解存储引擎的索引值的分布信息,以决定如何使用索引。第一个API是records_in_range(),通过向存储引擎传入两个边界值获取在这个范围大概有多少条记录。对于某些存储引擎,该接口返回精确值,例如MyISAM;但对于另一些存储引擎则是一个估算值,例如InnoDB。

  第二个API是info(),该接口返回各种类型的数据,包括索引的基数(每个键值有多少条记录)。

  如果存储引擎向优化器提供的扫描行数信息是不准确的数据,或者执行计划本身太复杂 以致无法准确地获取各个阶段匹配的行数,那么优化器会使用索引统计信息来估算扫描行数。MySQL优化器使用的是基于成本的模型,而衡量成本的主要指标就是一个査询需要扫描多少行。如果表没有统计信息,或者统计信息不准确,优化器就很有可能做出错误的决定。可以通过运行ANALYZE TABLE来重新生成统计信息解决这个问题。

  每种存储引擎实现索引统计信息的方式不同,所以需要进行ANALYZE TABLE的频率也因不同的引擎而不同,每次运行的成本也不同:

  • Memory引擎根本不存储索引统计信息。
  • MyISAM将索引统计信息存储在磁盘中,ANALYZE TABLE需要进行一次全索引扫描来计算索引基数。在整个过程中需要锁表。
  • 直到MySQL5.5版本,InnoDB也不在磁盘存储索引统计信息,而是通过随机的索引访问进行评估并将其存储在内存中。

  可以使用SHOW INDEX FROM命令来査看索引的基数(Cardinality)。例如:

mysql> SHOW INDEX FROM sakila.actor\G
*************************** 1. row ***************************
       Table: actor
  Non_unique: 0
    Key_name: PRIMARY
Seq_in_index: 1
 Column_name: actor_id
   Collation: A
 Cardinality: 200
    Sub_part: NULL
      Packed: NULL
        Null:
  Index_type: BTREE
     Comment:
*************************** 2. row ***************************
       Table: actor
  Non_unique: 1
    Key_name: idx_actor_last_name
Seq_in_index: 1
 Column_name: last_name
   Collation: A
 Cardinality: 200
    Sub_part: NULL
      Packed: NULL
        Null:
  Index_type: BTREE
     Comment:

  这个命令输出了很多关于索引的信息,在MySQL手册中对上面每个字段的含义都有详细的解释。这里需要特别提及的是索引列的基数(Cardinality),其显示了存储引擎估算索引列有多少个不同的取值。在MySQL5.0和更新的版本中,还可以通过INF0RMATI0N_SCHEMA.STATISTICS表很方便地査询到这些信息。例如基于INF0RMATI0N_SCHEMA的表,可以编写一个査询给出当前选择性比较低的索引。需要注意的是,如果服务器上的库表非常多,则从这里获取元数据的速度可能会非常慢,而且会给MySQL带来额外的压力。

  InnoDB的统计信息值得深入研究。InnoDB引擎通过抽样的方式来计算统计信息,首先 随机地读取少量的索引页面,然后以此为样本计算索引的统计信息。在老的InnoDB版本中,样本页面数是8,新版本的InnoDB可以通过参数innodb_stats_sample_pages来设置样本页的数量。设置更大的值,理论上来说可以帮助生成更准确的索引信息,特别是对于某些超大的数据表来说,但具体设置多大合适依赖于具体的环境。

  InnoDB会在表首次打开,或者执行ANALYZE TABLE,抑或表的大小发生非常大的变化 (大小变化超过十六分之一或者新插入了 20亿行都会触发)的时候计算索引的统计信息。

  InnoDB在打开某些INF0RMATI0N_SCHEMA表,或者使用SHOW TABLE STATUS 和 SHOW INDEX,抑或在MySQL客户端开启自动补全功能的时候都会触发索引统计信息的更新。如果服务器上有大量的数据,这可能就是个很严重的问题,尤其是当I/O比较慢的时候。客户端或者监控程序触发索引信息采样更新时可能会导致大量的锁,并给服务器带来很多的额外压力,这会让用户因为启动时间漫长而沮丧。只要SHOW INDEX查看索引统计信息,就一定会触发统计信息的更新。可以关闭innodb_stats_on_metadata参数来避免上面提到的问题。

  如果使用Percona版本,使用的就是XtraDB引擎而不是原生的InnoDB引擎,那么可以通过innodb_stats_auto_update参数来禁止通过自动采样的方式更新索引统计信息,这时需要手动执行ANALYZE TABLE命令来更新统计信息。如果某些査询执行计划很不稳定的话,可以用该办法固化査询计划。我们当初引入这个参数也正是为了解决一些客户的这种问题。

  如果想要更稳定的执行计划,并在系统重启后更快地生成这些统计信息,那么可以使用 系统表来持久化这些索引统计信息。甚至还可以在不同的机器间迁移索引统计信息,这样新环境启动时就无须再收集这些数据。在Percona5.1版本和官方的5.6版本都已经加入这个特性。在Percona版本中通过innodb_use_sys_stats_table参数可以启用该特性,官方5.6版本则通过innodb_analyze_is_persistent参数控制。

  一旦关闭索引统计信息的自动更新,那么就需要周期性地使用ANALYZE TABLE来手动更新。否则,索引统计信息就会永远不变。如果数据分布发生大的变化,可能会出现一些很糟糕的执行计划。

 

5.3 减少索引和数据的碎片

  B-Tree索引可能会碎片化,这会降低査询的效率。碎片化的索引可能会以很差或者无序的方式存储在磁盘上。

  根据设计,B-Tree需要随机磁盘访问才能定位到叶子页,所以随机访问是不可避免的。然而,如果叶子页在物理分布上是顺序且紧密的,那么査询的性能就会更好。否则,对于范围査询、索引覆盖扫描等操作来说,速度可能会降低很多倍;对于索引覆盖扫描这一点更加明显。

  表的数据存储也可能碎片化。然而,数据存储的碎片化比索引更加复杂。有三种类型的数据碎片。

  行碎片(Row fragmentation)

这种碎片指的是数据行被存储为多个地方的多个片段中。即使査询只从索引中访问一行记录,行碎片也会导致性能下降。

  行间碎片(Intra-row fragmentation)

行间碎片是指逻辑上顺序的页,或者行在磁盘上不是顺序存储的。行间碎片对诸如全表扫描和聚簇索引扫描之类的操作有很大的影响,因为这些操作原本能够从磁盘上顺序存储的数据中获益。

  剩余空间碎片(Free space fragmentation)

剩余空间碎片是指数据页中有大量的空余空间。这会导致服务器读取大量不需要的数据,从而造成浪费。

  对于MyISAM表,这三类碎片化都可能发生。但InnoDB不会出现短小的行碎片;InnoDB会移动短小的行并重写到一个片段中。

  可以通过执行OPTIMIZE TABLE或者导出再导入的方式来重新整理数据。这对多数存储引擎都是有效的。对于一些存储引擎如MyISAM,可以通过排序算法重建索引的方式来消除碎片。老版本的InnoDB没有什么消除碎片化的方法。不过最新版本InnoDB新增了“在线”添加和删除索引的功能,可以通过先删除,然后再重新创建索引的方式来消除索引的碎片化。 

  对于那些不支持OPTIMIZE TABLE的存储引擎,可以通过一个不做任何操作(no-op)的ALTER TABLE操作来重建表。只需要将表的存储引擎修改为当前的引擎即可:

mysql> ALTER TABLE <table> ENGINE=<engine>;

  对于开启了expand_fast_index_creation参数的Percona Server,按这种方式重建表,则会同时消除表和索引的碎片化。但对于标准版本的MySQL则只会消除表(实际上是聚簇索引)的碎片化。可用先删除所有索引,然后重建表,最后重新创建索引的方式模拟Percona Server的这个功能。

  应该通过一些实际测量而不是随意假设来确定是否需要消除索引和表的碎片化。Percona的XtraBackup有个--stats参数以非备份的方式运行,而只是打印索引和表的统计情况,包括页中的数据量和空余空间。这可以用来确定数据的碎片化程度。另外也要考虑数据是否已经达到稳定状态,如果你进行碎片整理将数据压缩到一起,可能反而会导致后续的更新操作触发一系列的页分裂和重组,这会对性能造成不良的影响(直到数据再次达到新的稳定状态)。

 

6.总结

  通过本章可以看到,索引是一个非常复杂的话题! MySQL和存储引擎访问数据的方式,加上索引的特性,使得索引成为一个影响数据访问的有力而灵活的工作(无论数据是在磁盘中还是在内存中)。

  在MySQL中,大多数情况下都会使用B-Tree索引。其他类型的索引大多只适用于特殊的目的。如果在合适的场景中使用索引,将大大提高査询的响应时间。本章将不再介绍更多这方面的内容了,最后值得总的回顾一下这些特性以及如何使用B-Tree索引。

  在选择索引和编写利用这些索引的査询时,有如下三个原则始终需要记住:

    1.单行访问是很慢的。特别是在机械硬盘存储中(SSD的随机I/O要快很多,不过这一点仍然成立)。如果服务器从存储中读取一个数据块只是为了获取其中一行,那么就浪费了很多工作。最好读取的块中能包含尽可能多所需要的行。使用索引可以创建位置引用以提升效率。

    2.按顺序访问范围数据是很快的,这有两个原因。第一,顺序I/O不需要多次磁盘寻道,所以比随机I/O要快很多(特别是对机械硬盘)。第二,如果服务器能够按需要顺序读取数据,那么就不再需要额外的排序操作,并且GROUP BY査询也无须再做排序和将行按组进行聚合计算了。

    3.索引覆盖査询是很快的。如果一个索引包含了査询需要的所有列,那么存储引擎就不需要再回表査找行。这避免了大量的单行访问,而上面的第1点已经写明单行访问是很慢的。

  总的来说,编写査询语句时应该尽可能选择合适的索引以避免单行査找、尽可能地使用 数据原生顺序从而避免额外的排序操作,并尽可能使用索引覆盖査询。这与本章开头提到的Lahdenmaki和Leach的书中的“三星”评价系统是一致的。

  如果表上的每一个查询都能有一个完美的索引来满足当然是最好的。但不幸的是,要这么做有时可能需要创建大量的索引。还有一些时候对某些査询是不可能创建一个达到“三星”的索引的(例如査询要按照两个列排序,其中一个列正序,另一个列倒序)。这时必须有所取舍以创建最合适的索引,或者寻求替代策略(例如反范式化,或者提前计算汇总表等)。

  理解索引是如何工作的非常重要,应该根据这些理解来创建最合适的索引,而不是根据一些诸如“在多列索引中将选择性最高的列放在第一列”或“应该为WHERE子句中出现的所有列创建索引”之类的经验法则及其推论。

  那如何判断一个系统创建的索引是合理的呢? 一般来说,我们建议按响应时间来对査询进行分析。找出那些消耗最长时间的査询或者那些给服务器带来最大压力的査询,然后检査这些査询的schema、SQL和索引结构,判断是否有查询扫描了太多的行,是否做了很多额外的排序或者使用了临时表,是否使用随机I/O访问数据,或者是有太多回表査询那些不在索引中的列的操作。

  如果一个査询无法从所有可能的索引中获益,则应该看看是否可以创建一个更合适的索引来提升性能。如果不行,也可以看看是否可以重写该査询,将其转化成一个能够高效利用现有索引或者新创建索引的査询,其它章节介绍。

  如果根据之前介绍的基于响应时间的分析不能找出有问题的査询呢?是否可能有我们没有注意到的“很糟糕”的査询,需要一个更好的索引来获取更高的性能? 一般来说,不可能。对于诊断时抓不到的査询,那就不是问题。但是,这个查询未来有可能会成为问题,因为应用程序、数据和负载都在变化。如果仍然想找到那些索引不是很合适的査询,并在它们成为问题前进行优化,则可以使用pt-query-digest的査询审査“review”功能,分析其EXPLAIN出来的执行计划。

posted @ 2021-11-11 19:06  小家电维修  阅读(83)  评论(0编辑  收藏  举报