MySQL 索引优化

2018-08-14  本文已影响0人  杨仕杰

一. 最左前缀原理

以 MySQL 官方示例数据库 employees 的 titles 表的 <emp_no, from_date> 主键索引来进行说明。

(一). 全值匹配

当按照索引中所有列进行精确匹配 ("=" 或 "IN" 匹配) 时,索引可以被用到。理论上索引对顺序是敏感的,但是 MySQL 查询优化器会自动调整 WHERE 子句的条件顺序以使用适合的索引。

EXPLAIN SELECT * 
FROM employees.titles 
WHERE emp_no='10001' AND title='Senior Engineer' AND from_date='1986-06-26'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: titles
         type: const
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 59
          ref: const,const,const
         rows: 1
        Extra: 
1 row in set (0.00 sec)

(二). 最左前缀匹配

当查询条件精确匹配索引的左边连续一个或几个列时,索引可以被用到,但是只能用到一部分,即条件所组成的最左前缀。

EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: titles
         type: ref
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 4
          ref: const
         rows: 1
        Extra: 
1 row in set (0.00 sec)

(三). 全值匹配,但是中间某个条件未提供

此时索引使用情况和 "最左前缀匹配" 相同,因为 title 未提供,所以查询只用到了索引的第一列,而后面的 from_date 虽然也在索引中,但是由于 title 不存在而无法和左前缀连接,因此需要对结果进行扫描过滤 from_date。

EXPLAIN SELECT * 
FROM employees.titles WHERE emp_no='10001' AND from_date='1986-06-26'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: titles
         type: ref
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 4
          ref: const
         rows: 1
        Extra: Using where
