【转】盘点 MySQL 慢查询的 12 个原因

转:
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 条件列建立索引,尽量避免走全表扫描。

【转】盘点 MySQL 慢查询的 12 个原因_第1张图片

// 添加索引
alter table user_info add index idx_name (name);

【转】盘点 MySQL 慢查询的 12 个原因_第2张图片

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+ 树的普通索引,如果查询条件为数字,会导致索引失效。

【转】盘点 MySQL 慢查询的 12 个原因_第3张图片

但如果传的是字符串,则会走索引。

【转】盘点 MySQL 慢查询的 12 个原因_第4张图片

为什么第一条语句未加单引号,SQL 就不走索引了呢?

这是因为不加单引号时,是字符串和数字做比较,由于它们的类型不匹配,MySQL 会做隐式的类型转换,把它们转换为浮点数后再做比较,而隐式的类型转换,会导致索引失效。

  • 查询条件包含 or
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 慢查询的 12 个原因_第5张图片

这是因为,假设 MySQL 走了 userId 的索引,但是走到 age 查询条件时,还是得走全表扫描,即一共需要三步:全表扫描 + 索引扫描 + 合并。

但如果从一开始就走全表扫描,这样直接扫描一遍就完事了。MySQL 优化器出于效率和成本的考虑,在遇到 or 条件时,会让索引失效。

此外,即使 or 条件的列都加了索引,也不一定会走索引。

平时使用的时候,还是要注意一下 or,学会用 explain 分析。遇到不走索引的情况,考虑拆开两条 SQL。

  • like 通配符

事实上,并不是使用了 like 通配符,索引就一定会失效,而是当 like 查询是以 % 开头时,才会导致索引失效。

【转】盘点 MySQL 慢查询的 12 个原因_第6张图片

如果把 % 放在后面,此时还是会正常走索引的。

【转】盘点 MySQL 慢查询的 12 个原因_第7张图片

既然 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 不是联合索引中的第一列。

【转】盘点 MySQL 慢查询的 12 个原因_第8张图片

在联合索引中,查询条件只有在满足最左匹配原则时,索引才会正常生效。

【转】盘点 MySQL 慢查询的 12 个原因_第9张图片

  • 在索引列上使用了 MySQL 的内置函数
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(),所以也是不会使用索引的。

【转】盘点 MySQL 慢查询的 12 个原因_第10张图片

这种情况该怎么优化呢?

可以考虑把内置函数的逻辑转移到右边。

【转】盘点 MySQL 慢查询的 12 个原因_第11张图片

  • 对索引进行列运算(+、-、*、/)
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 加了索引,但是它在进行列运算时,也是不会使用索引的。可以改为在代码处理好后,再传参进去。

【转】盘点 MySQL 慢查询的 12 个原因_第12张图片

  • 在索引字段上使用 != 或者 < >
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 慢查询的 12 个原因_第13张图片

这个也是和 MySQL 优化器有关,因为优化器觉得即使走了索引,但还是需要扫描很多行,它觉得不划算,还不如直接不走索引。

  • 在索引字段上使用 is null、is not null
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 为非空的语句,其实还是会走索引的。

【转】盘点 MySQL 慢查询的 12 个原因_第14张图片

但如果用 or 连接起来,索引就失效了。

【转】盘点 MySQL 慢查询的 12 个原因_第15张图片

很多时候因为数据量的问题,导致 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 表还是走全表扫描。

【转】盘点 MySQL 慢查询的 12 个原因_第16张图片

但如果把它们的 name 字段改为编码一致,相同的 SQL,则此时会走索引。

【转】盘点 MySQL 慢查询的 12 个原因_第17张图片

  • 优化器选错了索引

MySQL 中一张表是可以支持多个索引的。如果 SQL 语句没有主动指定使用哪个索引,那么则由 MySQL 来确定使用哪个索引。

日常开发中,不断地删除历史数据和新增数据的场景,有可能会导致 MySQL 选错索引。

那么有哪些解决方案呢?

  • 使用 force index 强行选择某个索引
  • 修改 SQL,引导它使用我们所期望的索引
  • 优化业务逻辑
  • 优化索引,新建一个更合适的索引,或者删除无用的索引

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;
  1. 通过普通二级索引树 idx_create_time,过滤 create_time 条件,找到满足条件的主键 id
  2. 通过主键 id,回到 id 主键索引树,找到满足记录的行,然后取出需要展示的列(回表过程)
  3. 扫描满足条件的 100010 行,然后扔掉前 100000 行,返回

