SQL 优化思路 + 经典案例分析
SQL 调优这块呢,大厂面试必问的。最近金九银十嘛,所以整理了 SQL 的调优思路,并且附几个经典案例分析。
 
 1.慢 SQL 优化思路。
- 慢查询日志记录慢 SQL 
- explain 分析 SQL 的执行计划 
- profile 分析执行耗时 
- Optimizer Trace 分析详情 
- 确定问题并采用相应的措施 
1.1 慢查询日志记录慢 SQL
如何定位慢 SQL 呢、我们可以通过慢查询日志来查看慢 SQL。默认的情况下呢,MySQL 数据库是不开启慢查询日志(slow query log)呢。所以我们需要手动把它打开。
查看下慢查询日志配置,我们可以使用 show variables like 'slow_query_log%'命令,如下:
 
 - slow query log 表示慢查询开启的状态 
- slow_query_log_file 表示慢查询日志存放的位置 
我们还可以使用 show variables like 'long_query_time'命令,查看超过多少时间,才记录到慢查询日志,如下:
 
 - long_query_time 表示查询超过多少秒才记录到慢查询日志。 
我们可以通过慢查日志,定位那些执行效率较低的 SQL 语句,重点关注分析。
1.2 explain 查看分析 SQL 的执行计划
当定位出查询效率低的 SQL 后,可以使用 explain 查看 SQL 的执行计划。
当 explain 与 SQL 一起使用时,MySQL 将显示来自优化器的有关语句执行计划的信息。即 MySQL 解释了它将如何处理该语句,包括有关如何连接表以及以何种顺序连接表等信息。
一条简单 SQL,使用了 explain 的效果如下:
 
 一般来说,我们需要重点关注 type、rows、filtered、extra、key。
1.2.1 type
type 表示连接类型,查看索引执行情况的一个重要指标。以下性能从好到坏依次:system > const > eq_ref > ref > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL
- system:这种类型要求数据库表中只有一条数据,是 const 类型的一个特例,一般情况下是不会出现的。 
- const:通过一次索引就能找到数据,一般用于主键或唯一索引作为条件,这类扫描效率极高,,速度非常快。 
- eq_ref:常用于主键或唯一索引扫描,一般指使用主键的关联查询 
- ref : 常用于非主键和唯一索引扫描。 
- ref_or_null:这种连接类型类似于 ref,区别在于 MySQL 会额外搜索包含 NULL 值的行 
- index_merge:使用了索引合并优化方法,查询使用了两个以上的索引。 
- unique_subquery:类似于 eq_ref,条件用了 in 子查询 
- index_subquery:区别于 unique_subquery,用于非唯一索引,可以返回重复值。 
- range:常用于范围查询,比如:between ... and 或 In 等操作 
- index:全索引扫描 
- ALL:全表扫描 
1.2.2 rows
该列表示 MySQL 估算要找到我们所需的记录,需要读取的行数。对于 InnoDB 表,此数字是估计值,并非一定是个准确值。
1.2.3 filtered
该列是一个百分比的值,表里符合条件的记录数的百分比。简单点说,这个字段表示存储引擎返回的数据在经过过滤后,剩下满足条件的记录数量的比例。
1.2.4 extra
该字段包含有关 MySQL 如何解析查询的其他信息,它一般会出现这几个值:
- Using filesort:表示按文件排序,一般是在指定的排序和索引排序不一致的情况才会出现。一般见于 order by 语句 
- Using index :表示是否用了覆盖索引。 
- Using temporary: 表示是否使用了临时表,性能特别差,需要重点优化。一般多见于 group by 语句,或者 union 语句。 
- Using where : 表示使用了 where 条件过滤. 
- Using index condition:MySQL5.6 之后新增的索引下推。在存储引擎层进行数据过滤,而不是在服务层过滤,利用索引现有的数据减少回表的数据。 
1.2.5 key
该列表示实际用到的索引。一般配合 possible_keys 列一起看。
1.3 profile 分析执行耗时
explain 只是看到 SQL 的预估执行计划,如果要了解 SQL 真正的执行线程状态及消耗的时间,需要使用 profiling。开启 profiling 参数后,后续执行的 SQL 语句都会记录其资源开销,包括 IO,上下文切换,CPU,内存等等,我们可以根据这些开销进一步分析当前慢 SQL 的瓶颈再进一步进行优化。
profiling 默认是关闭,我们可以使用 show variables like '%profil%'查看是否开启,如下:
 
 可以使用 set profiling=ON 开启。开启后,可以运行几条 SQL,然后使用 show profiles 查看一下。
 
 show profiles 会显示最近发给服务器的多条语句,条数由变量 profiling_history_size 定义,默认是 15。如果我们需要看单独某条 SQL 的分析,可以 show profile 查看最近一条 SQL 的分析,也可以使用 show profile for query id(其中 id 就是 show profiles 中的 QUERY_ID)查看具体一条的 SQL 语句分析。
 
 除了查看 profile ,还可以查看 cpu 和 io,如上图。
