第五章 创建高性能的索引

这是《高性能 MySQL(第三版)》第五章的读书笔记。

索引在 MySQL 中也叫键(Key),是存储引擎用于快速找到记录的一种数据结构。

表的数据量增大时,索引对良好的性能非常关键。索引是优化查询性能的最有效的手段。

1. 索引基础

MySQL 中,存储引擎先在索引中找到对应值,然后根据匹配的索引记录找到对应的数据行。

mysql> SELECT * FROM blog.user WHERE user_id = 5;

如果 user_id 列上建有索引,MySQL 将使用该索引找到 user_id 为 5 的行。MySQL 先在索引上按值进行查找,然后返回所有包含该值的数据行。

索引可以包含一个或多个列的值。如果包含多个列,MySQL 只能高效使用最左侧的前缀列。

1.1 索引类型

B-Tree 索引

B-Tree 索引使用 B-Tree 数据结构来存储数据。

MySQL 的默认索引类型,大多数存储引擎都支持,只是存储结构会有所差异。例如 InnoDB 使用 B+Tree。

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

B-Tree 对索引列是顺序组织存储的,适合查找某个范围内的数据。对于基于文本域的索引树,索引按字母排序。

对于下面的数据表:

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)
);
INSERT INTO people VALUES
    ('Allen', 'Cuba', '1960-01-01', 'f'),
    ('Akroyd', 'Debbie', '1990-03-01', 'f'),
    ('Akroyd', 'Kristin', '1978-03-01', 'f'),
    ('Allen', 'Kristin', '1990-03-01', 'f'),
    ('Allen', 'Kim', '1920-03-01', 'f'),
    ('Allen', 'Merry', '1930-03-01', 'f'),
    ('Barry', 'Julia', '1990-03-01', 'f'),
    ('Bssia', 'Vivew', '1990-03-01', 'f'),
    ('Bssia', 'Vivew', '1960-03-01', 'f')
;

表中每行数据的索引中包含了 last_name, first_name, dob 三列。对应的内存结构是:

B-Tree 索引适合的查询类型有:

  • 全值匹配:同时匹配索引中的所有列,例如上面示例中,查找姓名为 Cuba ALlen,生日为 1960-01-01 的人。例如:
SELECT * FROM people WHERE last_name = 'Allen' AND first_name = 'Cuba' AND dob = '1960-01-01';
  • 匹配最左值:只匹配索引中的第一列。例如:
SELECT * FROM people WHERE last_name = 'Allen';
  • 匹配列前缀:匹配索引中的第一列的前缀,例如查找姓氏是 A 的人。例如:
SELECT * FROM people WHERE last_name LIKE 'A%';
  • 匹配范围值:匹配索引中的第一列的范围,例如查找姓氏在 Allen 和 Eine 之间的人。例如:
SELECT * FROM people WHERE last_name BETWEEN 'Allen' AND 'Eine';
  • 精确匹配某一列并范围匹配另一列:索引的第一列完全匹配,第二列范围匹配。例如,查找姓氏是 Allen,名字是字母 K 开头的人。例如:
SELECT * FROM people WHERE last_name = 'Allen' AND first_name LIKE 'K%';
  • 只访问索引的查询:查询只需要访问索引,无需访问数据行。例如:
SELECT last_name FROM people WHERE last_name LIKE 'A%';

B-Tree 索引的限制:

  • 要使用 B-Tree 索引,必须使用索引中的最左列,且必须从前缀开始。下面的查询没有用到索引:
SELECT * FROM people WHERE last_name LIKE '%en';    <----------没用到索引中最左列的前缀
SELECT * FROM people WHERE first_name LIKE 'K%';    <----------没用到索引中的最左列
SELECT * FROM people WHERE dob = '1960-01-01';  <----------没用到索引中的最左列
  • 索引中的列不能跳过。如果使用索引的最右列,则必须同时使用前面的所有列。
  • 如果某个列使用了范围查询,则这个列的右侧的列无法使用索引。例如:
SELECT * FROM people WHERE last_name= 'Allen' and first_name LIKE 'K%' and dob = '1920-03-01';

哈希索引

哈希索引特点及限制

哈希索引基于哈希表实现,只有精确匹配索引所有列的查询才会有效。对于每一行数据,存储引擎都会对所有的索引列计算一个哈希码。哈希索引将所有的哈希码存储在索引中,同时在哈希表中保存指向每个数据行的指针。