【转】盘点 MySQL 慢查询的 12 个原因_第18张图片

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+ 树叶子节点存的是数据,内部节点存的是键值 + 指针。索引组织表通过非叶子节点的二分查找法以及指针确定数据在哪个页中,进而再去数据页中找到所需要的数据。

【转】盘点 MySQL 慢查询的 12 个原因_第19张图片

假设 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 变多,因此查询性能就会变慢。

如何解决因单表数据量太大,导致查询变慢的问题?

一般超过千万级别的数据量,我们就可以考虑分库分表了。

但分库分表可能会导致以下问题。

  • 事务问题
  • 跨库问题
  • 排序问题
  • 分页问题
  • 分布式 id

因此,在评估是否分库分表前,先考虑下是否可以把部分历史数据先归档。如果可以,就先不要急着分库分表。如果真的要分库分表,综合考虑和评估方案后,可以考虑垂直、水平分库分表。水平分库分表策略可以考虑 range 范围、hash 取模、range + hash 取模混合等。

5、join 或者子查询过多

一般来说,不建议使用子查询,可以考虑把子查询改成 join 来优化。

数据库有个规范约定:尽量不要有超过 3 个以上的表连接。

在 MySQL 中,join 的执行算法分别是:Index Nested-Loop Join 和 Block Nested-Loop Join。

  • Index Nested-Loop Join:和我们写程序时的嵌套查询类似,并且可以用上被驱动表的索引
  • Block Nested-Loop Join:被驱动表上没有可用的索引时,它会先把驱动表的数据读入线程内存 join_buffer 中,再扫描被驱动表,把被驱动表的每一行取出来,和 join_buffer 中的数据做对比,满足 join 条件的,就作为结果集的一部分返回

join 过多的问题

  • 一方面,过多的表连接,会大大增加 SQL 复杂度
  • 另一方面,如果可以使用被驱动表的索引那还好,并且使用小表来做驱动表,查询效率会更佳。但如果被驱动表没有可用的索引,join 是在 join_buffer 内存中做的,如果匹配的数据量比较小或者 join_buffer 设置的比较大,速度也不会太慢。但是,如果 join 的数据量比较大,MySQL 会采用在硬盘上创建临时表的方式进行多张表的关联匹配,显然这种方式效率极低,因为本来磁盘的 I/O 就不快,现在还要关联

一般情况下,如果业务需要,关联 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;
  1. 对于这条更新 SQL,执行器会先找存储引擎取 id = 666 这一行。如果这行所在的数据页本身就在内存中,则直接返回给执行器,否则就去磁盘读入内存后再返回
  2. 执行器拿到存储引擎给的行数据后,给这一行的字段 c 的值加一,得到新的一行数据,再调用存储引擎接口写入这行新数据
  3. 存储引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态
  4. 执行器生成这个操作的 binlog,同时将 binlog 写入磁盘
  5. 执行器调用存储引擎的提交事务接口,存储引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成

【转】盘点 MySQL 慢查询的 12 个原因_第20张图片

InnoDB 在处理更新语句时,只做了写日志这一个磁盘操作。这个日志叫作 redo log(重做日志)。平时之所以更新 SQL 执行得很快,是因为它只是在写内存和 redo log 日志,等到空闲的时候,才把 redo log 日志里的数据同步到磁盘中。

redo log 日志不是在磁盘么?那为什么不慢?

其实是因为写 redo log 的过程是顺序写磁盘的。磁盘顺序写会减少寻道等待时间,速度会比随机写快很多。

为什么会出现脏页呢?

更新 SQL 只是在写内存和 redo log 日志,等到空闲的时候,才把 redo log 日志里的数据同步到磁盘中。这时内存数据页和磁盘数据页的内容不一致,就出现了脏页。

什么时候会刷脏页(flush)?

InnoDB 存储引擎的 redo log 的大小是固定的,并且是环型写入的。

【转】盘点 MySQL 慢查询的 12 个原因_第21张图片

以下几种场景会触发刷脏页。

  • redo log 写满了,要刷脏页。这种情况要尽量避免。因为出现这种情况时,整个系统就不能再接收更新,即所有的更新都必须堵住
  • 内存不够了,需要新的内存页,就要淘汰一些数据页,这时候会刷脏页。InnoDB 使用缓冲池(buffer pool)来管理内存,当要读入的数据页不在内存时,就必须到缓冲池中申请一个数据页。这时候只能把最久不使用的数据页从内存中淘汰掉,如果要淘汰的是一个干净页,就直接释放出来复用;但如果是脏页,就必须先将脏页刷到磁盘,变成干净页后才能复用
  • MySQL 认为系统空闲的时候,也会刷一些脏页
  • MySQL 正常关闭时,会把内存的脏页都 flush 到磁盘上