1.4 Optimizer Trace 分析详情
profile 只能查看到 SQL 的执行耗时,但是无法看到 SQL 真正执行的过程信息,即不知道 MySQL 优化器是如何选择执行计划。这时候,我们可以使用 Optimizer Trace,它可以跟踪执行语句的解析优化执行的全过程。
我们可以使用 set optimizer_trace="enabled=on"打开开关,接着执行要跟踪的 SQL,最后执行 select * from information_schema.optimizer_trace 跟踪,如下:
 
 大家可以查看分析其执行树,会包括三个阶段:
- join_preparation:准备阶段 
- join_optimization:分析阶段 
- join_execution:执行阶段 
 
 1.5 确定问题并采用相应的措施
最后确认问题,就采取对应的措施。
- 多数慢 SQL 都跟索引有关,比如不加索引,索引不生效、不合理等,这时候,我们可以优化索引。 
- 我们还可以优化 SQL 语句,比如一些 in 元素过多问题(分批),深分页问题(基于上一次数据过滤等),进行时间分段查询 
- SQl 没办法很好优化,可以改用 ES 的方式,或者数仓。 
- 如果单表数据量过大导致慢查询,则可以考虑分库分表 
- 如果数据库在刷脏页导致慢查询,考虑是否可以优化一些参数,跟 DBA 讨论优化方案 
- 如果存量数据量太大,考虑是否可以让部分数据归档 
我之前写了一篇文章,有关于导致慢查询的 12 个原因,大家看一看一下哈:盘点 MySQL 慢查询的 12 个原因
2. 慢查询经典案例分析
2.1 案例 1:隐式转换
我们创建一个用户 user 表
userId 字段为字串类型,是 B+树的普通索引,如果查询条件传了一个数字过去,会导致索引失效。如下:
 
 如果给数字加上'',也就是说,传的是一个字符串呢,当然是走索引,如下图:
 
 为什么第一条语句未加单引号就不走索引了呢?这是因为不加单引号时,是字符串跟数字的比较,它们类型不匹配,MySQL 会做隐式的类型转换,把它们转换为浮点数再做比较。隐式的类型转换,索引会失效。
2.2 案例 2:最左匹配
MySQl 建立联合索引时,会遵循最左前缀匹配的原则,即最左优先。如果你建立一个(a,b,c)的联合索引,相当于建立了(a)、(a,b)、(a,b,c)三个索引。
假设有以下表结构:
假设有一个联合索引 idx_userid_name,我们现在执行以下 SQL,如果查询列是 name,索引是无效的:
 
 因为查询条件列 name 不是联合索引 idx_userid_name 中的第一个列,不满足最左匹配原则,所以索引不生效。在联合索引中,只有查询条件满足最左匹配原则时,索引才正常生效。如下,查询条件列是 user_id
 
 2.3 案例 3:深分页问题