1 row in set (0.00 sec

如果想让 from_date 列也使用索引而不是使用 WHERE 过滤,可以增加一个辅助索引 <emp_no, from_date>。除此之外,还可以使用一种被称之为 "隔离列" 的优化方法,将 emp_no 与 from_date 之间的 "坑" 填上。

SELECT DISTINCT(title) FROM employees.titles;
+--------------------+
| title              |
+--------------------+
| Senior Engineer    |
| Staff              |
| Engineer           |
| Senior Staff       |
| Assistant Engineer |
| Technique Leader   |
| Manager            |
+--------------------+
7 rows in set (0.20 sec)

EXPLAIN SELECT * 
FROM employees.titles 
WHERE emp_no='10001' 
  AND title IN (
    'Senior Engineer', 
    'Staff', 
    'Engineer', 
    'Senior Staff', 
    'Assistant Engineer', 
    'Technique Leader', 
    'Manager') 
  AND from_date='1986-06-26'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: titles
         type: range
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 59
          ref: NULL
         rows: 7
        Extra: Using where
1 row in set (0.00 sec)

SHOW PROFILES\G
*************************** 1. row ***************************
Query_ID: 1
Duration: 0.00032611
   Query: SELECT * FROM employees.titles 
          WHERE emp_no='10001' AND from_date='1986-06-26'
*************************** 2. row ***************************
Query_ID: 2
Duration: 0.00031092
   Query: SELECT * FROM employees.titles
          WHERE emp_no='10001'
            AND title IN (
                'Senior Engineer', 
                'Staff', 
                'Engineer', 
                'Senior Staff', 
                'Assistant Engineer', 
                'Technique Leader',
                 'Manager')
            AND from_date='1986-06-26'
2 rows in set (0.00 sec)

但只有在这种成为 "坑" 的列值比较少的情况下,才可以考虑用 "IN"来填补这个 "坑" 从而形成最左前缀。

(四). 匹配某列的前缀字符串

此时可以用到索引。如果如果通配符 % 不出现在开头,则可以用到索引,但根据具体情况不同可能只会用其中一个前缀。

EXPLAIN SELECT * 
FROM employees.titles WHERE emp_no='10001' AND title LIKE 'Senior%'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: titles
         type: range
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 56
          ref: NULL
         rows: 1
        Extra: Using where
1 row in set (0.00 sec)

(五). 范围查询

范围列可以用到索引 (必须是最左前缀),但是范围列后面的列无法用到索引。索引最多用于一个范围列,因此如果查询条件中有两个范围列则无法全用到索引。

EXPLAIN SELECT * 
FROM employees.titles WHERE emp_no < '10010' and title='Senior Engineer'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: titles
         type: range
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 4
          ref: NULL
         rows: 14
        Extra: Using where
1 row in set (0.00 sec)

MySQL 有一个非常有意思的地方:仅用 EXPLAIN 可能无法区分范围索引和多值匹配,因为在 type 中这两者都显示为 range。同时用了 "between" 并不意味着就是范围查询。

下面的查询中,看起来是用了两个范围查询,但作用于 emp_no 上的 "BETWEEN" 实际上相当于 "IN",也就是说 emp_no 实际是多值精确匹配。可以看到这个查询用到了索引全部三个列。因此在 MySQL 中要谨慎地区分多值匹配和范围匹配,否则会对 MySQ L的行为产生困惑。

EXPLAIN SELECT * 
FROM employees.titles
WHERE emp_no BETWEEN '10001' AND '10010'
AND title='Senior Engineer'
AND from_date BETWEEN '1986-01-01' AND '1986-12-31'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: titles
         type: range
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 59
          ref: NULL
         rows: 15
        Extra: Using where
1 row in set (0.00 sec)

(六). 查询条件中含有函数或表达式

如果查询条件中含有函数或表达式,则 MySQL 不会为这列使用索引。

EXPLAIN SELECT * 
FROM employees.titles WHERE emp_no='10001' AND left(title, 6)='Senior'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: titles
         type: ref
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 4
          ref: const
         rows: 1
        Extra: Using where
1 row in set (0.00 sec)

二. 索引选择性

索引虽然加快了查询速度,但也是有代价的。索引文件本身要消耗存储空间,同时索引会增加插入、删除和修改记录时的开销。另外 MySQL 在运行时也要消耗资源维护索引,因此索引并不是越多越好。

一般 3 种情况下不建议使用索引:

三. 前缀索引

前缀索引是用列的前缀代替整个列作为索引 key,当前缀长度合适时,可以做到既使得前缀索引的选择性接近全列索引,同时因为索引 key 变短而减少了索引文件的大小和维护开销。

对于 BLOB、TEXT 或者很长的 VARCHAR 的列,必须使用前缀索引。MySQL 不允许索引这些列的完整长度。

前缀索引兼顾索引大小和查询速度,缺点是不能用于 ORDER BY 和 GROUP BY 操作,也不能用于索引覆盖扫描 (即当索引本身包含查询所需全部数据时,不再访问数据文件本身)。

EXPLAIN SELECT * 
FROM employees.employees WHERE first_name='Eric' AND last_name='Anido'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: employees
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 300252
        Extra: Using where

SELECT count(DISTINCT(first_name))/count(*) AS Selectivity FROM employees.employees;
+-------------+
| Selectivity |
+-------------+
|      0.0042 |
+-------------+
1 row in set (0.13 sec)

SELECT count(DISTINCT(last_name))/count(*) AS Selectivity FROM employees.employees;
+-------------+
| Selectivity |
+-------------+
|      0.0055 |
+-------------+

SELECT count(DISTINCT(concat(first_name, last_name)))/count(*) AS Selectivity 
FROM employees.employees;
+-------------+
| Selectivity |
+-------------+
|      0.9313 |
+-------------+
1 row in set (0.36 sec)

SELECT count(DISTINCT(concat(first_name, left(last_name, 4))))/count(*) 
  AS Selectivity 
FROM employees.employees;
+-------------+
| Selectivity |
+-------------+
|      0.9007 |
+-------------+
1 row in set (0.36 sec)

ALTER TABLE employees.employees 
  ADD INDEX `first_name_last_name4` (first_name, last_name(4));

EXPLAIN SELECT * 
FROM employees.employees WHERE first_name='Eric' AND last_name='Anido'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: employees
         type: ref
possible_keys: first_name_last_name4
          key: first_name_last_name4
      key_len: 22
          ref: const,const
         rows: 1
        Extra: Using index condition; Using where
1 row in set (0.00 sec)

SHOW PROFILES\G
*************************** 1. row ***************************
Query_ID: 1
Duration: 0.13175585
   Query: SELECT * 
          FROM employees.employees WHERE first_name='Eric' AND last_name='Anido'
*************************** 15. row ***************************
Query_ID: 2
Duration: 0.00034243
   Query: SELECT * 
          FROM employees.employees WHERE first_name='Eric' AND last_name='Anido'

四. 覆盖索引

如果一个索引包含 (或者覆盖) 所有要查询的字段的值,就称其为 "覆盖索引"。当发起一个被索引覆盖的查询时,在 EXPLAIN 的 Extra 列可以看到 "Using index" 的信息。

覆盖索引具有如下优点:

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

在大多数存储引擎中,覆盖索引只能覆盖那些只访问索引中部分列的查询。不过可以更进一步优化 InnoDB。InnoDB 的二级索引的叶子节点都包含了主键的值,这意味着 InnoDB 的二级索可以有效的利用这些 "额外" 的主键列来覆盖查询。

五. 多列索引

在多个列上建立独立的单列索引在大部分情况下并不能提高 MySQL 的查询性能。MySQL5.0 和更新版本引入了 "索引合并" 策略,一定程度上可以使用表上的多个单列索引来定位指定的行。查询能够同时使用多个单列索引进行扫描,并将结果进行合并。这种算法有三个变种:OR 条件的联合、AND 条件的相交、组合前两种情况的联合及相交。

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

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

五. 索引顺序

索引列的正确顺序依赖于使用该索引的查询,并且同时需要考虑如何更好地满足排序和分组的需要。在一个包含多列的索引中,索引列的顺序意味着所有首先按照最左列进行排序,其次是第二列,等等。索引可以按照升序或降序进行扫描,以满足精确符合列顺序的 ORDER BY、GROUP BY 和 DISTINCT 等子句的查询要求。

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

六. 索引排序

MySQL 有两种方式可以生成有序的结果:通过排序操作、通过索引顺序扫描。如果 EXPLAIN 的 type 列的值为 "index",则说明 MySQL 使用了索引扫描来做排序。

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

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

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

CREATE TABLE rental(
    PRIMARY KEY (rental_id),
    UNIQUE KEY rental_data (rental_data, inventory_id, customer_id)
);

SELECT rental_id, staff_id 
FROM rental
WHERE rental_data = '2005-05-25'
ORDER BY inventory_id, customer_id

六. 哈希索引

如果存储引擎不支持哈希索引,可以在 B-Tree 基础上创建一个伪哈希索引。这和真正的哈希索引不是一回事,因为还是使用 B-Tree 进行查找,但它使用哈希值而不是键值进行索引查找。需要做的就是在查询的 WHERE 子句中手动指定哈希函数。这样实现的缺陷是需要维护哈希值 (可以手动维护,也可以用触发器实现)。

-- 创建如下表
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)
);