为什么刷脏页会导致 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 '员工表';

表数据如下。

【转】盘点 MySQL 慢查询的 12 个原因_第22张图片

现在有这么一个需求:查询前 10 个来自深圳的员工的姓名、年龄、城市,并且按照年龄小到大排序。对应的 SQL 语句如下。

select name, age, city from staff where city = '深圳' order by age limit 10;

使用 explain 查看 SQL 的执行计划。

【转】盘点 MySQL 慢查询的 12 个原因_第23张图片

  • 执行计划里的 key 表示用到了索引 idx_city
  • Extra 字段里的 Using index condition 表示索引条件
  • Extra 字段的 Using filesort 表示用到了文件排序
  • 即这条 SQL 使用到了索引,并且也用到了排序

order by 用到文件排序时,为什么查询效率较低?

【转】盘点 MySQL 慢查询的 12 个原因_第24张图片

order by 排序,分为全字段排序和 rowid 排序。它是拿 max_length_for_sort_data 和结果行的数据长度做对比,如果结果行的数据长度超过 max_length_for_sort_data 的值,就会走 rowid 排序,否则走全字段排序。

什么是全字段排序?

MySQL 会给每个查询线程都分配一块小内存用于排序,称为 sort_buffer。通过 idx_city 索引找到对应的数据后,再把数据放进去排序。

idx_city 索引树,叶子节点存储的是主键 id,如下图所示。

【转】盘点 MySQL 慢查询的 12 个原因_第25张图片

同时,还有一棵 id 主键聚族索引树。

【转】盘点 MySQL 慢查询的 12 个原因_第26张图片

SQL 查询语句先通过 idx_city 索引树,找到对应的主键 id,然后再通过拿到的主键 id,搜索 id 主键索引树,找到对应的行数据。

加上 order by 之后,整体的执行流程如下所示。

【转】盘点 MySQL 慢查询的 12 个原因_第27张图片

  1. MySQL 为对应的线程初始化 sort_buffer,放入需要查询的 name、age、city 字段
  2. 从索引树 idx_city 中,找到第一个满足 city = “深圳”这一条件的主键 id,即图中的 id = 9
  3. 到主键 id 索引树中拿到 id = 9 的这一行数据,取 name、age、city 三个字段的值,存到 sort_buffer 中
  4. 从索引树 idx_city 中拿到下一个记录的主键 id,即图中的 id = 13
  5. 重复步骤 3、4 直到 city 的值不等于深圳为止
  6. 前面 5 步已经查找到了所有 city 为深圳的数据,在 sort_buffer 中将所有的数据根据 age 进行排序
  7. 按照排序结果取前 10 行返回给客户端

将查询所需的字段全部读取到 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 中看出是否使用了临时文件。

【转】盘点 MySQL 慢查询的 12 个原因_第28张图片

number_of_tmp_files 表示用来排序的磁盘临时文件数。如果 number_of_tmp_files > 0,则表示使用了磁盘文件来进行排序。

如果使用了磁盘临时文件,此时排序过程又是怎样的呢?

  1. 从主键 id 索引树中,拿到需要的数据,并放到 sort_buffer 内存块中。当 sort_buffer 快要满时,就对 sort_buffer 中的数据进行排序,排完后,把数据临时放到磁盘的一个小文件中
  2. 继续回到主键 id 索引树中取数据,继续放到 sort_buffer 内存中,排序后,也把这些数据写入到磁盘的临时小文件中
  3. 继续循环,直到取出所有满足条件的数据
  4. 最后把磁盘中临时排好序的小文件,合并成一个有序的大文件。事实上,借助磁盘临时小文件排序,使用的是归并排序算法

什么是 rowid 排序?

rowid 排序就是只把查询所需的用于排序的字段和主键 id,放到 sort_buffer 中。

怎么确定走的是全字段排序还是 rowid 排序呢?

实际上是通过参数 max_length_for_sort_data 控制的,它表示 MySQL 用于排序的行数据的长度,如果单行的长度超过这个值,MySQL 就认为单行太大,就换成 rowid 排序。