limit 深分页问题,会导致慢查询,应该大家都司空见惯了吧。
limit 深分页为什么会变慢呢? 假设有表结构如下:
以下这个 SQL,你知道执行过程是怎样的呢?
这个 SQL 的执行流程酱紫:
- 通过普通二级索引树 idx_create_time,过滤 create_time 条件,找到满足条件的主键 id。 
- 通过主键 id,回到 id 主键索引树,找到满足记录的行,然后取出需要展示的列(回表过程) 
- 扫描满足条件的 100010 行,然后扔掉前 100000 行,返回。 
 
 因此,limit 深分页,导致 SQL 变慢原因有两个:
- limit 语句会先扫描 offset+n 行,然后再丢弃掉前 offset 行,返回后 n 行数据。也就是说 limit 100000,10,就会扫描 100010 行,而 limit 0,10,只扫描 10 行。 
- limit 100000,10 扫描更多的行数,也意味着回表更多的次数。 
如何优化深分页问题?
我们可以通过减少回表次数来优化。一般有标签记录法和延迟关联法。
标签记录法
就是标记一下上次查询到哪一条了,下次再来查的时候,从该条开始往下扫描。就好像看书一样,上次看到哪里了,你就折叠一下或者夹个书签,下次来看的时候,直接就翻到啦。
假设上一次记录到 100000,则 SQL 可以修改为:
这样的话,后面无论翻多少页,性能都会不错的,因为命中了 id 索引。但是这种方式有局限性:需要一种类似连续自增的字段。
延迟关联法
延迟关联法,就是把条件转移到主键索引树,然后减少回表。如下
优化思路就是,先通过 idx_create_time 二级索引树查询到满足条件的主键 ID,再与原表通过主键 ID 内连接,这样后面直接走了主键索引了,同时也减少了回表。
2.4 案例 4:in 元素过多
如果使用了 in,即使后面的条件加了索引,还是要注意 in 后面的元素不要过多哈。in 元素一般建议不要超过 200 个,如果超过了,建议分组,每次 200 一组进行哈。
反例:
如果我们对 in 的条件不做任何限制的话,该查询语句一次性可能会查询出非常多的数据,很容易导致接口超时。尤其有时候,我们是用的子查询,in 后面的子查询,你都不知道数量有多少那种,更容易采坑.如下这种子查询:
如果 type = 1 有 1 一千,甚至上万个呢?肯定是慢 SQL。索引一般建议分批进行,一次 200 个,比如:
in 查询为什么慢呢?
这是因为 in 查询在 MySQL 底层是通过 n*m 的方式去搜索,类似 union。
in 查询在进行 cost 代价计算时(代价 = 元组数 * IO 平均值),是通过将 in 包含的数值,一条条去查询获取元组数的,因此这个计算过程会比较的慢,所以 MySQL 设置了个临界值(eq_range_index_dive_limit),5.6 之后超过这个临界值后该列的 cost 就不参与计算了。因此会导致执行计划选择不准确。默认是 200,即 in 条件超过了 200 个数据,会导致 in 的代价计算存在问题,可能会导致 Mysql 选择的索引不准确。
2.5 order by 走文件排序导致的慢查询
如果 order by 使用到文件排序,则会可能会产生慢查询。我们来看下下面这个 SQL:
它表示的意思就是:查询前 10 个,来自深圳员工的姓名、年龄、城市,并且按照年龄小到大排序。
 
 查看 explain 执行计划的时候,可以看到 Extra 这一列,有一个 Using filesort,它表示用到文件排序。
order by 文件排序效率为什么较低
大家可以看下这个下面这个图:
 
 order by 排序,分为全字段排序和 rowid 排序。它是拿 max_length_for_sort_data 和结果行数据长度对比,如果结果行数据长度超过 max_length_for_sort_data 这个值,就会走 rowid 排序,相反,则走全字段排序。