-- 创建触发器 (先临时修改一下语句分隔符,这样可以在触发器定义中使用分号)
DELIMITRE //

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 ;

如果采用自定义哈希索引的方式,不要使用 SHA1() 和 MD5() 作为哈希函数。因为这两个函数计算出来的哈希值是非常长的字符串,会浪费大量空间。如果数据表非常大,CRC32() 会出现大量的哈希冲突,则可以考虑自己实现一个简单的 64 位哈希函数。这个自定义的函数要返回整数,而不是字符串。一个简单的办法可以使用 MD5() 函数返回值的一部分来作为自定义函数。

当使用哈希索引进行查询时,要避免哈希冲突的问题,必须在 WHERE 条件中带入哈希值和对应列值。如果不是想查询具体值,例如只是统计记录数则可以不带入列值,直接使用 CRC32() 的哈希值查询即可。

五. 主键选择

使用 InnoDB 存储引擎时,如果没有特别的需要,请永远使用一个与业务无关的自增字段作为主键。从数据库索引优化角度看,使用 InnoDB 引擎而不使用自增主键绝对是一个糟糕的主意。

InnoDB 使用聚簇索引,数据记录被存放于主键索引的叶子节点上。这要求同一个叶子节点内的各条数据记录按主键顺序存放。每当有一条新的记录插入时,MySQL 会根据其主键将其插入适当的节点和位置。如果页面达到装载因子 (默认为15/16),则开辟一个新的页 (节点)。

如果表使用自增主键,那么每次插入新的记录,记录就会顺序添加到当前索引节点的后续位置,当一页写满,就会自动开辟一个新的页。这样就会形成一个紧凑的索引结构,近似顺序填满。每次插入时也不需要移动已有数据,因此效率很高,也不会增加很多开销在维护索引上。

如果使用非自增主键,每次插入主键的值近似于随机,因此新增的纪录都要被插到现有索引页得中间某个位置。此时 MySQL 不得不为了将新记录插到合适位置而移动数据,甚至目标页可能已经被回写到磁盘上而从缓存中清掉。此时又要从磁盘上读回来,这增加了很多开销。同时频繁的移动、分页操作造成了大量的碎片,使得索引结构不够紧凑,不得不通过 OPTIMIZE TABLE 来重建表并优化填充页面。

上一篇下一篇

猜你喜欢

热点阅读