转:
https://mp.weixin.qq.com/s/gz-wQPsaerf4k7ymG8DiDA
https://mp.weixin.qq.com/s/h9jWeoyiBGnQLvDrtXqVWw
https://mp.weixin.qq.com/s/0qvO6eQAa9dSGoY1LF8_pA
https://mp.weixin.qq.com/s/h_sN67Q2UVL-jbTaFXOZYw
1、SQL 没加索引
很多时候,慢查询都是因为没有加索引导致的。
如果没有加索引,会导致走全表扫描,应考虑在 where 条件列建立索引,尽量避免走全表扫描。
// 添加索引
alter table user_info add index idx_name (name);
2、索引不生效
有时候明明加了索引,但是索引却不生效。
哪些场景会导致索引不生效呢?
// 我们创建一个用户user表
CREATE TABLE user (
id int(11) NOT NULL AUTO_INCREMENT,
userId varchar(32) NOT NULL,
age varchar(16) NOT NULL,
name varchar(255) NOT NULL,
PRIMARY KEY (id),
KEY idx_userid (userId) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
userId 为字符串类型,同时也是 B+ 树的普通索引,如果查询条件为数字,会导致索引失效。
但如果传的是字符串,则会走索引。
为什么第一条语句未加单引号,SQL 就不走索引了呢?
这是因为不加单引号时,是字符串和数字做比较,由于它们的类型不匹配,MySQL 会做隐式的类型转换,把它们转换为浮点数后再做比较,而隐式的类型转换,会导致索引失效。
CREATE TABLE user (
id int(11) NOT NULL AUTO_INCREMENT,
userId varchar(32) NOT NULL,
age varchar(16) NOT NULL,
name varchar(255) NOT NULL,
PRIMARY KEY (id),
KEY idx_userid (userId) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
userId 加了索引,但是 age 没有加索引。
这是因为,假设 MySQL 走了 userId 的索引,但是走到 age 查询条件时,还是得走全表扫描,即一共需要三步:全表扫描 + 索引扫描 + 合并。
但如果从一开始就走全表扫描,这样直接扫描一遍就完事了。MySQL 优化器出于效率和成本的考虑,在遇到 or 条件时,会让索引失效。
此外,即使 or 条件的列都加了索引,也不一定会走索引。
平时使用的时候,还是要注意一下 or,学会用 explain 分析。遇到不走索引的情况,考虑拆开两条 SQL。
事实上,并不是使用了 like 通配符,索引就一定会失效,而是当 like 查询是以 % 开头时,才会导致索引失效。
如果把 % 放在后面,此时还是会正常走索引的。
既然 like 查询以 % 开头时,会导致索引失效,那么我们该如何优化呢?
选择使用覆盖索引,或者考虑把 % 放后面。
MySQL 建立联合索引时,会遵循最左前缀匹配的原则,即最左优先。
如果建立了一个(a, b, c)的联合索引,就相当于建立了(a)、(a, b)、(a, b, c)这三个索引。
CREATE TABLE user (
id int(11) NOT NULL AUTO_INCREMENT,
user_id varchar(32) NOT NULL,
age varchar(16) NOT NULL,
name varchar(255) NOT NULL,
PRIMARY KEY (id),
KEY idx_userid_name (user_id,name) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
建立联合索引 idx_userid_name,执行如下 SQL,查询条件是 name,此时索引是无效的。因为查询条件列 name 不是联合索引中的第一列。
在联合索引中,查询条件只有在满足最左匹配原则时,索引才会正常生效。
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`userId` varchar(32) NOT NULL,
`login_time` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_userId` (`userId`) USING BTREE,
KEY `idx_login_time` (`login_Time`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
虽然 login_time 加了索引,但是因为使用了 MySQL 的内置函数 Date_ADD(),所以也是不会使用索引的。
这种情况该怎么优化呢?
可以考虑把内置函数的逻辑转移到右边。
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`userId` varchar(32) NOT NULL,
`age` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_age` (`age`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
虽然 age 加了索引,但是它在进行列运算时,也是不会使用索引的。可以改为在代码处理好后,再传参进去。
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`userId` int(11) NOT NULL,
`age` int(11) DEFAULT NULL,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_age` (`age`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
虽然 age 加了索引,但是使用了 != 或者 < >、not in 时,索引如同虚设。
这个也是和 MySQL 优化器有关,因为优化器觉得即使走了索引,但还是需要扫描很多行,它觉得不划算,还不如直接不走索引。
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`card` varchar(255) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_name` (`name`) USING BTREE,
KEY `idx_card` (`card`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
单个 name 字段加上索引,并查询 name 为非空的语句,其实还是会走索引的。
但如果用 or 连接起来,索引就失效了。
很多时候因为数据量的问题,导致 MySQL 优化器放弃走索引。平时在用 explain 分析 SQL 的时候,如果 type = range,要注意可能会因为数据量的问题,导致索引失效。
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8mb4 DEFAULT NULL,
`age` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_name` (`name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
CREATE TABLE `user_job` (
`id` int(11) NOT NULL,
`userId` int(11) NOT NULL,
`job` varchar(255) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_name` (`name`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
user 表的 name 字段编码是 utf8mb4,而 user_job 表的 name 字段编码为 utf8。
执行左外连接查询时,user_job 表还是走全表扫描。
但如果把它们的 name 字段改为编码一致,相同的 SQL,则此时会走索引。
MySQL 中一张表是可以支持多个索引的。如果 SQL 语句没有主动指定使用哪个索引,那么则由 MySQL 来确定使用哪个索引。
日常开发中,不断地删除历史数据和新增数据的场景,有可能会导致 MySQL 选错索引。
那么有哪些解决方案呢?
3、limit 深分页问题
limit 深分页问题,会导致慢查询。
limit 深分页为什么会导致 SQL 变慢呢?
CREATE TABLE account (
id int(11) NOT NULL AUTO_INCREMENT COMMENT '主键Id',
name varchar(255) DEFAULT NULL COMMENT '账户名',
balance int(11) DEFAULT NULL COMMENT '余额',
create_time datetime NOT NULL COMMENT '创建时间',
update_time datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
KEY idx_name (name),
KEY idx_create_time (create_time) // 索引
) ENGINE=InnoDB AUTO_INCREMENT=1570068 DEFAULT CHARSET=utf8 ROW_FORMAT=REDUNDANT COMMENT='账户表';
执行如下 SQL,分析其执行流程。
select id, name, balance from account where create_time > '2020-09-19' limit 100000, 10;
limit 语句会先扫描 offset + n 行,然后再丢弃掉前 offset 行,返回后 n 行数据。即 limit 100000, 10 会扫描 100010 行,而 limit 0, 10 只会扫描 10 行。limit 100000, 10 扫描更多的行数,也就意味着回表的次数更多。
如何优化深分页问题?
可以通过减少回表次数来优化。
一般有标签记录法和延迟关联法。
假设上一次记录到 100000,则 SQL 可以进行如下修改。
select id, name, balance FROM account where id > 100000 limit 10;
这样无论后面翻多少页,性能都会不错,因为命中了 id 索引。但是这种方式有局限性:需要一种类似连续自增的字段。
select acct1.id, acct1.name, acct1.balance FROM account acct1 INNER JOIN (SELECT a.id FROM account a WHERE a.create_time > '2020-09-19' limit 100000, 10) AS acct2 on acct1.id = acct2.id;
优化思路是:先通过 idx_create_time 二级索引树查询到满足条件的主键 id,再与原表通过主键 id 内连接,这样后面就直接走主键索引了,同时也减少了回表次数。
4、单表数据量太大
单表数据量太大为什么会导致 SQL 变慢?
当一个表的数据量达到好几千万甚至上亿时,加索引的效果已经没那么明显了。性能之所以会变差,是因为维护索引的 B+ 树的结构层级变得更高了。查询一条数据,需要经历的磁盘 I/O 变多,因此查询性能也就变慢了。
一棵 B+ 树可以存放多少数据量?
InnoDB 存储引擎的最小储存单元是页,一页大小是16k。
B+ 树叶子节点存的是数据,内部节点存的是键值 + 指针。索引组织表通过非叶子节点的二分查找法以及指针确定数据在哪个页中,进而再去数据页中找到所需要的数据。
假设 B+ 树的高度为 2,即有一个根结点和若干个叶子结点。那么这棵 B+ 树的存放总记录数 = 根结点指针数 * 单个叶子节点的记录行数。
如果一行记录的数据大小为 1k,那么单个叶子节点可以存放的记录数 = 16k / 1k = 16。
非叶子节点内可以存放多少指针呢?
我们假设主键 id 为 bigint 类型,长度为 8 字节(int 类型,一个 int 就是 32 位,4 字节),而指针大小在 InnoDB 源码中设置为 6 字节,所以就是 8 + 6 = 14 字节,16k / 14B = 16 * 1024B / 14B = 1170。
因此,一棵高度为 2 的 B+ 树,能存放 1170 * 16 = 18720 条的数据记录。
同理,一棵高度为 3 的 B+ 树,能存放 1170 * 1170 * 16 = 21902400 条,即两千万左右的记录。
B+ 树的高度一般为 1~3 层,这样已经满足千万级别的数据存储了。
如果 B+ 树想存储更多的数据,那树的结构层级就会更高,查询一条数据时,需要经历的磁盘 I/O 变多,因此查询性能就会变慢。
如何解决因单表数据量太大,导致查询变慢的问题?
一般超过千万级别的数据量,我们就可以考虑分库分表了。
但分库分表可能会导致以下问题。
因此,在评估是否分库分表前,先考虑下是否可以把部分历史数据先归档。如果可以,就先不要急着分库分表。如果真的要分库分表,综合考虑和评估方案后,可以考虑垂直、水平分库分表。水平分库分表策略可以考虑 range 范围、hash 取模、range + hash 取模混合等。
5、join 或者子查询过多
一般来说,不建议使用子查询,可以考虑把子查询改成 join 来优化。
数据库有个规范约定:尽量不要有超过 3 个以上的表连接。
在 MySQL 中,join 的执行算法分别是:Index Nested-Loop Join 和 Block Nested-Loop Join。
join 过多的问题
一般情况下,如果业务需要,关联 2~3 个表是可以接受的,但是关联的字段需要加索引。
如果需要关联更多的表,建议从代码层面进行拆分,在业务层先查询一张表的数据,然后以关联字段作为条件查询关联表形成 map,然后在业务层进行数据的拼装。
6、in 元素过多
如果使用了 in,要注意 in 后面的元素不要过多,即使后面的条件加了索引。
select user_id, name from user where user_id in (1, 2, 3...1000000);
in 元素一般建议不要超过 500 个,如果超过了,则建议分组,比如每 500 一组进行。
select user_id, name from user where user_id in (1, 2, 3...500);
如果我们对 in 的条件不做任何限制,该查询语句一次性可能会查询出非常多的数据,很容易导致接口超时。尤其有时候我们是用的子查询,in 后面的子查询,根本不确定数量有多少,更容易踩坑。
select * from user where user_id in (select author_id from artilce where type = 1);
如果传参太多,还可以考虑补充参数校验。
if (userIds.size() > 500) {
throw new Exception("单次查询的用户id不能超过200");
}
7、数据库在刷脏页
什么是脏页?
当内存数据页和磁盘数据页的内容不一致的时候,我们就称这个内存页为“脏页”。内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,此时称为“干净页”。一般有更新 SQL 时才可能会导致脏页。
一条更新语句是如何执行的?
update t set c = c + 1 where id = 666;
InnoDB 在处理更新语句时,只做了写日志这一个磁盘操作。这个日志叫作 redo log(重做日志)。平时之所以更新 SQL 执行得很快,是因为它只是在写内存和 redo log 日志,等到空闲的时候,才把 redo log 日志里的数据同步到磁盘中。
redo log 日志不是在磁盘么?那为什么不慢?
其实是因为写 redo log 的过程是顺序写磁盘的。磁盘顺序写会减少寻道等待时间,速度会比随机写快很多。
为什么会出现脏页呢?
更新 SQL 只是在写内存和 redo log 日志,等到空闲的时候,才把 redo log 日志里的数据同步到磁盘中。这时内存数据页和磁盘数据页的内容不一致,就出现了脏页。
什么时候会刷脏页(flush)?
InnoDB 存储引擎的 redo log 的大小是固定的,并且是环型写入的。
以下几种场景会触发刷脏页。
为什么刷脏页会导致 SQL 变慢呢?
redo log 写满了,要刷脏页,这时候会导致系统所有的更新都堵住,写性能跌为 0。
一个查询要淘汰的脏页个数太多,一样会导致查询的响应时间明显变长。
8、order by 文件排序
order by 一定会导致慢查询吗?
不是的,因为 order by 平时用得多,并且数据量一上来,如果走文件排序,很容易产生慢 SQL。
假设存在一张员工表,表结构如下。
CREATE TABLE `staff` (
`id` BIGINT ( 11 ) AUTO_INCREMENT COMMENT '主键id',
`id_card` VARCHAR ( 20 ) NOT NULL COMMENT '身份证号码',
`name` VARCHAR ( 64 ) NOT NULL COMMENT '姓名',
`age` INT ( 4 ) NOT NULL COMMENT '年龄',
`city` VARCHAR ( 64 ) NOT NULL COMMENT '城市',
PRIMARY KEY ( `id`),
INDEX idx_city ( `city` )
) ENGINE = INNODB COMMENT '员工表';
表数据如下。
现在有这么一个需求:查询前 10 个来自深圳的员工的姓名、年龄、城市,并且按照年龄小到大排序。对应的 SQL 语句如下。
select name, age, city from staff where city = '深圳' order by age limit 10;
使用 explain 查看 SQL 的执行计划。
order by 用到文件排序时,为什么查询效率较低?
order by 排序,分为全字段排序和 rowid 排序。它是拿 max_length_for_sort_data 和结果行的数据长度做对比,如果结果行的数据长度超过 max_length_for_sort_data 的值,就会走 rowid 排序,否则走全字段排序。
什么是全字段排序?
MySQL 会给每个查询线程都分配一块小内存用于排序,称为 sort_buffer。通过 idx_city 索引找到对应的数据后,再把数据放进去排序。
idx_city 索引树,叶子节点存储的是主键 id,如下图所示。
同时,还有一棵 id 主键聚族索引树。
SQL 查询语句先通过 idx_city 索引树,找到对应的主键 id,然后再通过拿到的主键 id,搜索 id 主键索引树,找到对应的行数据。
加上 order by 之后,整体的执行流程如下所示。
将查询所需的字段全部读取到 sort_buffer 中,就是全字段排序。
把查询的所有字段都放到 sort_buffer 中,如果数据量太大,sort_buffer 放不下怎么办呢?
答案是:磁盘临时文件辅助排序。
实际上,sort_buffer 的大小是由参数 sort_buffer_size 控制的。如果要排序的数据小于 sort_buffer_size,排序在 sort_buffer 内存中完成,否则借助磁盘文件来进行排序。
如何确定是否使用了磁盘文件来进行排序呢?
// 打开optimizer_trace,开启统计
set optimizer_trace = "enabled=on";
// 执行SQL语句
select name, age, city from staff where city = '深圳' order by age limit 10;
// 查询输出的统计信息
select * from information_schema.optimizer_trace;
可以从 number_of_tmp_files 中看出是否使用了临时文件。
number_of_tmp_files 表示用来排序的磁盘临时文件数。如果 number_of_tmp_files > 0,则表示使用了磁盘文件来进行排序。
如果使用了磁盘临时文件,此时排序过程又是怎样的呢?
什么是 rowid 排序?
rowid 排序就是只把查询所需的用于排序的字段和主键 id,放到 sort_buffer 中。
怎么确定走的是全字段排序还是 rowid 排序呢?
实际上是通过参数 max_length_for_sort_data 控制的,它表示 MySQL 用于排序的行数据的长度,如果单行的长度超过这个值,MySQL 就认为单行太大,就换成 rowid 排序。
可以通过命令来查看参数的取值。
max_length_for_sort_data 的默认值是 1024。示例中 name,age,city 的长度= 64 + 4 + 64 = 132 < 1024,所以走的是全字段排序。
// 修改排序数据的最大单行长度为32
set max_length_for_sort_data = 32;
// 执行查询SQL
select name, age, city from staff where city = '深圳' order by age limit 10;
如果使用 rowid 排序,SQL 执行流程如下所示。
对比全字段排序的流程,rowid 排序就是多了一次回表操作。
什么是回表?
拿到主键后再回到主键索引中查询的过程,就叫做“回表”。
通过 optimizer_trace 可以查看是否使用了 rowid 排序。
// 打开optimizer_trace,开启统计
set optimizer_trace = "enabled=on";
// 执行SQL语句
select name, age, city from staff where city = '深圳' order by age limit 10;
// 查询输出的统计信息
select * from information_schema.optimizer_trace
全字段排序与 rowid 排序的对比
如何优化 order by?
联合索引优化
给查询条件 city 和排序字段 age 加上联合索引 idx_city_age。
alter table staff add index idx_city_age(city, age);
可以发现,加上 idx_city_age 联合索引后,就不需要 Using filesort 文件排序了,因为索引本身就是有序的。
此时 SQL 执行流程如下所示。
从示意图中可知,还是有一次回表操作。针对本次示例,有没有更高效的方案呢?
有的,可以使用覆盖索引。
在查询的数据列里,不需要再回表查询,直接从索引列就能取到想要的结果。换句话说,SQL 用到的索引列数据,覆盖了查询结果的列,就是覆盖索引。
给 city、name、age 组成一个联合索引,即可用到覆盖索引,这时 SQL 执行连回表操作都可以省去。
调整参数优化
可以通过调整参数,去优化 order by 的执行。比如可以调整 sort_buffer_size 的值。因为如果 sort_buffer 值太小,同时数据量太大,会借助磁盘临时文件排序。如果 MySQL 服务器配置高的话,可以稍微调大一点。
还可以调整 max_length_for_sort_data 的值,如果这个值太小,order by 会走 rowid 排序,会回表查询,降低查询性能。所以max_length_for_sort_data 可以适当调大一点。
当然,很多时候这些 MySQL 的参数值,直接采用默认值就可以了。
使用 order by 的一些注意点
假设存在如下 SQL,判断 create_time 是否需要加索引。
select * from A order by create_time;
无条件查询,即使 create_time 上有索引,也不会使用到。因为 MySQL 优化器认为走普通二级索引,再去回表查询,这一成本比全表扫描排序更高。所以选择走全表扫描,然后根据全字段排序或者 rowid 排序来进行。
select * from A order by create_time limit m;
无条件查询,如果 m 值较小,是可以走索引的。因为 MySQL 优化器认为,根据索引的有序性去回表查询数据,然后得到 m 条数据,就可以终止循环,那么成本会比全表扫描小,则选择走二级索引。
select * from A order by a limit 100000, 10;
可以记录上一页最后的 id,下一页查询时,查询条件带上 id,如:where id > 上一页最后的 id limit 10;也可以在业务允许的情况下,限制页数。
假设存在联合索引 idx_age_name,需要查询前 10 个员工的姓名、年龄,并且按照年龄从小到大排序,如果年龄相同,则按照姓名降序排序。
select name, age from staff order by age, name desc limit 10;
查看执行计划,发现使用到了 Using filesort 文件排序。
这是因为,在 idx_age_name 索引树中,age 从小到大排序,如果 age 相同,再按照 name 从小到大排序。而 order by 中,是按照 age 从小到大排序,如果 age 相同,再按照 name 从大到小排序。即索引存储顺序与 order by 不一致。
此时该怎么优化呢?
MySQL 8.0 版本,支持 Descending Indexes,可以通过修改索引来优化。
CREATE TABLE `staff` (
`id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
`id_card` varchar(20) NOT NULL COMMENT '身份证号码',
`name` varchar(64) NOT NULL COMMENT '姓名',
`age` int(4) NOT NULL COMMENT '年龄',
`city` varchar(64) NOT NULL COMMENT '城市',
PRIMARY KEY (`id`),
KEY `idx_age_name` (`age`,`name` desc) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8 COMMENT='员工表';
假设存在联合索引 idx_city_name,执行如下 SQL,是不会走排序过程的。
但如果使用了 in 条件,并且有多个条件时,就会有排序过程。
这是因为 in 有两个条件,在满足“深圳”时,age 是排好序的,但是把满足“上海”的 age 也加进来,就不能保证所有的 age 都是排好序的,因此需要 Using filesort 文件排序。
9、拿不到锁
有时候一条很简单的查询 SQL,等待很长时间却不见结果返回。一般这种情况就是表被锁住了,或者要查询的某一行或者某几行被锁住了,只能慢慢等待锁被释放。
这时可以用 show processlist 命令,查看当前语句处于什么状态。
10、delete + in 子查询不走索引
当 delete + in 子查询时,即使有索引,也是不走索引的。而对应的 select + in 子查询,却可以走索引。
// MySQL版本5.7
CREATE TABLE `old_account` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键Id',
`name` varchar(255) DEFAULT NULL COMMENT '账户名',
`balance` int(11) DEFAULT NULL COMMENT '余额',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_name` (`name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1570068 DEFAULT CHARSET=utf8 ROW_FORMAT=REDUNDANT COMMENT='老的账户表';
CREATE TABLE `account` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键Id',
`name` varchar(255) DEFAULT NULL COMMENT '账户名',
`balance` int(11) DEFAULT NULL COMMENT '余额',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_name` (`name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1570068 DEFAULT CHARSET=utf8 ROW_FORMAT=REDUNDANT COMMENT='账户表';
从 explain 结果可知,先全表扫描 account,然后逐行执行子查询,判断条件是否满足。显然这个执行计划和预期不符,因为并没有走索引。
但如果把 delete 换成 select,此时就会走索引。
为什么 select + in 子查询会走索引,而 delete + in 子查询却不会走索引呢?
explain select * from account where name in (select name from old_account);
show WARNINGS; // 可以查看优化后最终执行的sql
select `test2`.`account`.`id` AS `id`, `test2`.`account`.`name` AS `name`, `test2`.`account`.`balance` AS `balance`, `test2`.`account`.`create_time` AS `create_time`, `test2`.`account`.`update_time` AS `update_time` from `test2`.`account` semi join (`test2`.`old_account`) where (`test2`.`account`.`name` = `test2`.`old_account`.`name`);
可以发现,在实际执行的时候,MySQL 对 select + in 子查询做了优化,把子查询改成了 join 的方式,所以可以走索引。
但是很遗憾,对于 delete + in 子查询,MySQL 没有对它做这个优化。
那如何优化这个问题呢?
通过上面的分析,显然可以把 delete + in 子查询改为 join 的方式。
可以发现,改用 join 的方式是可以走索引的,完美解决了这个问题。
事实上,对于 update 或者 delete 子查询的语句,MySQL 也是推荐采用 join 的方式优化。
此外,给表加上别名,也可以解决这个问题。
为什么加个别名就可以走索引了呢?
查看 explain 执行计划,可以发现在 Extra 那一栏,有个 LooseScan。
LooseScan,是一种策略,是 semi join 子查询的一种执行策略。
因为子查询改为 join,可以让 delete + in 子查询走索引,加别名会走 LooseScan 策略,而 LooseScan 策略本质上就是 semi join 子查询的一种执行策略。因此,加别名就可以让 delete + in 子查询也走索引。
11、group by 使用临时表
group by 一般用于分组统计,它表达的逻辑就是根据一定的规则,进行分组。在日常开发中,我们使用得比较频繁,如果稍不注意,很容易产生慢 SQL。
CREATE TABLE `staff` (
`id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
`id_card` varchar(20) NOT NULL COMMENT '身份证号码',
`name` varchar(64) NOT NULL COMMENT '姓名',
`age` int(4) NOT NULL COMMENT '年龄',
`city` varchar(64) NOT NULL COMMENT '城市',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8 COMMENT='员工表';
现在有这么一个需求:统计每个城市的员工数量。对应 SQL 的执行计划如下所示。
group by 是怎么使用到临时表和排序的呢?
对应 SQL 的执行流程如下。
那么临时表的排序是怎样的呢?
就是把需要排序的字段,放到 sort buffer 中,排完就返回。这里需要注意,排序分为全字段排序和 rowid 排序。
where 和 having 的区别?
select city, count(*) as num from staff where age > 30 group by city;
// 加索引
alter table staff add index idx_age (age);
从 explain 执行计划中,可以发现查询条件命中了 idx_age 的索引,并且使用了临时表和排序。
Using index condition 表示索引下推优化,根据索引尽可能的过滤数据,然后再返回给服务器层去根据 where 的其他条件进行过滤。这里并不代表一定是使用了索引下推,只是代表可以使用,实际上不一定用了。
对应 SQL 执行流程如下。
如果要查询每个城市的员工数量,获取员工数量不低于 3 的城市,可以用 having 解决。
having 称为分组过滤条件,它对返回的结果集操作。
select city, count(*) as num from staff where age > 19 group by city having num >= 3;
where + having 区别总结
使用 group by 需要注意的问题
group by 就是分组统计的意思,一般情况下都是配合聚合函数一起使用。
但在 MySQL 5.7 版本中,即使没有配合聚合函数使用也是可以的,不会报错,并且返回的是分组的第一行数据。
当然,平时使用的时候,group by 还是配合聚合函数使用的,除非一些特殊场景,比如想去重,当然去重用 distinct 也是可以的。
不一定。
分组字段 city 不在 select 后面,但并不会报错。当然,这个可能和不同的数据库或者不同的版本有关。
group by 使用不当,很容易产生慢 SQL 问题。因为它既用到了临时表,又默认用到了排序,有时候还可能用到磁盘临时表。
如果在执行过程中,MySQL 发现内存临时表的大小达到了上限(由参数 tmp_table_size 控制),会把内存临时表转成磁盘临时表。如果数据量很大,很可能查询所需的磁盘临时表,会占用大量的磁盘空间。
group by 的优化方案
至于为什么执行 group by 语句时需要使用临时表,是因为在 group by 的语义逻辑中,就是统计不同的值出现的个数。如果这些值从一开始就是有序的,那我们直接往下扫描统计就好了,就不需要再使用临时表来记录并统计结果了。
如果加上联合索引 idx_age_city(age, city),查看对应 SQL 的执行计划,发现既不用排序,也不需要临时表。
可见加合适的索引是优化 group by 最简单最有效的优化方式。
并不是所有的场景都适合加索引,如果碰上不适合创建索引的场景,比如需求并不需要对结果集进行排序,则可以使用 order by null。
如果 group by 需要统计的数据量不多,可以考虑尽量只使用内存临时表。
如果内存临时表放不下数据,从而用到磁盘临时表,此时会比较耗时。因此可以适当调大 tmp_table_size 参数,以此避免用到磁盘临时表。
如果数据量实在太大,不可能无限制地调大 tmp_table_size,也不能眼睁睁地看着数据先放到内存临时表,随着数据的插入,发现到达上限后,再转成磁盘临时表。
因此,如果预估数据量比较大,可以使用 SQL_BIG_RESULT 这个提示,直接用磁盘临时表。MySQL 优化器发现磁盘临时表是 B+ 树存储,存储效率不如数组,因此会直接采用数组来存储。
从执行计划的 Extra 字段可以看到,执行没有再使用临时表,而是只有排序。
对应的执行流程如下。
12、系统硬件或者网络资源
如果数据库服务器内存、硬件资源,或者网络资源配置不是很好,就会慢一些,这时候可以考虑升级配置。
如果数据库的压力本身很大,比如在高并发场景下,大量请求打到数据库上,数据库服务器的 CPU 占用很高或者 I/O 利用率很高,这种情况下所有语句的执行都有可能变慢。
此外,如果测试环境下数据库的一些参数配置,和生产环境下的参数配置不一致,也容易产生慢 SQL。