2.5.1 rowid 排序
rowid 排序,一般需要回表去找满足条件的数据,所以效率会慢一点。以下这个 SQL,使用 rowid 排序,执行过程是这样:
- MySQL 为对应的线程初始化 sort_buffer,放入需要排序的 age 字段,以及主键 id; 
- 从索引树 idx_city, 找到第一个满足 city='深圳’条件的主键 id,假设 id 为 X; 
- 到主键 id 索引树拿到 id=X 的这一行数据, 取 age 和主键 id 的值,存到 sort_buffer; 
- 从索引树 idx_city 拿到下一个记录的主键 id,假设 id=Y; 
- 重复步骤 3、4 直到 city 的值不等于深圳为止; 
- 前面 5 步已经查找到了所有 city 为深圳的数据,在 sort_buffer 中,将所有数据根据 age 进行排序;遍历排序结果,取前 10 行,并按照 id 的值回到原表中,取出 city、name 和 age 三个字段返回给客户端。 
 
 2.5.2 全字段排序
同样的 SQL,如果是走全字段排序是这样的:
- MySQL 为对应的线程初始化 sort_buffer,放入需要查询的 name、age、city 字段; 
- 从索引树 idx_city, 找到第一个满足 city='深圳’条件的主键 id,假设找到 id=X; 
- 到主键 id 索引树拿到 id=X 的这一行数据, 取 name、age、city 三个字段的值,存到 sort_buffer; 
- 从索引树 idx_city 拿到下一个记录的主键 id,假设 id=Y; 
- 重复步骤 3、4 直到 city 的值不等于深圳为止; 
- 前面 5 步已经查找到了所有 city 为深圳的数据,在 sort_buffer 中,将所有数据根据 age 进行排序; 
- 按照排序结果取前 10 行返回给客户端。 
 
 sort_buffer 的大小是由一个参数控制的:sort_buffer_size。
- 如果要排序的数据小于 sort_buffer_size,排序在 sort_buffer 内存中完成 
- 如果要排序的数据大于 sort_buffer_size,则借助磁盘文件来进行排序。 
借助磁盘文件排序的话,效率就更慢一点。因为先把数据放入 sort_buffer,当快要满时。会排一下序,然后把 sort_buffer 中的数据,放到临时磁盘文件,等到所有满足条件数据都查完排完,再用归并算法把磁盘的临时排好序的小文件,合并成一个有序的大文件。
2.5.3 如何优化 order by 的文件排序
order by 使用文件排序,效率会低一点。我们怎么优化呢?
- 因为数据是无序的,所以就需要排序。如果数据本身是有序的,那就不会再用到文件排序啦。而索引数据本身是有序的,我们通过建立索引来优化 order by 语句。 
- 我们还可以通过调整 max_length_for_sort_data、sort_buffer_size 等参数优化; 
2.6 索引字段上使用 is null, is not null,索引可能失效
表结构:
单个 name 字段加上索引,并查询 name 为非空的语句,其实会走索引的,如下:
 
 单个 card 字段加上索引,并查询 name 为非空的语句,其实会走索引的,如下:
 
 但是它两用 or 连接起来,索引就失效了,如下:
 
 很多时候,也是因为数据量问题,导致了 MySQL 优化器放弃走索引。同时,平时我们用 explain 分析 SQL 的时候,如果 type=range,要注意一下哈,因为这个可能因为数据量问题,导致索引无效。
2.7 索引字段上使用(!= 或者 < >),索引可能失效
假设有表结构:
虽然 age 加了索引,但是使用了!= 或者< >,not in 这些时,索引如同虚设。如下:
 
 其实这个也是跟 mySQL 优化器有关,如果优化器觉得即使走了索引,还是需要扫描很多很多行的哈,它觉得不划算,不如直接不走索引。平时我们用!= 或者< >,not in 的时候,留点心眼哈。
