【MySQL 进阶】高性能优化
1. 测试方法
观察单次查询性能:

使用 MySQL 提供的客户端程序 mysqlslap 进行压力测试,配置如下:
[mysqlslap]
concurrency=30 # 客户端数(并发数)
iterations=3 # 每个客户端查询的次数
create-schema="test2_db" # 操作的数据库
engine="innodb" # 存储引擎
number-of-queries=90 # 最大查询次数

2. 执行计划
使用执行计划查看 SQL 语句的执行情况,这是判断如何优化 SQL 和索引的最重要工具。

EXPLAIN 返回的结果中,最重要的字段是 type,其次是 key、rows、extra。
key 表示实际使用的索引,rows 表示预计需要扫描的行数。type 和 extra 会在下面重点介绍:
2.1 对 type 的解释
这个字段描述了 MySQL 是如何找到目标数据的,该字段有下面几种取值。
| const | 使用主键或唯一索引进行等值查找。这是性能最高的查询,因为此时只有可能返回一条数据。 |
| ref | 使用普通索引进行等值查找。这个过程包括: 1. 在该索引树中定位到第一个等于目标 value 的索引记录。 2. 沿着叶子节点的双向链表向后扫描,直到遇到不等于 value 的记录为止,从而获取所有匹配的索引记录。 3. 索引中存储的是索引列的值和主键值,因此对于每个匹配的索引记录,我们都需要通过主键值回表到聚簇索引中获取完整的行数据。此时的回表是随机的 I/O,因为主键索引是聚簇索引,数据按主键顺序存储,而二级索引中的主键值可能是无序的。 可以看出:最大的开销在回表。此时就有两种优化策略: 1. 如果索引的区分度很高(即重复值很少),那么匹配的行数就会很少,回表的次数也就很少。当然,这是数据本身决定的,严格来说并不属于可以优化的点。 2. 使用覆盖索引,即索引中包含查询所需要的所有列,这样就不需要回表了。 |
ref_or_null | 类似 ref,但是 MySQL 必须额外搜索包含 NULL 值的行,这些行也都要回表,这通常发生在条件中使用了 IS NULL。所以说尽量将字段设为 NOT NULL,就不用考虑这种情况了。 |
index_merge | 这通常发生在 WHERE 中包含多个针对不同列的条件,用 OR / AND 连接,并且这些条件分别适合不同的索引,但没有一个复合索引能覆盖所有这些条件,此时 MySQL 可能会使用索引合并对该查询进行优化。 索引合并:允许优化器在一次查询中同时使用多个索引,分别从相应的索引树中筛选出结果集 A 和 结果集 B,再根据每条记录的主键对二者取交集或并集,之后再回表。 优化器也并不一定会选择索引合并,如果某个查询字段的区分度很高,那么优化器可能会先筛选这个字段,然后回表,再过滤其他条件。 |
| range | 使用索引(无论是什么类型的索引)进行范围查找(>、>=、<、<=、is NULL、BETWEEN、LIKE、IN),此时需要遍历索引树中的一部分。 |
| index | 全索引扫描,遍历整个索引树。通常发生在需要查询全表记录,但使用了覆盖索引的时候。 |
| ALL | 从头到尾扫描整个表,通常发生在没有索引的时候。 |
| eq_ref | 联表查询时,两表都使用主键或唯一索引作为连接条件,进行等值查找。这是联表查询时的最高性能,因为只需对左表的每一行,在右表进行一次 const 查询。 |
unique_subquery | SELECT * FROM table WHERE value in (子查询) |
index_subquery | SELECT * FROM table WHERE value in (子查询) 如果子查询是一个 ref 查询,MySQL 会使用 index_subquery。 |
总结:性能的差异主要来自于需要扫描的数据量。从 const 到 ALL,需要扫描的数据量依次增加。const、eq_ref、ref 利用索引直接定位到一行或多行,range 扫描一个范围,index 扫描整个索引(比 ALL好很多,因为索引的大小比表数据小),ALL 扫描整个表。
2.2 对 Extra 的解释
Extra 是对 type 的补充信息,将这两个字段结合来看就可以更加清晰地了解优化器是如何执行 SQL 的。
| Using index | 表示查询使用了索引覆盖,即查询的列都包含在索引中,不需要回表。这是一种很高效的方式,因为回表是随机 IO,非常影响性能,而索引覆盖可以有效避免。B+ 数叶子是以双向链表相连的,如果范围查询有索引覆盖就变成连续 IO,如果没有那就只能一次次回表随机 IO。 |
| Using where | 有未使用索引的 WHERE 条件。它对性能的影响主要取决于过滤条件的选择性,比如对于下面的条件,age 有索引,name 无索引: WHERE age > 25 AND name LIKE 'J%' 如果 age > 25 能有效减少行数,则性能影响不大。 |
| Using temporary | 查询需要创建临时表(内存或磁盘)来进行中间运算,常见于 GROUP BY、DISTINCT、UNION。比如对于 GROUP BY,如果分组条件没有索引,那么肯定要先创建一个临时表,进行分组后再返回,此时性能较差。如果有索引,那么利用索引的有序性直接流式返回结果集即可。 |
| Using filesort | 查询需要对无索引的字段进行排序。此时 MySQL 需要在内存中排序,如果要排序的记录庞大,还可能使用磁盘临时文件,这个时候性能就非常差了。 |
Using index condition | 优化器使用了索引下推对查询进行优化。 索引下推:在复合索引中,利用索引树中包含的字段,对 WHERE 条件中不满足最左前缀的部分在存储引擎层提前过滤。 例如,对于复合索引 (key1, key2),有如下查询语句: SELECT * FROM t WHERE key1 = ‘foo’ AND key2 LIKE ‘%bar’; 存储引擎依然使用 key1 = ‘foo’ 来过滤数据,但当定位到索引页时,它会顺便检查 key2 是否符合 key2 LIKE ‘%bar’,只有同时满足才会回表。 |
3. 索引失效
1. 不满足复合索引的最左前缀原则。如果复合索引如 (key1, key2, key3),那么在 WHERE 条件中至少要使用到 key1。因为 B+ 树是先排 key1,key1 相等的再排 key2。
2. 对条件中使用的索引列进行函数运算、计算或类型转换。
3. LIKE 查询以 % 开头。因为 B+ 树排序字符串时,是先排第一个字母,第一个字母相同的再排第二个,以此类推,因此只能前缀匹配。
4. 否定查询。
4. 索引优化
索引可以提高查询效率,但也会降低插入和更新的效率。
如果 type 是 ALL,考虑为查询条件添加索引。如果 type 是 index,考虑是否可以通过添加条件变成 range,或者检查查询是否真的需要全索引扫描。如果 type 是 ref 或 range,已经不错,可以进一步考虑使用覆盖索引减少回表。理想情况下 type 最好至少达到 range。
每张表必须有自增主键,InnoDB 是按照主键索引的顺序来组织表的。
避免在区分度低的列上建立索引。
建立索引的字段应当尽量不为 NULL,对于可以为 NULL 的字段,数据库难以优化。
对于频繁的查询,考虑使用覆盖索引,避免回表。回表是随机 IO,对性能影响最大。
尽可能考虑建立复合索引来解决问题,而不是单列索引。
使用复合索引遵循最左原则。
建立复合索引时,将区分度高的列放在左边,可以快速排除大量数据。通过 count(distinct column) / count(*) 来计算数据区分度。
多表查询时,可以在两表的关联键上建立索引,但尽量避免使用外键,数据的参照完整性在业务代码中进行控制。也可以尝试将多表查询拆分为多个单表查询,然后在业务代码中合并。
阿里开发手册中,强制要求在 varchar 字段上建立索引时,必须指定索引长度,根据实际文本区分度来决定。索引的长度与区分度是一对矛盾体,太长的索引会占用更多空间。
5. 深度查询优化
首先,一个 SQL 的逻辑执行顺序是:FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT。我们发现 ORDER BY 和 LIMIT 是在最后执行的,这可以理解为它们只是在改变返回视图,而与数据的查询无关。
对于下面的 SQL:
SELECT*
FROM`posts`
WHERE`category_id` = 5
ORDER BY`created_at` DESCLIMIT 10000,20;在什么索引都没有的情况下(表中只有 id 主键),SQL 执行方案如下:
1. 根据 WHERE 条件全表扫描,筛选出 category_id 为 5 的行,对这些行进行标记。这一步全表扫描是巨大开销,是绝对不允许的。
2. 对于筛选出的行,在临时表中进行排序。由于使用的是聚簇索引,因此每行数据都是完整的数据行,这个临时表会非常大,可能排序缓冲区不够用,需要硬盘临时文件。
3. 从排序后的第一条记录向后遍历,遍历到指定的 LIMIT 位置,返回 20 行。因为这里已经是完整数据行了所以无需回表。
先进行索引优化:
1. category_id 必须要有索引,否则 WHERE 要全表扫描。
2. 如果想避免临时表排序,必须创建(category_id,created_at)的复合索引,这样的话对于相同的 category_id 来说,created_at 是已经被索引排好序的了。
3. 注意,现在我们使用的索引树是(category_id,created_at),但我们要查询的是完整记录,无法覆盖索引,必须回表。也就是,每遍历一条索引记录就立即回表获取完整记录,这需要回表 10020 次,尽管我们只需要最后的 20 条。
继续进行 SQL 优化:
在上面的优化基础上,如何减少回表次数呢?首先想到的就是索引覆盖。如果我们要查的不是完整记录,而是先只查 id,再通过查出来的 20 个 id 去查完整数据,就可以建立(category_id,created_at,id)的复合索引,查 id 的时候就不需要回表了。当然还是会产生 10020 行的临时表,但是这个临时表只有索引字段,比存完整字段的表小很多。
查出 20 个 id 的 SQL 可以作为子查询,也可以在应用层面保存后进行二次查询。
这种优化方式称为延迟关联:
SELECT p.*
FROM `posts` p
INNER JOIN (SELECT `id` FROM `posts` WHERE `category_id` = 5 ORDER BY `created_at` DESC LIMIT 10000, 20
) AS tmp ON p.id = tmp.id除此之外,其实还有更靠谱的优化方式,就是游标分页,当然这也是基于索引优化的:
游标分页的思想就是避免使用 offset 跳过大量数据,而是记录表中最后一条发给前端的数据的唯一索引字段,这样就能直接拿这个记录好的游标去索引中精确定位该次查询的起点了。
对于游标分页值得注意的地方:
1. 只能顺序浏览,也就是上一页或下一页,或者无限滚动,但不能直接定位到某页。
2. 游标必须是唯一索引,不管是单一索引还是复合索引,都必须是唯一索引。
3. 游标的记录需要前端提供支持,并且最好将游标维护在 URL 中,这样刷新页面后游标才不会丢失。如果游标存储在 localStorage 中,那么刷新页面后,JavaScript 可以从 localStorage 读取游标,通过 AJAX 加载第二页,但地址栏还是原来的 URL,导致参数丢失。
5. 其他优化
1. 尽量避免 select * :
这会消耗更多 CPU 来解析,并容易带有无用字段(即使确实需要全部字段也不要这样写)。如果表结构变更,这样的写法更受影响,并且这样写就肯定用不了覆盖索引了。
2. 尽量避免 JOIN:
在 JOIN 操作中,MySQL 会选择其中一个表作为驱动表,另一个作为被驱动表。例如下面的 SQL,user 表有 1000 行数据,orders 表有 100000 行数据,那么 MySQL 会自动选择 user 表为驱动表,因为较小。对于 user 的每一行,去 orders 表中做等值查找,此时 orders 表的 user_id 字段如果没有索引,那开销就太大了。
所以大表的连接字段必须有索引。小表一般也不要直接和大表连接,起码要用 WHERE 将小表筛选得更小,再考虑和大表连接。
SELECT *
FROM users u
JOIN orders o ON u.id = o.user_id;这是在明确知道一张表肯定小于另一张表的时候,如果是两张大表,那么连接字段必须全部有索引,因为 MySQL 不一定会选择哪个作为驱动表。实际上,两张大表的连接本就是不推荐的,实在需要连接的话,也要先将它们过滤成小表,再连接。
更靠谱的做法是采用下面两种方案:
1. 单表查询后在业务代码中做关联。这是使用率很高的一种方案,并且单表查询的代码还方便复用。
2. 在设计表的时候对常用字段进行适当冗余,从而避免关联其他表。
不过,其实在一些并发程度不高,不太要求性能的系统中 JOIN 还是比较常用的,主要是因为上面的两种方式更加要求开发者的能力。
3. 不要使用外键:
外键无法跨数据库实例,也就不支持分库分表,因此长期来看还是在业务层校验参照一致性比较稳妥。