MySQL 中只有 Memory 引擎显式支持哈希索引,且支持非唯一哈希索引。如果多个列的哈希值相同,索引会以链表的方式存放多个记录指针到同一个哈希条目中。

对于下面的数据表:

CREATE TABLE hash_test (
    fname VARCHAR(50) NOT NULL,
    Lname VARCHAR(50) NOT NULL,
    KEY USING HASH(fname)
) ENGINE=MEMORY;
INSERT INTO hash_test VALUES
    ('Arjen', 'Lentz'),
    ('Baron', 'Shwora'),
    ('Peter', 'Zaier'),
    ('Vadim', 'Tkachen')
;

假设使用哈希函数 f(),其返回值如下:

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

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

Slot Value
2323 指向第 1 行的指针
2458 指向第 4 行的指针
7837 指向第 2 行的指针
8784 指向第 3 行的指针

其中每个 Slot 的编号是顺序的,但是数据行不是。对于下面的查询:

SELECT * FROM hash_test WHERE fname = 'Peter';

MySQL 会首先计算 Peter 的哈希值,并用该值寻找对应的记录指针,最后对比字段数据。哈希值为 8784,从而找到指向第 3 行的指针,然后对比这一行的字段数据是否是 Peter。

哈希索引的限制:

  • 哈希索引只存储哈希值和行指针,不存储字段值。所以每次查询都必须读行数据。
  • 哈希索引数据不是按照索引值顺序排序的,无法用于排序。
  • 不支持部分索引列匹配。哈希索引用索引列的所有字段计算哈希值,查询时需同时提供所有的索引列才能使用哈希索引。
  • 哈希索引只支持等值比较查询,包括 =IN()<=>。不支持范围查询。
  • 发生哈希冲突(不同的索引列值有相同的哈希值)时,存储引擎必须遍历链表中所有的行指针,逐行比较直到找到所有符合条件的行。
  • 哈希冲突多的话,索引维护操作的代价很大。

哈希索引只适用于一些特定的场合。例如星型 schema,需要关联很多查找表,哈希索引适合查找表的需求。

InnoDB 引擎有一个特殊功能“自适应哈希索引(adaptive hash index)。当 InnoDB 引擎注意到某些索引值被使用的非常频繁时,它会在内存中基于 B-Tree 索引之上再创建一个哈希索引。这个功能完全自动,用户无法控制,但是可以关闭。

创建自定义哈希索引

如果存储引擎不支持哈希索引,可以参考 InnoDB,在 B-Tree 基础上创建一个伪哈希索引。这个索引还是使用 B-Tree 进行查找,但是使用哈希值而不是键本身进行索引查找。需要在查询的 WHERE 字句中手动指定使用哈希函数。

例如,需要对某个很长的 URL 字段进行索引。如果使用 B-Tree 来存储,需要消耗大量存储空间。但是插入、查询等语句简单:

SELECT * FROM url WHERE url = "http://www.baidu.com";

现在,删除原来 url 列上的索引,增加一个被索引的 url_crc 列,使用 CRC32 做哈希,查询语句如下:

SELECT * FROM url WHERE url = "http://www.baidu.com" AND url_crc = CRC32("http://www.baidu.com");

MySQL 优化器会使用这个选择性很高而体积很小的基于 url_crc 的索引来完成查询,高效。如果对完整的 URL 字符串做索引,会非常慢。

哈希值可以手动维护,也可以通过触发器维护。通过触发器在插入和更新时维护 url_crc 列的示例:

CREATE TABLE pseudo_hash (
    id INT UNSIGNED NOT NULL AUTO_INCREMENT,
    url VARCHAR(255) NOT NULL,
    url_crc INT UNSIGNED NOT NULL DEFAULT 0,
    PRIMARY KEY(id)
);

创建触发器,先通过 DELIMITER 临时修改分隔符,这样就可以在触发器定义中使用分号:

DELIMITER //

CREATE TRIGGER pseudohash_crc_ins
BEFORE INSERT
ON pseudo_hash
FOR EACH ROW
BEGIN
SET NEW.url_crc = CRC32(NEW.url);
END;
//

CREATE TRIGGER pseudohash_crc_upd
BEFORE UPDATE
ON pseudo_hash
FOR EACH ROW
BEGIN
SET NEW.url_crc = CRC32(NEW.url);
END;
//