2.8 左右连接,关联的字段编码格式不一样
新建两个表,一个 user,一个 user_job
user 表的 name 字段编码是 utf8mb4,而 user_job 表的 name 字段编码为 utf8。
 
 执行左外连接查询,user_job 表还是走全表扫描,如下:
 
 如果把它们的 name 字段改为编码一致,相同的 SQL,还是会走索引。
 
 2.9 group by 使用临时表
group by 一般用于分组统计,它表达的逻辑就是根据一定的规则,进行分组。日常开发中,我们使用得比较频繁。如果不注意,很容易产生慢 SQL。
2.9.1 group by 执行流程
假设有表结构:
我们查看一下这个 SQL 的执行计划:
 
 - Extra 这个字段的 Using temporary 表示在执行分组的时候使用了临时表 
- Extra 这个字段的 Using filesort 表示使用了文件排序 
group by 是怎么使用到临时表和排序了呢?我们来看下这个 SQL 的执行流程
- 创建内存临时表,表里有两个字段 city 和 num; 
- 全表扫描 staff 的记录,依次取出 city = 'X'的记录。 
- 判断临时表中是否有为 city='X'的行,没有就插入一个记录 (X,1); 
- 如果临时表中有 city='X'的行,就将 X 这一行的 num 值加 1; 
- 遍历完成后,再根据字段 city 做排序,得到结果集返回给客户端。这个流程的执行图如下: 
 
 临时表的排序是怎样的呢?
就是把需要排序的字段,放到 sort buffer,排完就返回。在这里注意一点哈,排序分全字段排序和 rowid 排序
- 如果是全字段排序,需要查询返回的字段,都放入 sort buffer,根据排序字段排完,直接返回 
- 如果是 rowid 排序,只是需要排序的字段放入 sort buffer,然后多一次回表操作,再返回。 
2.9.2 group by 可能会慢在哪里?
group by 使用不当,很容易就会产生慢 SQL 问题。因为它既用到临时表,又默认用到排序。有时候还可能用到磁盘临时表。
- 如果执行过程中,会发现内存临时表大小到达了上限(控制这个上限的参数就是 tmp_table_size),会把内存临时表转成磁盘临时表。 
- 如果数据量很大,很可能这个查询需要的磁盘临时表,就会占用大量的磁盘空间。 
2.9.3 如何优化 group by 呢
从哪些方向去优化呢?
- 方向 1:既然它默认会排序,我们不给它排是不是就行啦。 
- 方向 2:既然临时表是影响 group by 性能的 X 因素,我们是不是可以不用临时表? 
我们一起来想下,执行 group by 语句为什么需要临时表呢?group by 的语义逻辑,就是统计不同的值出现的个数。如果这个这些值一开始就是有序的,我们是不是直接往下扫描统计就好了,就不用临时表来记录并统计结果啦?
可以有这些优化方案:
- group by 后面的字段加索引 
- order by null 不用排序 
- 尽量只使用内存临时表 
- 使用 SQL_BIG_RESULT 
2.10 delete + in 子查询不走索引!
之前见到过一个生产慢 SQL 问题,当 delete 遇到 in 子查询时,即使有索引,也是不走索引的。而对应的 select + in 子查询,却可以走索引。
MySQL 版本是 5.7,假设当前有两张表 account 和 old_account,表结构如下:
执行的 SQL 如下:
查看执行计划,发现不走索引:
 
 但是如果把 delete 换成 select,就会走索引。如下:
 
 为什么 select + in 子查询会走索引,delete + in 子查询却不会走索引呢?
我们执行以下 SQL 看看:
结果如下:
可以发现,实际执行的时候,MySQL 对 select in 子查询做了优化,把子查询改成 join 的方式,所以可以走索引。但是很遗憾,对于 delete in 子查询,MySQL 却没有对它做这个优化。
日常开发中,大家注意一下这个场景哈











 
    
评论