查询就是一个人任务,是由一系列子任务组成,每个子任务都消耗一定的时间,如果优化查询,那么实际上就要优化子任务
客户端 -> 服务器 -> 解析 -> 生成计划 -> 执行(最重要阶段) -> 返回给客户端
一些不必要的额外操作,某些操作被重复了很多次,一些操作执行很慢,优化查询就是减少和消除这些操作话费的时间
了解查询的生命周期, 时间消耗情况
对优化查询有很大意义
很多情况下是因为访问的数据太多,一些查询不能避免需要筛选大量的数据.对于低效的查询,我们可以通过两个步骤分析:
不需要的数据会带来额外的负担,增加网络开销,消耗服务器的 CPU 和内存资源
LIMIT
*
来查询所有的列,在多表关联的时只取需确保返回数据合理之后,就需要考虑是否扫描了额外的记录,衡量查询开销的三个指标
响应时间
响应时间只是表面的值,响应时间是服务时间
和排队时间
扫描的行数和返回的行数
查看查询扫描和返回的行数,可以看出数据的效率高不高
理想状态下扫描和返回的应该是相同的,但是这种情况并不多
一般扫描的函数在 1: 1 - 10: 1之间
扫描的行数和访问类型
在评估查询开销的时候,需要考虑查找一行的成本,有些需要扫描很多行才能返回一行
在EXPLAIN
中的type
列反映了访问类型,访问类型有多种
这些事从慢到快,扫描的行数也是从大到小,你需要明白扫描表,扫描索引,范围访问和单值访问的概念
如果没有找到合适的访问类型,通常的办法就是增加一个合适的索引
下面一个例子来说明
EXPLAIN SELECT * FROM sakila.film_actor WHERE film_id = 1;
这个查询预期返回10行数据
key | value |
---|---|
id | 1 |
select_type | SIMPLE |
table | ref |
possible_keys | idx_fk_film_id |
key | idx_fx_film_id |
key_len | 2 |
ref | const |
rows | 10 |
Extra | |
可以看到使用索引idx_fx_film_id 扫描了10条数据,下面删除索引来看一下 |
ALTER TABLE sakila.film_actor DROP FOREIGN KEY fk_film_actor_film;
ALTER TABLE sakila.film_actor DROP KEY idx_fk_film_id;
EXPLAIN SELECT * FROM sakila.film_actor WHERE film_id=1;
key | value |
---|---|
id | 1 |
select_type | SIMPLE |
table | film_actor |
type | ALL |
possible_keys | NULL |
key | NULL |
key_len | NULL |
ref | NULL |
rows | 5073 |
Extra | Using where |
此时我们发现已经成为了全表扫描5073条数据来完成这个查询,Using where 表示MySQL通过WHERE条件筛选存储引擎返回的记录 |
一般MySQL能够使用三种方式应用WHERE,从好到坏依次为:
索引中
使用WHERE条件来过滤不匹配的记录,这是在存储引擎层完成的Using index
)来返回记录,直接命中索引中过滤不需要的记录并返回命中结果,这是MySQL服务层完成的,无需回表数据表
中返回数据,然后过滤不满足条件的记录(在Extra列中出现Using Where
)这是在MySQL服务层完成的,需要先读数据然后进行过滤索引很重要,但有些时候增加索引让扫描的行数等于返回的行数譬如COUNT()
SELECT actor_id, COUNT(*) FROM sakila.film_acor GROUP BY actor_id;
这需要读取几千行,然是仅返回200行, MySQL不会告诉你实际扫描多少行数据,只会告诉你生成了多少行, 理解一下传需要扫描多少行需要理解,查询背后的逻辑和思想,下面提供一些思路,来优化查询
MySQL在设计上让连接和断开都很轻量级,在返回一个小的查询结果方面很高效
在其他条件都相同的时候,使用尽可能少的查询当然是更好的,但是讲一个大查询分解为多个小查询是很有必要的,可以衡量一下这样是不是会减少工作量
当定期清理大量数据时,如果用一个大的语句一次性完成的话,可能需要一次锁住很多数据,占满事务日志,耗尽系统资源,阻塞很多小的但重要的查询,将一个打的DELETE切为多个小查询尽可能小的影响MySQL
很多高性能的应用都会讲关联进行分解例如
SELECT * FROM tag
JOIN tag_post ON tag_post.tag_id=tag.id
JOIN post ON tag_post.post_id=post.id
WHERE tag.tag='mysql';
可以使用以下查询来代替
SELECT * FROM tag WHERE tag='mysql';
SELECT * FROM tag_post WHERE tag_id=1234;
# 此处需要注意 IN 的值,最好不要超过 1000 个
SELECT * FROM post WHERE post.id in (123, 456, 9098, 7804);
有以下优势:
如果能够弄清楚查询是如何执行的,就可以遵循一些原则优化器按照预想的合理的方式运行
MySQL客户端和服务器之间的通信协议是"半双工",同一时刻,要么是服务器向客户端发送数据,要么是客户端往服务器发送数据,无法也无需将消息切成小块独立来发送
这种协议快速,但是导致没有办法进行流量控制,一端一旦开始发送消息,另一端就要完整接收消息才能响应
客户端用一个单独的数据包将查询串给服务器,这就是为什么语句很长max_allowed_packet
就很重要
相反,服务器响应给用户的数据通常很多,有多个数据包组成.服务器开始响应客户端请求时,客户端必须完全接收整个返回结果,而不是简单地只取前几条,然后停止
很多的MySQL的连接库函数都可以获得全部的结果集并缓存到内存中,而MySQL通常需要等所有的数据已经发送给客户端才会释放这条查询占用的资源
查询状态
对于MySQL的连接或者一个线程,任何时刻都有一个状态,表示了MySQL当前在做什么,可以使用SHOW FULL PROCESSLIST
(返回结果中Command)就是当前的状态,在一个生命周期中,会变化很多次
GROUP BY
操作,要么是文件排序或者UNION
操作,如果后面有on disk
表示MySQL正在讲一个内存临时表放到磁盘上在解析一个查询语句之前,如果缓存是打开的,MySQL会优先检查这个查询是否命中缓存,如果缓存是通过哈希实现的,那么要全部一样才能命中
如果使用了查询缓存,会检查权限,如果权限没有问题就会直接返回
下一步是讲SQL转化为执行计划,在依照执行计划和存储引擎进行交互,包括: 解析SQL, 预处理, 优化SQL,执行计划.任何一步出错,都会终止查询,这几步可能同时执行也可能单独执行
语法解析器和预处理
首先通过SQL语句进行解析,生成一棵"解析树",使用MySQL语法规则验证和解析查询,例如验证是否使用错误的关键字,或者关键字的顺序是否正确,或者引号是否能前后正确匹配
预处理则根据一些MySQL规则进一步检查是否合法,检查数据表和数据列是否存在,解析名字和别名是否有歧义,最后会验证权限,通常验证权限会很快,除非服务器有非常多的权限配置
查询优化器
优化器会将语法书转化为执行计划,一条查询可能有很多执行方式,优化器的作用就是找到最好的执行计划
MySQL使用基于成本的优化器,尝试预测一个查询使用某种执行方式的成本,通过查询当前会话的Last_query_cost
来得知当前查询的成本
SELECT SQL_NO_CACHE COUNT(*) FROM sakila.film_actor;
SHOW STATUS LIKE 'Last_query_cost';
表示大概需要做1040个查找才能完成上面的查询,导致MySQL优化器选择错误的执行计划有很多原因:
MySQL的优化策略可以简单的分为两种
MySQL能够处理优化的类型
EXPLAIN SELECT film.film_id, film_actor.actor_id
FROM sakila.film
INNER JOIN sakila.film_actor USING(film_id)
WHERE film.film_id = 1;
会分为两步查询,第一步: 先从 film 找到需要的行,因为在 film_id 字段上有主键索引,所以MySQL优化器知道只会返回一条数据,然后在执行第二步,MySQL将第一步中返回的film_id列作为已知取值来处理EXPLAIN SELECT film.film_id FROM sakila.film WHERE film_id = -1;
SELECT film.film_id
FROM sakila.film
INNER JOIN sakila.film_actor UWING(film_id)
WHERE film.film_id > 500;
这里使用film_id进行关联,这里的WHERE不仅对film表适用,对film_actor一样适用,如果其他数据库需要手动写两个数据和索引的统计信息
服务器层有查询优化器,却没有保存数据和索引的统计信息,统计信息由存储引擎实现,不同的存储引擎可能会存储不同的统计信息, 查询优化器在生成对应查询计划需要向存储引擎获取相应的统计信息,包括每个表或者所引有多少页面,每个表的每个索引基数.数据行和索引长度,索引分布信息等
MySQL执行关联查询
MySQL中的关联比我们理解的要广泛,任何一次查询都是一次关联,并不仅仅是两个表匹配才叫关联
UNION查询,MySQL现将一系列单个查询结果放到一个临时表,然后重新读取临时表完成UNION查询,在MySQL中,每个查询都是一次关联,所以读取结果也是一次关联
MySQL关联的策略很简单,对任何关联执行嵌套循环关联操作,在MySQL现在一个表中循环取出单条数据,然后嵌套循环到下一个表寻找匹配的行,直到所有匹配的行都匹配,按照这样的方式找到第一个表记录,在嵌套查询下一个关联表,然后回溯到上一个表,所以叫做嵌套循环关联
执行内连接可以表示为以下形式
SELECT tbl1.col1, tbl2.col2 FROM tbl1 INNER JOIN tbl2 USING(COL3)
WHERE tbl1.cik1 IN (5, 6);
伪代码可以表示为:
outer_iter = iterator over tbl1 where col1 IN(5, 6)
outer_row = outer_iter.next
while outer_row
inner_itor = iterator over tbl2 where col3 = outer_row.col3
inner_row = inner_iter.next
while inner_row
output [ outer_row.col1, inner_row.col2 ]
inner_row = inner_iter.next
end
outer_row = outer_iter.next
end
外连接的表现形式为:
SELECT tbl1.col1, tbl2.col2 FROM tbl1 LEFT OUTER JOIN tbl2 USING(col3) WHERE tbl1.col1 IN(5, 6);
伪代码表示为:
outer_iter = iterator over tbl1 where col1 IN(5, 6)
outer_row = outer_iter.next
while outer_row
inner_itor = iterator over tbl2 where col3 = outer_row.col3
inner_row = inner_iter.next
if inner_row
while inner_row
output [ outer_row.col1, inner_row.col2 ]
inner_row = inner_iter.next
end
else
output [ outer_row.col1, NULL ]
end
outer_row = outer_iter.next
end
也并不是所有的查询都可以转化为上面的形式,例如全外连接就无法通过嵌套循环和回溯方式完成
执行计划
MySQL不会生成查询字节码来查询,MySQL生成查询指令书,然后通过存储引擎执行完成并返回结果,最终执行计划包含了重构的全部信息,如果对一个查询执行 EXPLAIN 之后,在执行SHOW WARNINGS
,就可以看到重构的查询
这是一颗平衡树,这并不是MySQL的查询方式,MySQL是从一个表一直嵌套循环,回溯完成所有的表关联,实际是一颗左侧深度优先的树
关联查询优化器
决定了多个表关联时的顺序,通过多表关联,关联优化器通过评估不同的顺序成本来选择代价最小的
UNION的限制
UNION的LIMIT截取部分结果集, 下面的做法会将两个查询的结果存放到同一个临时表,再取前20条,这样效率很低
(
SELECT * FROM sakila.actor
) UNION ALL
(
SELECT * FROM sakila.customer
) LIMIT 20;
下面是给出的优化方式,给每一个查询都加上LIMIT 20,就可以解决这个问题
(
SELECT * FROM sakila.actor LIMIT 20
) UNION ALL
(
SELECT * FROM sakila.customer LIMIT 20
) LIMIT 20;
松散索引扫描
MySQL不支持松散索引扫描,所以无法按照不连续的方式扫描一个索引,即使需要的数据只有索引中的几个,也需要扫描这段索引的每一个条目
最大和最小值优化
如果对一些主键最小最大化可以不用 MIN() 而使用 LIMIT 代替
SELECT actor_id FROM sakila.actor USE INDEX(PRIMARY) WHERE first_name = 'PENELOPE' LIMIT 1;
无法在同一个表上同时查询和更新
MySQL不允许在同一张表进行查询和更新,这并不是优化器的限制,譬如下面的查询就无法执行
UPDATE tb1 AS outer_tb1
SET cnt = (
SELECT count(*) FROM tb1 AS inner_tb1
WHERE inner_tb1.type = outer_tb1.type
);
可以将查询改为下面的查询,这个查询实际是两个查询:
UPDATE tb1
INNER JOIN(
SELECT type, count(*) AS cnt FROM tb1
GROUP BY type
) AS der USING(type)
SET tb1.cnt = der.cnt
可以提供优化器提示,控制最终执行计划
MySQL5.0 和更新版本,新增了一些参数控制优化器的行为
前两个参数用来控制优化器走一些"捷径",可以让优化器再处理复杂SQL仍然很高效,但也可能让优化器错过一些真正最优的执行,所以要根据实际情况修改这些参数
MySQL升级后的影响
在版本升级之后一些"优化器提示"可能会让新版的优化策略失效,一般升级操作都很顺利,但还是需要检查细节和边界,可以使用Percona Toolkit
中的pt-upgrade
可以检查新版运行得SQL和老版是否一样
# 优化前, 扫描 4097 行数据
SELECT COUNT(*) FROM world.City WHERE ID > 5;
# 优化后,扫描 6 行数据
SELECT (SELECT COUNT(*) FROM world.City) - COUNT(*)
FROM world.City WHERE ID <= 5;
同级不同颜色的商品数量,此时不能使用OR语句
# 优化前使用 OR
SELECT COUNT(color='blue' OR color='red') FROM items;
# 优化后, 使用 SUM()
SELECT
SUM(IF(color='blue', 1, 0)) AS blue,
SUM(IF(color='read', 1, 0) AS red
FROM items;
# 优化后使用 OR NULL
SELECT
COUNT(color='blue' OR NULL) AS blue,
COUNT(color='red' OR NULL) AS red
FROM items;
使用近似值
有一些业务并不完全要求精确地COUNT, 可以用近似值来代替,譬如使用 EXPLAIN来估算行数, EXPLAIN 并不会真正的执行
更复杂的优化
更加复杂的优化可以考虑
快速``精确``简单
只能满足其二语句的优化
关联查询优化
优化子查询
SQL_BIG_RESULT
和SQL_SMALL_RESYLT
来按照这种方式运行, 如果遇到需要分组的尽量使用更高效的更短的列进行分组譬如能使用actor_id
(ID分组)就不要使用first_name,last_name
(姓名全拼)来分组
优化GROUP BY WITH ROLLUP
WITH ROLLUP: 将分组的结果进行的一个聚合(汇总)
可以通过EXPLAIN观察加WITH ROLLUP和去掉之后的执行计划是否相同对于WITH ROLLUP的优化分为大致几种策略:
优化LIMIT分页
在偏移量很大的时候 LIMIT 1000,20 MySQL需要查询 10020 记录然后最后返回 20 条,前面 10000 条都会被无情的抛弃,这样的代价很高,可以在页面限制分页的数量要么是优化大偏移量的性能
# 优化前
SELECT film_id, description FROM sakila.film ORDER BY title LIMIT 50, 5;
# 优化后, 通过覆盖索引查询到ID然后关联,对于大量数据的表,这样尤其有用
SELECT film.film_id, film.description
FROM sakila.film
INNER JOIN (
SELECT film_id FROM sakila.film ORDER BY title LIMIT 50, 5
) AS lim USING(film_id);
# BETWEEN: 代表 ... 到 ... 之间
SELECT film_id, description FROM sakila.film
WHERE position BETWEEN 50 AND 54 ORDER BY position;
# 获取第一组结果
SELECT * FROM sakila.rental ORDER BY rental_id DESC LIMIT 20;
# 从记录点开始找
SELECT * FROM sakila.rental WHERE rental_id < 16030 ORDER BY rental_id DESC LIMIT 20;
使用SQL_CALC_FOUND_ROWS提示可以去掉,LIMIT满足条件之后的行数,可以作为分页的总数,但是实际工作原理是扫描所有满足的行,然后抛弃不需要的行,而不是在满足LIMIT行后就停止扫描,所以这个提示的代价很高
一个改良的设计是,每页显示20条数据,那么每次查询都使用LIMIT返回21条记录并显示20条,如果第21条存在,那么就显示下一页的按钮,否则就说明没有更多的数据
另一种做法是仙还村较多的数据,比如缓存1000条,然后分次分页都从这个缓存中获取,这样做可以让应用程序根据大小采用不同的策略,如果结果集少于1000,这样就可以在页面显示所有的分页连接,如果大于1000,就在页面上设计一个额外的按钮
也可以使用 EXPLAIN 的 rows 来作为结果集总数的近似值,当需要精确结果使用 COUNT(*) 来满足需求,这都比 SQL_CALC_FOUND_ROWS 要快得多
UNION的实现方式总是通过临时表,所以很多优化策略都需要手动将 WHERE, LIMIT, ORDER BY下推到 UNION 中去
如果确实需要消除重复行,否则一定要使用 UNION ALL, 如果没有 ALL, MySQL会给临时表加上 DISTINCT, 会导致临时表做唯一性检查,代价很高
用户自定义变量是一个用来存储内容的临时容器,连接MySQL的整个过程都存在
自定义变量的属性和限制
优化排名语句
# := 代表赋值操作, 如果只是 "=" 不会进行赋值
SET @rownum := 0;
SELECT actor_id, @rownum := @rownum + 1 AS rownum FROM sakila.actor LIMIT 3;
加上排名之后
SELECT actor_id,COUNT(*) as cnt FROM sakila.film_actor
GROUP BY actor_id
ORDER BY cnt DESC
LIMIT 10;
# 优化后, 加入排名
SET @curr_cnt := 0, @prev_cnt := 0. @rank := 0;
SELECT actor_id,
@curr_cnt := COUNT(*) AS cnt,
@rank := IF(@prev_cnt <> @curr_cnt, @rank + 1, @rank) AS rank,
@prev_cnt := @curr_cnt AS dummy
FROM sakila.film_actor
GROUP BY actor_id
ORDER BY cnt DESC
LIMIT 10;
使用用户自定义变量经常会遇到"诡异"的现象,相比带来的优势,还是可以花一些时间来研究的,使用SQL生成排名通常需要两次计算, 如果通过中间临时表方案可能比较简单
SET @curr_cnt := 0,@prev_cnt := 0, @rank := 0;
SELECT actor_id,
@curr_cnt := cnt AS cnt,
@rank := IF(@prev_cnt <> @curr_cnt, @rank + 1, @rank) AS rank,
@prev_cnt := @curr_cnt AS dummy
FROM (
SELECT actor_id, COUNT(*) AS cnt
FROM sakila.film_actor
GROUP BY actor_id
ORDER BY cnt DESC
LIMIT 10
) AS der;
避免重复查询刚刚更新的数据
MySQL没有PGSQL中的UPDATE RETURNING
,但是MySQL可以通过一个变量来解決
UPDATE t1 SET lastUpdated =NOW() WHERE id = 1;
SELECT lastUpdated FROM t1 WHERE id = 1;
# 優化後
UPDATE t1 SET lastUpdated = NOW() WHERE id = 1 AND @now := NOW();
SELECT @now;
统计更新和插入的数量
如果使用INSERT ON DUPLICATE KEY UPDATE
如果想知道插入多少数据,有多少因为冲突改成更新的
# 每次由于冲突会导致变量 @x 自增,然后通过这个表达式 *0 不影响要更新的内容
INSERT INTO t1(c1, c2) VALUES(4, 4), (2, 1), (3, 1)
ON DUPLICATE KEY UPDATE
c1 = VALUES(c1) + (0 * (@x := @x + 1));
确定取值的顺序
最常见的问题就是在赋值前读取
SET @rownum := 0;
SELECT actor_id, @rownum := @rownum + 1 AS cnt
FROM sakila.actor
WHERE @rownum <= 1;
因为WHERE和SELECT是在查询不同阶段被执行的,如果加入ORDER BY结果可能会更不同
SET @rownum := 0;
SELECT actor_id, @rownum := @rownum + 1 AS cnt
FROM sakil.actor
WHERE @rownum <= 1
ORDER BY first_name;
因为ORDER BY引入了文件排序,WHERE条件是在文件排序操作之前取值,所以查询会返回所有的记录, 解决办法就是在赋值和取值发生在执行查询的同一阶段
SET @rownum := 0;
SELECT actor_id, @rownum AS rownum
FROM sakila.actor
WHERE (@rownum := @rownum + 1) <= 1;
如果在ORDER BY上加入会是什么结果?
SET @rownum := 0;
SELECT actor_id, first_name, @rownum AS rownum
FROM sakila.actor
WHERE @rownum <= 1
ORDER BY first_namem LEAST(0, @rownum := @rownum + 1);
编写偷懒的UNION
其中一个子分支先执行,如果找到匹配的行就跳过第二个分支,一般会现在频繁访问的表中查找"热数据",找不到到另一张访问较少的表中找"冷数据"
SELECT id FROM users WHERE id = 123
UNION ALL
SELECT id FROM users_archived WHERE id = 123;
# 优化后
# GREATEST函数返回参数中的最大值
SELECT GREATEST(@found := -1, id) AS id, 'users' AS which_tb1
FROM users WHERE id = 1
UNION ALL
SELECT id, 'users_archived'
FROM users_archived WHERE id = 1 AND @found IS NULL
UNION ALL
SELECT 1, 'reset' FROM DUAL WHERE (@found := NULL) IS NOT NULL;
用户自定义变量的其他用处