DELIMITER ;

验证触发器能否维护哈希索引:

mysql> INSERT INTO pseudo_hash (url) VALUES('http://www.mysql.com') ;
Query OK, 1 row affected (0.00 sec)

mysql> SELECT * FROM pseudo_hash;
+----+----------------------+------------+
| id | url                  | url_crc    |
+----+----------------------+------------+
|  1 | http://www.mysql.com | 1560514994 |
+----+----------------------+------------+
1 row in set (0.00 sec)

mysql> UPDATE pseudo_hash SET url='http://www.baidu.com' WHERE id=1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> SELECT * FROM pseudo_hash;
+----+----------------------+------------+
| id | url                  | url_crc    |
+----+----------------------+------------+
|  1 | http://www.baidu.com | 3500265894 |
+----+----------------------+------------+
1 row in set (0.00 sec)

注意,这里的哈希函数尽量使用 CRC32(),如果使用 MD5() 或 SHA1(),尽管碰撞概率降低了,但是哈希值非常长,且查询速度慢。

mysql> SELECT CRC32('http://www.baidu.com');
+-------------------------------+
| CRC32('http://www.baidu.com') |
+-------------------------------+
|                    3500265894 |   <---------------INT 类型,32 位
+-------------------------------+
1 row in set (0.00 sec)

mysql> SELECT MD5('http://www.baidu.com');
+----------------------------------+
| MD5('http://www.baidu.com')      |
+----------------------------------+
| bfa89e563d9509fbc5c6503dd50faf2e |    <---------------String 类型,128 位
+----------------------------------+
1 row in set (0.00 sec)

mysql> SELECT SHA1('http://www.baidu.com');
+------------------------------------------+
| SHA1('http://www.baidu.com')             |
+------------------------------------------+
| 633a42441e296c9004a78abe0b2ee3b37559d32f |    <---------------String 类型,160 位
+------------------------------------------+
1 row in set (0.00 sec)

如果数据量非常大,CRC32() 出现大量冲突时,可以自己实现返回 64 位整数的哈希函数。简单示例如下:

mysql> SELECT CONV(RIGHT(MD5('http://www.baidu.com'), 16), 16, 10) AS HASH64;
+----------------------+
| HASH64               |
+----------------------+
| 14251166297358315310 |
+----------------------+
1 row in set (0.00 sec)

MD5() 函数返回 32 位 16 进制的哈希值,RIGHT() 函数返回哈希值最后的 16 位字符。CONV() 函数将字符串从 16 进制转为 10 进制。

处理哈希冲突

使用哈希索引进行查询时,必须在 WHERE 子句中包含哈希值和对应列值,即使发生冲突也可以正常工作:

SELECT * FROM url WHERE url = "http://www.baidu.com" AND url_crc = CRC32("http://www.baidu.com");

CRC32() 返回的是 32 位整数,当索引有 93000 条记录时出现冲突的概率是 1%。将 /usr/share/dict/words 中的词导入数据表,会有 98569 行。WHERE 子句中只包含哈希值时,冲突时返回多行数据而不是一行数据。

空间数据索引 R-Tree

用于地理数据存储。空间索引会从所有维度来索引数据,可以使用任意维度来组合查询。MySQL 的 GIS 支持并不完善,可以使用 PostgreSQL 的 PostGIS。

全文索引

查找文本中的关键词,而不是直接比较索引中的值。类似于搜索引擎,而不是简单的 WHERE 条件匹配。

在同一个列上可以同时创建全文索引和基于值的 B-Tree 索引。全文索引适用于 MATCH AGAINST 操作,而不是普通的 WHERE 条件操作。

2. 索引的优点

通过索引,服务器可以快速定位到表的位置。

对于 B-Tree 索引,按照顺序存储数据,MySQL 可以进行 ORDER BY 和 GROUP BY 操作。因为数据有序存储,所以 B-Tree 也会将相关的列值存储在一起。如果要查询的字段包含在索引中,则只使用索引就能完成查询。索引优点:

  1. 减少服务器需要扫描的数据量。
  2. 帮助服务器避免排序和临时表。
  3. 将随机 I/O 变为顺序 I/O。

数据量小的表,通常全局扫描更高效。中大型表,使用索引更有效。对于特大型表,建立和使用索引的代价也随之增长,需要使用其他技术,例如分区。

3. 高性能的索引策略

3.1 独立的列

