序
前面已经介绍了表结构优化与索引优化,本文继续分析SQL优化
如果SQL写的很糟糕,即使表结构再合理,索引再适合也无法实现高性能
SQL优化的本质
将一次查询看做是一个任务,本质是优化其中的子任务,要么提高子任务执行速度,要么减少子任务运行次数,要么消除一些子任务
查询优化排查思路
1. 检查是否查询了不需要的数据,导致访问的过多的行或者列
查询不需要的记录
例如查询了大量结果,只获取了前N行数据后关闭结果集,丢弃了大部分数据,实际上服务器、客户端已经返回接收了全部的数据。加上Limit条件即可解决该问题
查询了多余的列
查询重复的数据
2. 检查MySQL服务器是否在分析大量超过需要的数据行
检查查询为了返回结果是否扫描了过多的数据,衡量查询开销的三个指标如下:
这三个指标都会记录到MySQL慢查询日志中,所以检查慢查询日志是找出扫描行数过多的查询的好办法。
通过合适的where条件减少扫描行数,MySQL应用where条件的三种方式,从好到坏依次是:
完全覆盖索引
where条件和索引完全匹配,在索引中使用where条件来过滤不匹配的记录。完全在存储引擎层完成(Explain Extra 显示 Using index)
索引下推
where条件和索引部分匹配,尝试只使用二级索引中的列进行where条件判断,满足才回表查询整行数据,进行其他where条件判断,减少回表次数。部分在存储引擎层完成,部分在服务器层完成(Explain Extra 显示 Using index condition)
从数据表中返回数据,然后过滤不满足条件的行。完全在服务器层完成。(Explain Extra 显示 Using Where)
3. 切分大查询
一个大查询如果一次性执行的话,可能一次锁住很多数据、占满整个事务日志、耗尽系统资源、阻塞很多小的但重要的查询
切分为多个小查询,在应用中做关联,好处如下
各种查询语句优化解析
大多数时候业务系统实现的分页功能SQL如下
select * from table limit 10000, 10
看似只查询了10条记录,实际上这条SQL先读取10010条记录,然后抛弃掉前面10000条记录,当这个数字继续增大时,抛弃掉的数据就越多,效率非常低
按照主键排序时
不带排序条件时,默认按照主键升序排列
select * from table where id > 10000 limit 10
按照非主键排序时
通过延迟关联的方式
SELECT
*
FROM
TABLE t
INNER JOIN (
SELECT id FROM TABLE ORDER BY NAME LIMIT 10000, 10
) tid ON t.id = tid.id
先说结论,对于关联sql的优化:
straight_join解释:straight_join功能同join类似,但能让左边的表来驱动右边的表,能改表优化器对于联表查询的执行顺序。
对于小表定义的明确:在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与 join 的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。
关于优化联合查询的注意点:
以下分析原因
MySQL的表关联常见有两种算法:
先解释下驱动表概念,在联合查询中,MySQL先查询的第一张表叫做驱动表,后关联查询的表较被驱动表
嵌套循环连接 Nested-Loop Join(NLJ) 算法
一次一行循环地从第一张表(驱动表)中读取行,在这行数据中取到关联字段,根据关联字段在另一张表(被驱动表)里取出满足条件的行,然后取出两张表的结果合集
explain select * from userinfo inner join hobby on userinfo.id = hobby.user_id
上述结果表名:
NLJ执行流程:
整个过程会读取hobby表321行,然后遍历每行数据中的user_id字段的值,到userinfo表中的扫描对应的行(由于关联的是userinfo的主键索引id字段,所以每次只会扫描一行完整记录),总共扫描321次,整个扫描过程是 321 + 321 = 642 次
基于块的嵌套循环连接 Block Nested-Loop Join(BNL)算法
如果被驱动表的关联字段没有索引,MySQL 会选择使用 Block Nested-Loop Join 算法
把驱动表的数据读入到 join_buffer 中,然后扫描被驱动表,把被驱动表每一行取出来跟 join_buffer 中的数据做对比。
explain select * from userinfo inner join hobby on userinfo.username = hobby.username
Extra 中的 Using join buffer(Block Nested Loop)说明该关联查询使用的是 BNL 算法
BNL执行流程:
整个过程对表 hobby 和 userinfo 都做了一次全表扫描,因此扫描的总行数为 100(hobby表) + 10000(userinfo表) = 10100。并且 join_buffer 里的数据是无序的,因此对表 userinfo 中的每一行,都要做 100 次判断,所以内存中的判断次数是 100 * 10000 = 100 万次
join_buffer 的大小是由参数 join_buffer_size 设定的,默认值是 256k。如果一次放不下表 hobby 表的所有数据话,就分段放。
比如 hobby 表有1000行记录,join_buffer 一次只能放800行数据,那么执行过程就是先往 join_buffer 里放800行记录,然后从 userinfo 表里取数据跟 join_buffer 中数据对比得到部分结果,然后清空 join_buffer ,再放入 hobby 表剩余200行记录,再次从 userinfo 表里取数据跟 join_buffer 中数据对比。所以就多扫了一次 userinfo 表。
被驱动表的关联字段没索引为什么要选择使用 BNL 算法而不使用 Nested-Loop Join 呢?
如果使用NLJ算法,那么扫描行数为 100 * 10000 = 100万次,这个是磁盘扫描。
很显然,用BNL磁盘扫描次数少很多,相比于磁盘扫描,BNL的内存计算会快得多。
因此MySQL对于被驱动表的关联字段没索引的关联查询,一般都会使用 BNL 算法。如果有索引一般选择 NLJ 算法,有 索引的情况下 NLJ 算法比 BNL算法性能更高。
当不指定ORDER BY条件时,默认按照主键升序排列
MySQL支持两种排序方式 filesort 和 index
当Explain结果的Extra列为Using Index时表示MySQL扫描索引完成排序,Using filesort时表示MySQL使用文件排序
索引排序
总结下不能使用索引排序的CASE,其实和不能完全使用联合索引所有列一个原理:
假设存在联合索引 idx_date_age_height(login_date, age, height)
... WHERE login_date = '2022-04-05' Order by age ASC, height DESC
... WHERE login_date = '2022-04-05' Order by weight ASC, height ASC
... WHERE login_date > '2022-04-05' Order by age ASC, height ASC
... WHERE login_date = '2022-04-05' AND age IN (18,24) Order by height ASC
文件排序(filesort)
filesort的两种排序方式:
区别是单路排序一次把所有数据读取到内存,所以需要更大的内存,但只会执行一次I/O,性能更快。双路排序需要两次I/O,但第一次只需要读取主键和排序列到内存,相对节约内存
filesort 使用的算法是QuickSort,即对需要排序的记录生成元数据进行分块排序,然后再使用mergesort方法合并块
当排序记录太多 sort_buffer_size 不够用时,MySQL会使用临时文件来存放各个分块,然后各个分块排序后再多次合并分块最终全局完成排序
MySQL通过比较系统变量max_length_for_sort_data(默认1024字节)的大小和需要查询的字段总大小来判断使用哪种排序模式
实际应用中,如果服务器资源充足,可以适当增大max_length_for_sort_data,尽可能多使用单路排序,提高性能,反之降低max_length_for_sort_data,使用双路排序,通常情况下可以不做调整,保持默认即可
GROUP BY在利用索引的方面和ORDER BY几乎相同,都需要满足最左侧前缀
下面说下需要注意的点
先将IN列表中的数据排序,然后用二分查找的方式来确定列表中的值是否满足条件,复杂度是O(log n)而非O(n)
原则:小表驱动大表,即小的数据集驱动大的数据集
IN
当B表的数据集小于A表的数据集时,IN优于Exists
select * from A where id in (select id from B)
# 等价于
for (select id from B) {
select * from A where A.id = B.id
}
Exists
当A表的数据集小于B表的数据集时,Exists优于IN
将主查询A的数据,放到子查询B中做条件验证,根据验证结果(true或false)来决定主查询的数据是否保留
select * from A where exists (select 1 from B where B.id = A.id)
# 等价于
for (select * from A) {
select * from B where B.id = A.id
}
count(1),count(*),count(字段),count(id) 区别(假设id为主键)
效率对比
3. 字段有二级索引时
count(*) ≈ count(1) > count(字段) > count(id)
分析:字段有索引时,count(字段)统计走二级索引,二级索引存储数据比主键索引少,更小,所以 count(字段) > count(主键 id)
字段无二级索引时
count(*) ≈ count(1) > count(主键 id) > count(字段)
分析:count(主键 id) 可以走主键索引,所以 count(id) > count(字段)
优化方案
近似值
如果只需要统计近似值,可以使用如下SQL替代
show table status like 'userinfo'
该表实际数量为10000,当然也可能统计为比实际数量小的值
汇总表
使用单独的汇总表某个字段统计数量,每次写操作时维护该数量,统计时直接查该字段(当写多读少不适用该方案)
缓存中间件
可以使用缓存中间件来统计数量,例如Redis
MySQL对于MIN()和MAX()的优化做的并不好
例如
表: hobby 有20万数据,sort 字段有一个单列索引 idx_sort
select max(sort) from hobby where username = 'Rita'
这个查询MySQL会做一次全表扫描,因为username字段没有索引
执行查询,耗时61毫秒
下面对该查询做一次优化
explain select sort from hobby where username = 'Rita' order by sort desc limit 1
改为按照sort倒序排列,取第一条即最大值,此时由于 username = ‘Rita’ 是一个常量,且 idx_sort 索引是按顺序排列的,所以可以直接查询 idx_sort所以获得结果,几乎不耗时
MIN() 同理
尽量避免 UNION,因为UNIO
能使用limit
附录
通过show processlist可以查看当前连接的执行状态,重点关注Command列,其可能的值及含义如下:
线程正在等待客户端发送新的请求
线程正在执行查询或者正在将结果发送给客户端
在MySQL服务器层,该线程正在等待表锁。在存储引擎层实现的锁,例如Innodb的行锁,并不会体现在线程状态中。
线程正在收集存储引擎的统计信息,并生成查询的执行计划。
线程正在执行查询,并且将其结果都复制到一个临时表中,这种状态一般要么是在做Group By操作,要么是文件排序操作,或者是UNION操作。如果这个状态后面还有"on disk"标记,那表示Mysql正在将一个内存临时表放到磁盘是。
线程正在对结果集进行排序
这表示多种情况:线程可能在多个状态之间传送数据,或者在生成结果集,或者在向客户端返回数据。
系列文章
上一篇:【MySQL优化(七)】MySQL Explain详解
下一篇:【MySQL优化(九)】MySQL锁机制