可以通过命令来查看参数的取值。

【转】盘点 MySQL 慢查询的 12 个原因_第29张图片

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 执行流程如下所示。

  1. MySQL 为对应的线程初始化 sort_buffer,放入需要排序的 age 字段以及主键 id
  2. 从索引树 idx_city 中,找到第一个满足 city = “深圳”这一条件的主键 id,即图中的 id = 9
  3. 从主键 id 索引树中拿到 id = 9 的这一行数据,取 age 和主键 id 的值,存到 sort_buffer 中
  4. 从索引树 idx_city 中拿到下一个记录的主键 id,即图中的 id = 13
  5. 重复步骤 3、4 直到 city 的值不等于深圳为止
  6. 前面 5 步已经查找到了所有 city 为深圳的数据,在 sort_buffer 中将所有的数据根据 age 进行排序
  7. 遍历排序结果,取前 10 行,并按照 id 的值回到原表中,取出 city、name 和 age 三个字段返回给客户端

【转】盘点 MySQL 慢查询的 12 个原因_第30张图片

对比全字段排序的流程,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 

【转】盘点 MySQL 慢查询的 12 个原因_第31张图片

全字段排序与 rowid 排序的对比

  • 全字段排序:sort_buffer 内存如果不够,就需要用到磁盘临时文件,造成磁盘访问
  • rowid 排序:sort_buffer 可以放更多的数据,但是需要再回到原表去取数据,比全字段排序多一次回表操作
  • 一般情况下,对 InnoDB 存储引擎而言,会优先使用全字段排序。可以发现 max_length_for_sort_data 参数设置为 1024,这个数是比较大的。而一般情况下,排序字段并不会超过这个值,即都会走全字段排序

如何优化 order by?

  • 因为数据是无序的,所以就需要排序。但如果数据本身就是有序的,那就不用排了。索引数据本身是有序的,可以通过建立联合索引的方式,优化 order by 语句
  • 还可以通过调整 max_length_for_sort_data 等参数进行优化

联合索引优化

【转】盘点 MySQL 慢查询的 12 个原因_第32张图片

给查询条件 city 和排序字段 age 加上联合索引 idx_city_age。

alter table staff add index idx_city_age(city, age);

【转】盘点 MySQL 慢查询的 12 个原因_第33张图片

可以发现,加上 idx_city_age 联合索引后,就不需要 Using filesort 文件排序了,因为索引本身就是有序的。

【转】盘点 MySQL 慢查询的 12 个原因_第34张图片

此时 SQL 执行流程如下所示。

【转】盘点 MySQL 慢查询的 12 个原因_第35张图片

  1. 从索引 idx_city_age 中找到满足 city = “深圳”的主键 id
  2. 从主键 id 索引中取出整行,拿到 name、city、age 三个字段的值,作为结果集的一部分直接返回
  3. 从索引 idx_city_age 中取下一个记录的主键 id
  4. 重复步骤 2、3 直到查到第 10 条记录或者不满足 city = “深圳”条件时,循环结束

从示意图中可知,还是有一次回表操作。针对本次示例,有没有更高效的方案呢?

有的,可以使用覆盖索引。

在查询的数据列里,不需要再回表查询,直接从索引列就能取到想要的结果。换句话说,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 的一些注意点

  • 没有 where 条件,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 条数据,就可以终止循环,那么成本会比全表扫描小,则选择走二级索引。

  • 分页 limit 过大时,导致大量排序怎么办?
select * from A order by a limit 100000, 10;

可以记录上一页最后的 id,下一页查询时,查询条件带上 id,如:where id > 上一页最后的 id limit 10;也可以在业务允许的情况下,限制页数。

  • 索引存储顺序与 order by 不一致,如何优化?

假设存在联合索引 idx_age_name,需要查询前 10 个员工的姓名、年龄,并且按照年龄从小到大排序,如果年龄相同,则按照姓名降序排序。

select name, age from staff order by age, name desc limit 10;

查看执行计划,发现使用到了 Using filesort 文件排序。

【转】盘点 MySQL 慢查询的 12 个原因_第36张图片

这是因为,在 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='员工表';
  • 使用了 in 条件时,SQL 执行是否有排序过程?

假设存在联合索引 idx_city_name,执行如下 SQL,是不会走排序过程的。

【转】盘点 MySQL 慢查询的 12 个原因_第37张图片

但如果使用了 in 条件,并且有多个条件时,就会有排序过程。