查询语句中,如果列不是独立的,MySQL 就不会使用索引。列不是独立的,指的是列是表达式的一部分,或函数参数。例如:

SELECT ... WHERE id + 1 = 5;
SELECT ... WHERE TO_DAYS(CURRENT_DATE) - TO_DAYS(date_col) <= 10;

3.2 前缀索引和索引选择性

在很长的字符列上使用索引时,会使索引大且慢。可以使用前面的模拟哈希索引。

也可以只索引开始的部分字符。对于 BLOB、TEXT 或很长的 VARCHAR 类型的列,必须使用前缀索引。需要选择足够长的前缀以保证较高的选择性(防止重复),同时不能太长以节约空间。

前缀索引的优缺点:

  • 使索引更小、更快。
  • MySQL 无法使用前缀索引做 ORDER BY 和 GROUP BY,也无法使用前缀索引做覆盖扫描。

确定合适的前缀长度

1. 生成测试数据

在示例数据库 sakila 中没有合适的例子,需要从表 city 中生成一个示例表:

CREATE TABLE sakila.city_demo(city VARCHAR(50) NOT NULL);
INSERT INTO sakila.city_demo(city) SELECT city FROM sakila.city;

INSERT INTO sakila.city_demo(city) SELECT city FROM sakila.city_demo;   -- <---- 这一行重复多次

UPDATE sakila.city_demo SET city = (SELECT city FROM sakila.city ORDER BY RAND() LIMIT 1);

例如下面的例子:

MariaDB [sakila]> CREATE TABLE sakila.city_demo(city VARCHAR(50) NOT NULL);
Query OK, 0 rows affected (0.196 sec)

MariaDB [sakila]> INSERT INTO sakila.city_demo(city) SELECT city FROM sakila.city;
Query OK, 600 rows affected (0.032 sec)
Records: 600  Duplicates: 0  Warnings: 0

MariaDB [sakila]> INSERT INTO sakila.city_demo(city) SELECT city FROM sakila.city_demo;
Query OK, 600 rows affected (0.004 sec)
Records: 600  Duplicates: 0  Warnings: 0

MariaDB [sakila]> INSERT INTO sakila.city_demo(city) SELECT city FROM sakila.city_demo;
Query OK, 1200 rows affected (0.007 sec)
Records: 1200  Duplicates: 0  Warnings: 0

...

MariaDB [sakila]> INSERT INTO sakila.city_demo(city) SELECT city FROM sakila.city_demo;
Query OK, 76800 rows affected (0.277 sec)
Records: 76800  Duplicates: 0  Warnings: 0

MariaDB [sakila]> UPDATE sakila.city_demo SET city = (SELECT city FROM sakila.city ORDER BY RAND() LIMIT 1);
Query OK, 153358 rows affected (1 min 6.620 sec)
Rows matched: 153600  Changed: 153358  Warnings: 0

2. 计算最佳前缀长度

可以通过计算完整列的选择性来计算合适的前缀长度,使前缀的选择性接近于完整列的选择性。计算完整列的选择性:

MariaDB [sakila]> SELECT COUNT(DISTINCT city)/COUNT(*) FROM sakila.city_demo;
+-------------------------------+
| COUNT(DISTINCT city)/COUNT(*) |
+-------------------------------+
| 0.0039                        |
+-------------------------------+
1 row in set (0.119 sec)

计算不同前缀长度的选择性:

MariaDB [sakila]> 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.0030 | 0.0037 | 0.0038 | 0.0039 | 0.0039 |
+--------+--------+--------+--------+--------+
1 row in set (0.312 sec)

前缀长度达到 7 时,选择性提升的幅度基本稳定。

创建前缀索引

MariaDB [sakila]> ALTER TABLE sakila.city_demo ADD KEY(city(7));
Query OK, 0 rows affected (3.350 sec)               
Records: 0  Duplicates: 0  Warnings: 0

3.3 多列索引

多列索引同时在多个列上建立一个索引,索引列的顺序很重要。对于 AND 条件导致的索引相交时,最好使用一个包含所有相关列的多列索引,而不是多个独立的单列索引。

3.4 选择合适的索引列顺序

最常用的规则:将选择性最高的列放到索引最前列。

3.5 聚簇索引

聚簇索引是一种数据存储方式,而不是索引类型。InnoDB 的聚簇索引在同一个结构中保存了 B-Tree 索引和数据行。