【转】盘点 MySQL 慢查询的 12 个原因_第38张图片

这是因为 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='账户表';

【转】盘点 MySQL 慢查询的 12 个原因_第39张图片

从 explain 结果可知,先全表扫描 account,然后逐行执行子查询,判断条件是否满足。显然这个执行计划和预期不符,因为并没有走索引。

但如果把 delete 换成 select,此时就会走索引。

【转】盘点 MySQL 慢查询的 12 个原因_第40张图片

为什么 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 的方式。

【转】盘点 MySQL 慢查询的 12 个原因_第41张图片

可以发现,改用 join 的方式是可以走索引的,完美解决了这个问题。

事实上,对于 update 或者 delete 子查询的语句,MySQL 也是推荐采用 join 的方式优化。

【转】盘点 MySQL 慢查询的 12 个原因_第42张图片

此外,给表加上别名,也可以解决这个问题。

在这里插入图片描述

为什么加个别名就可以走索引了呢?

查看 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 的执行计划如下所示。

【转】盘点 MySQL 慢查询的 12 个原因_第43张图片

  • Extra 字段的 Using temporary 表示在执行分组的时候使用了临时表
  • Extra 字段的 Using filesort 表示使用了文件排序

group by 是怎么使用到临时表和排序的呢?

对应 SQL 的执行流程如下。

  1. 创建内存临时表,表里有两个字段 city 和 num
  2. 全表扫描 staff 的记录,依次取出 city = ‘xx’ 的记录
  3. 判断临时表中是否有 city = ‘xx’ 的行,如果没有就插入一个记录 (xx, 1)
  4. 如果临时表中有 city = ‘xx’ 的行,就将这一行的 num 值加 1
  5. 遍历完成后,再根据字段 city 做排序,得到结果集后返回给客户端

【转】盘点 MySQL 慢查询的 12 个原因_第44张图片

那么临时表的排序是怎样的呢?

就是把需要排序的字段,放到 sort buffer 中,排完就返回。这里需要注意,排序分为全字段排序和 rowid 排序。

  • 如果是全字段排序,需要查询返回的字段,都放入 sort buffer 中,再根据排序字段排完后,直接返回
  • 如果是 rowid 排序,只是需要排序的字段放入 sort buffer 中,然后多一次回表操作,再返回
  • 可以通过数据库参数 max_length_for_sort_data 来确定走的是全字段排序还是 rowid 排序

where 和 having 的区别?

  • group by + where 的执行流程
select city, count(*) as num from staff where age > 30 group by city;
// 加索引
alter table staff add index idx_age (age);

【转】盘点 MySQL 慢查询的 12 个原因_第45张图片

从 explain 执行计划中,可以发现查询条件命中了 idx_age 的索引,并且使用了临时表和排序。

Using index condition 表示索引下推优化,根据索引尽可能的过滤数据,然后再返回给服务器层去根据 where 的其他条件进行过滤。这里并不代表一定是使用了索引下推,只是代表可以使用,实际上不一定用了。

对应 SQL 执行流程如下。

  1. 创建内存临时表,表里有两个字段 city 和 num
  2. 扫描索引树 idx_age,找到年龄大于 30 的主键 id
  3. 通过主键 id,回表查询找到 city = ‘xx’ 的数据行
  4. 判断临时表中是否有为 city = ‘xx’ 的数据行,如果没有就插入一个记录(xx, 1)
  5. 如果临时表中有 city = ‘xx’ 的数据行,就将这一行的 num 值加 1
  6. 继续重复 2、3 步骤,找到所有满足条件的数据
  7. 最后根据字段 city 做排序,得到结果集后返回给客户端
  • group by + having 的执行流程

如果要查询每个城市的员工数量,获取员工数量不低于 3 的城市,可以用 having 解决。

【转】盘点 MySQL 慢查询的 12 个原因_第46张图片

having 称为分组过滤条件,它对返回的结果集操作。

  • 同时有 where、group by、having 的执行顺序
select city, count(*) as num from staff where age > 19 group by city having num >= 3;
  1. 执行 where 子句查找符合年龄大于 19 的员工数据
  2. group by 子句对员工数据根据城市分组
  3. 对 group by 子句形成的城市分组,运行聚合函数计算每一组的员工数量值
  4. 最后用 having 子句选出员工数量大于等于 3 的城市分组

where + having 区别总结

  • having 子句用于分组后的筛选,where 子句用于行条件的筛选
  • having 一般都是配合 group by 和聚合函数一起出现,如:count()、sum()、avg()、max()、min()
  • where 条件子句中不能使用聚合函数,而 having 子句可以
  • having 只能用在 group by 之后,而 where 执行在 group by 之前

使用 group by 需要注意的问题

  • group by 一定要配合聚合函数使用么?

group by 就是分组统计的意思,一般情况下都是配合聚合函数一起使用。

但在 MySQL 5.7 版本中,即使没有配合聚合函数使用也是可以的,不会报错,并且返回的是分组的第一行数据。

【转】盘点 MySQL 慢查询的 12 个原因_第47张图片

【转】盘点 MySQL 慢查询的 12 个原因_第48张图片

当然,平时使用的时候,group by 还是配合聚合函数使用的,除非一些特殊场景,比如想去重,当然去重用 distinct 也是可以的。

  • group by 后面跟的字段一定要出现在 select 中么?

不一定。

【转】盘点 MySQL 慢查询的 12 个原因_第49张图片

分组字段 city 不在 select 后面,但并不会报错。当然,这个可能和不同的数据库或者不同的版本有关。

  • group by 导致的慢 SQL 问题

group by 使用不当,很容易产生慢 SQL 问题。因为它既用到了临时表,又默认用到了排序,有时候还可能用到磁盘临时表。

如果在执行过程中,MySQL 发现内存临时表的大小达到了上限(由参数 tmp_table_size 控制),会把内存临时表转成磁盘临时表。如果数据量很大,很可能查询所需的磁盘临时表,会占用大量的磁盘空间。

group by 的优化方案

  • 方向1:既然它默认会排序,那我们不给它排就行了
  • 方向2:既然临时表是影响 group by 性能的重要因素,那么我们不用临时表就行了

至于为什么执行 group by 语句时需要使用临时表,是因为在 group by 的语义逻辑中,就是统计不同的值出现的个数。如果这些值从一开始就是有序的,那我们直接往下扫描统计就好了,就不需要再使用临时表来记录并统计结果了。

  • 给 group by 后面的字段加索引

在这里插入图片描述

如果加上联合索引 idx_age_city(age, city),查看对应 SQL 的执行计划,发现既不用排序,也不需要临时表。

【转】盘点 MySQL 慢查询的 12 个原因_第50张图片

可见加合适的索引是优化 group by 最简单最有效的优化方式。

  • order by null 不用排序

并不是所有的场景都适合加索引,如果碰上不适合创建索引的场景,比如需求并不需要对结果集进行排序,则可以使用 order by null。

【转】盘点 MySQL 慢查询的 12 个原因_第51张图片

  • 尽量只使用内存临时表

如果 group by 需要统计的数据量不多,可以考虑尽量只使用内存临时表。

如果内存临时表放不下数据,从而用到磁盘临时表,此时会比较耗时。因此可以适当调大 tmp_table_size 参数,以此避免用到磁盘临时表。

  • 使用 SQL_BIG_RESULT 优化

如果数据量实在太大,不可能无限制地调大 tmp_table_size,也不能眼睁睁地看着数据先放到内存临时表,随着数据的插入,发现到达上限后,再转成磁盘临时表。

因此,如果预估数据量比较大,可以使用 SQL_BIG_RESULT 这个提示,直接用磁盘临时表。MySQL 优化器发现磁盘临时表是 B+ 树存储,存储效率不如数组,因此会直接采用数组来存储。

【转】盘点 MySQL 慢查询的 12 个原因_第52张图片

从执行计划的 Extra 字段可以看到,执行没有再使用临时表,而是只有排序。

对应的执行流程如下。

  1. 初始化 sort_buffer,放入 city 字段
  2. 扫描表 staff,依次取出 city 的值,存入 sort_buffer 中
  3. 扫描完成后,对 sort_buffer 中的 city 字段做排序
  4. 排序完成后,得到一个有序数组
  5. 根据有序数组,统计每个值出现的次数

12、系统硬件或者网络资源

如果数据库服务器内存、硬件资源,或者网络资源配置不是很好,就会慢一些,这时候可以考虑升级配置。

如果数据库的压力本身很大,比如在高并发场景下,大量请求打到数据库上,数据库服务器的 CPU 占用很高或者 I/O 利用率很高,这种情况下所有语句的执行都有可能变慢。

此外,如果测试环境下数据库的一些参数配置,和生产环境下的参数配置不一致,也容易产生慢 SQL。

你可能感兴趣的:(MySQL,数据库)