表有聚簇索引时,数据行实际上存放在索引的叶子页中。“聚簇”表示将数据行和相邻的键值存储在一起。因为数据行只能存储在一个地方,所以一个表只能有一个聚簇索引。

聚簇索引的优点:

  • 把相关数据保存在一起。例如电子邮箱,根据用户 ID 聚集数据,只需从磁盘读少量数据页即可获取某用户的全部邮件。不使用聚簇索引的话,每封电子邮件都可能导致一次磁盘 I/O。
  • 数据访问更快。聚簇索引将索引和数据保存在同一个 B-Tree 中,读数据快。
  • 使用覆盖索引扫描的查询可用直接使用页节点中的主键值。

聚簇索引的缺点:

  • 聚簇索引提高了 I/O 密集型应用的性能。对于内存型存储引擎用不到。
  • 插入速度严重依赖插入顺序。按主键顺序插入是加载数据到 InnoDB 表中速度最快的方式。
  • 更新聚簇索引列的代价很高。
  • 基于聚簇索引的表在插入新行,或主键被更新导致需要移动行时,可能导致“页分裂”问题。当行的主键值要求必须将这行插入某个已满的页中,存储引擎会将该页分裂成两个页面来容纳该行。
  • 聚簇索引使全表扫描变慢。尤其是行比较稀疏,或页分裂导致数据不连续的时候。
  • 二级索引(非聚簇索引)访问需要两次索引查找,而不是一次。

3.6 覆盖索引

一般通过查询的 WHERE 条件来创建合适的索引。但是设计优秀的索引应该考虑整个查询。对于经常使用的某几个列,可以考虑添加覆盖索引。

覆盖索引:索引包含(覆盖)所有需要查询的字段的值。MySQL 只能用 B-Tree 索引做覆盖索引。

覆盖索引的优点:

  • 索引条目通常远小于数据行大小,容易放入内存缓存,且可以减少数据访问量
  • 索引按照列值的顺序存储(至少在单个页内如此)
  • 一些存储引擎如 MyISAM 在内存中只缓存索引
  • 由于 InnoDB 的聚簇索引,覆盖索引对 InnoDB 表特别有用。InnoDB 的二级索引在叶子节点中保存了行的主键值,如果二级主键能够覆盖查询,可以避免对主键索引的二次查询

3.7 使用索引扫描来做排序

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

  • 通过排序操作 ORDER BY 或 GROUP BY。Extra 列显示“Using filesort”。
  • 按索引顺序扫描。如果 EXPLAIN 出来的 type 列的值为“index”,则说明 MySQL 使用了索引扫描来排序。不要和 Extra 列的“Using index”搞混。

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

MySQL 的索引可以同时用于排序和查找行。

用索引(而不是排序操作)对结果排序的规则:

  • 只有当索引的列顺序和 ORDER BY 子句的顺序完全一致,并且所有列的排序方向(倒序或正序)都一样时,MySQL 才能使用索引来对结果做排序。
  • 如果查询需要关联多张表,则只有当 ORDER BY 子句引用的字段全部为第一个表时,才能使用索引做排序。
  • ORDER BY 子句和查找型查询的限制是一样的:需要满足索引的最左前缀的要求。例外情况:前导列为常量,如果 WHERE 子句或 JOIN 子句将这些列指定为常量,则仍会使用索引排序。

Sakila 示例数据库的 rental 表在列(rental_date, inventory_id, customer_id)上面创建了索引。

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 NULL 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 INDEX `rental_date` (`rental_date`, `inventory_id`, `customer_id`),
    INDEX `idx_fk_inventory_id` (`inventory_id`),
    INDEX `idx_fk_customer_id` (`customer_id`),
    INDEX `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
)
COLLATE='utf8_general_ci'
ENGINE=InnoDB
AUTO_INCREMENT=16050
;

下面示例中,WHERE 子句将前导列指定为常量。通过 rental_date 索引为下面的查询进行排序,从 EXPLAIN 中可以看到没有出现文件排序(filesort)操作:

mysql> EXPLAIN SELECT * FROM rental WHERE rental_date='2005-05-25' ORDER BY inventory_id, customer_id;
+----+-------------+--------+------+---------------+-------------+---------+-------+------+-------------+
| id | select_type | table  | type | possible_keys | key         | key_len | ref   | rows | Extra       |
+----+-------------+--------+------+---------------+-------------+---------+-------+------+-------------+
|  1 | SIMPLE      | rental | ref  | rental_date   | rental_date | 8       | const |    1 | Using where |
+----+-------------+--------+------+---------------+-------------+---------+-------+------+-------------+
1 row in set (0.00 sec)

3.8 压缩(前缀压缩)索引

MyISAM 使用前缀压缩来减小索引的大小,将更多索引放入内存中。默认只压缩字符串,可以开启对整数的压缩。

压缩后,磁盘空间占用变为之前的十分之一,但速度变慢。I/O 密集型应用可以考虑压缩,CPU 密集型应用就算了。

CREATE TABLE 时指定 PACK_KEYS 参数来控制索引压缩方式。

3.9 冗余和重复索引

可以在同一个列上创建多个索引。MySQL 需要单独维护重复的索引,且优化器在优化查询的时候也需要逐个考虑,影响性能。

重复索引:在同一个列上按相同顺序创建相同类型的索引。这是错误的用法。

冗余索引:如果创建了索引(A,B),再创建索引(A)就是冗余索引,因为这只是前一个索引的前缀索引。索引(A,B)也可以当做索引(A)来使用(冗余只是针对 B-Tree 索引而言)。再创建索引(B,A)或(B)都不是冗余索引。另外,不同类型的索引(例如哈希索引或全文索引)也不会是 B-Tree 索引的冗余索引,不管覆盖哪个列。一般不需要冗余索引。

冗余索引通常发生在添加新索引时,例如:

  • 增加一个新索引(A,B)而不是扩展已有的索引(A)
  • 将索引(A)扩展为(A,ID),其中 ID 是主键。对于 InnoDB 来说,主键列已经包含在二级索引中了,这会冗余。

3.10 未使用的索引

查找方法:在 MariaDB 中打开 userstates 服务器变量(默认是关闭的),让服务器运行一段时间后,通过查询 INFORMATION_SCHEMA.INDEX_STATISTICS 可以查到每个索引的使用频率。

另外,可以使用 Perconna Tollkit 中的 pt-index-usage 工具,读取查询日志后对每条查询进行 EXPLAIN 操作,然后打印关于索引和查询的报告。

3.11 索引和锁

InnoDB 只有在访问行的时候才会对其加锁,而索引可以减少 InnoDB 访问的行数,从而减少锁的数量。索引可以让查询锁定更少的行。

下面示例返回 2-4 行数据,但是会锁定 1-4 行的数据。因为 MySQL 为该查询选择的计划是索引范围扫描,存储引擎只接收了 WHERE 条件的第一部分,

mysql> SET AUTOCOMMIT=0;
Query OK, 0 rows affected (0.00 sec)

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT actor_id FROM sakila.actor WHERE actor_id < 5 AND actor_id <> 1 FOR UPDATE;
+----------+
| actor_id |
+----------+
|        2 |
|        3 |
|        4 |
+----------+
3 rows in set (0.00 sec)

mysql> EXPLAIN SELECT actor_id FROM sakila.actor WHERE actor_id < 5 AND actor_id <> 1 FOR UPDATE;
+----+-------------+-------+-------+---------------+---------+---------+------+------+--------------------------+
| id | select_type | table | type  | possible_keys | key     | key_len | ref  | rows | Extra                    |
+----+-------------+-------+-------+---------------+---------+---------+------+------+--------------------------+
|  1 | SIMPLE      | actor | range | PRIMARY       | PRIMARY | 2       | NULL |    3 | Using where; Using index |
+----+-------------+-------+-------+---------------+---------+---------+------+------+--------------------------+
1 row in set (0.00 sec)

mysql> EXPLAIN SELECT actor_id FROM sakila.actor WHERE actor_id < 5 FOR UPDATE;
+----+-------------+-------+-------+---------------+---------+---------+------+------+--------------------------+
| id | select_type | table | type  | possible_keys | key     | key_len | ref  | rows | Extra                    |
+----+-------------+-------+-------+---------------+---------+---------+------+------+--------------------------+
|  1 | SIMPLE      | actor | range | PRIMARY       | PRIMARY | 2       | NULL |    4 | Using where; Using index |
+----+-------------+-------+-------+---------------+---------+---------+------+------+--------------------------+
1 row in set (0.00 sec)

上面的例子中,Extra 列中出现了“Using WHERE”,表示 MySQL 服务器在存储引擎返回行后再应用 WHERE 过滤条件。怎么证明确实锁定了第一行数据呢,新开一个连接,访问这一行数据即可,会发现查询一直是挂起状态,直到上面窗口提交或回滚事务(留意总查询时间):

mysql> SELECT actor_id FROM sakila.actor WHERE actor_id = 1 FOR UPDATE;
+----------+
| actor_id |
+----------+
|        1 |
+----------+
1 row in set (45.64 sec)

4. 索引案例学习

网站用户信息表具有很多列,例如国家、城市、地区、性别,需要支持这些特征来搜索用户。同时,需要根据评分、最后登录时间等对用户排序并限制结果。

首先需要考虑的是使用索引排序,还是先检索数据再排序。使用索引排序需要严格限制索引和查询的设计。例如,如果希望用索引做根据评分的排序,则 WHERE 条件中的 age BETWEEN 18 AND 25 就无法使用索引。如果使用某个索引进行范围查询,也就无法再使用另一个索引(或该索引的后续字段)进行排序了。

4.1 支持多种过滤条件

有很多不同值的列(例如姓名),及频繁在 WHERE 子句中出现的列,可以添加索引。
需要做范围查询的列,尽量防在索引的后部,以便优化器能使用尽可能多的索引列。

例如对于交友类网站,性别和地区是常用的筛选条件,且基本上使用 = 来比较,而对于年龄,则经常用范围查询 BETWEEN。city 选择性通常不高(国内有几千个城市)。sex 虽然选择性很低,但是会在很多查询中用到,可以考虑创建不同组合索引的时候,用(sex, city)列作为前缀。 例如(sex, city, age)。

碰到不需要 sex 的查询,该如何使用这个具有(sex, city)前缀的索引呢?在查询条件中新增 AND sex IN ('m', 'f') 来让 MySQL 选择该索引。加上这个条件不会影响结果,但是可以匹配索引的最左前缀。但对于 city 就不行了,你得 IN 几千个城市。

对于这个 WHERE 子句:

WHERE eye_color IN ('blue', 'brown', 'red')
    AND hair_color IN ('red', 'black', 'orange', 'white')
    AND sex IN ('m', 'f')

优化器会转化成 3 * 4 * 2 总共 24 种组合,执行计划需要检查 WHERE 子句中的所有 24 中组合。如果组合数达到上千个,则会耗时耗内存,需要避免。

4.2 避免多个范围条件

从 EXPLAIN 的输出中很难区分 MySQL 要查询范围值(BETWEEN、>、< 等)还是列表值(IN,相当于多个等值条件),这两种情况对应的 type 都是 range:

mysql> EXPLAIN SELECT actor_id FROM sakila.actor WHERE actor_id IN (1, 4, 99);
+----+-------------+-------+-------+---------------+---------+---------+------+------+--------------------------+
| id | select_type | table | type  | possible_keys | key     | key_len | ref  | rows | Extra                    |
+----+-------------+-------+-------+---------------+---------+---------+------+------+--------------------------+
|  1 | SIMPLE      | actor | range | PRIMARY       | PRIMARY | 2       | NULL |    3 | Using where; Using index |
+----+-------------+-------+-------+---------------+---------+---------+------+------+--------------------------+
1 row in set (0.01 sec)

mysql> EXPLAIN SELECT actor_id FROM sakila.actor WHERE actor_id > 45;
+----+-------------+-------+-------+---------------+---------+---------+------+------+--------------------------+
| id | select_type | table | type  | possible_keys | key     | key_len | ref  | rows | Extra                    |
+----+-------------+-------+-------+---------------+---------+---------+------+------+--------------------------+
|  1 | SIMPLE      | actor | range | PRIMARY       | PRIMARY | 2       | NULL |  155 | Using where; Using index |
+----+-------------+-------+-------+---------------+---------+---------+------+------+--------------------------+
1 row in set (0.00 sec)

对于范围条件查询,MySQL 无法再使用范围列后面的其他索引列了。

4.3 优化排序

使用 filesort 文件排序对于小数据集是很快的,但是如果一个查询匹配的结果又上百万行,就需要索引。例如对于下面的查询:

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

可以创建索引(sex, rating)。这个查询同时使用了 ORDER BY 和 LIMIT,如果没有索引会很慢。

即使有索引,用户翻页到比较靠后的时候,也会很慢:

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

可以通过延迟关联来优化大偏移量的数据查询。先使用覆盖索引查询并返回需要的主键,然后根据这些主键关联原表获得所需数据行。这样可以减少 MySQL 对需要丢弃的行的扫描。

高效使用索引(sex, rating)进行排序和分页:

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 找到并修复损坏的表

如果遇到古怪的问题,可以尝试 CHECK TABLE 检查是否发生了表损坏,通常能找出大多数的表和索引错误。

可以用 REPAIR TABLE 命令来修复损坏的表。如果存储引擎不支持这个命令,可以通过不做任何操作的 ALTER 操作来重建表,例如修改表的存储引擎为当前引擎。

ALTER TABLE tb ENGINE=INNODB;

另外,某些存储引擎提供离线工具,例如 myisamchk。如果损坏的是系统区域或数据区域,而不是索引,则需要从备份中恢复表。

5.2 更新索引统计信息

MySQL 的查询优化器会通过两个 API 来了解存储引擎的索引值的分布信息,以决定如何使用索引:

  • records_in_range():通过向存储引擎传入两个边界值获取在这个范围内大概有多少条记录。MyISAM 可以返回精确值,但是 InnoDB 返回估计值。
  • info():返回各种类型的数据,包括索引的基数(每个键值有多少条记录)。

通过 ANALYZE TABLE 可以重新生成统计信息。如果存储引擎提供的扫描行数不准确,或执行计划太复杂以致无法准确获得各个阶段匹配的行数时,优化器会使用索引统计信息来估算扫描行数。

  • Memory 引擎不存储索引统计信息。
  • MyISAM 将索引统计信息存储在磁盘上。ANALYZE TABLE 需要进行全索引扫描来计算索引基数。整个过程都需要锁表。
  • InnoDB 通过随机索引访问进行评估并将其存储在内存中。

SHOW INDEX FROM 命令可以查看索引的基数(Cardinality),显示了存储引擎估算索引列有多少个不同的取值,也可以通过 INFORMATION_SCHEMA.STATISTICS:

mysql> SHOW INDEX FROM sakila.actor;
+-------+------------+---------------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
| Table | Non_unique | Key_name            | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment |
+-------+------------+---------------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
| actor |          0 | PRIMARY             |            1 | actor_id    | A         |         200 |     NULL | NULL   |      | BTREE      |         |               |
| actor |          1 | idx_actor_last_name |            1 | last_name   | A         |         200 |     NULL | NULL   |      | BTREE      |         |               |
+-------+------------+---------------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
2 rows in set (0.02 sec)

mysql> SELECT CARDINALITY FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_NAME='actor';
+-------------+
| CARDINALITY |
+-------------+
|         200 |
|         200 |
+-------------+
2 rows in set (0.02 sec)

5.3 减少索引和数据的碎片

B-Tree 索引会导致碎片化,降低查询效率。碎片化的索引无序存储在磁盘上。

根据设计,B-Tree 需要随机磁盘访问才能定位到叶子页,无法避免随机访问。但是如果叶子页在物理分布上是顺序且紧密的,查询性能会更好。否则对于范围查询、索引覆盖扫描,速度会慢很多倍。

表的数据存储也可能碎片化,有三种类型:

  • 行碎片:数据行存储在多个地方的多个片段中。只要访问这一行数据,性能都会下降。
  • 行间碎片:逻辑上顺序的页或行在磁盘上不是顺序存储的。对全表扫描和聚簇索引扫描影响很大。
  • 剩余空间碎片:数据页中有大量空余空间。浪费磁盘。

MyISAM 表会发生上面三种碎片,InnoDB 表不会出现行碎片。

可以通过 OPTIMIZE TABLE 或导出再导入的方式重新整理数据。

6. 总结

MySQL 默认使用 B-Tree 索引。

在选择索引和编写利用索引的查询时,三个原则:

  • 单行访问很慢。可以使用索引。
  • 按顺序访问范围数据很快。顺序 I/O 不需要多次磁盘寻道,且顺序读取的数据不需要额外排序。
  • 索引覆盖查询很快。索引包含了所需列时,不需要回表查找行。

应对措施:

  • 选择合适索引以避免单行查找
  • 尽可能使用数据原生顺序,避免额外的排序操作
  • 尽可能使用索引覆盖查询

猜你喜欢

转载自blog.csdn.net/kikajack/article/details/80256629