文章目录
- 一.SQL没加索引
- 二. SQL 索引不生效
- 2.1 隐式的类型转换,索引失效
- 2.2 查询条件包含or,可能导致索引失效
- 2.3 like通配符可能导致索引失效
- 2.4 查询条件不满足联合索引的最左匹配原则
- 2.5 在索引列上使用mysql的内置函数
- 2.6 对索引进行列运算(如,+、-、*、/)
- 2.7 索引字段上使用(!= 或者 < >),索引可能失效
- 三. limit深分页问题
- 3.1 limit深分页为什么会变慢
- 3.2 如何优化深分页问题
- 3.2.1 标签记录法
- 3.2.2 延迟关联法
- 四. 单表数据量太大
- 4.1 单表数据量太大为什么会变慢?
- 4.2 一棵B+树可以存多少数据量
- 4.3 如何解决单表数据量太大,查询变慢的问题
- 五. join 或者子查询过多
- 六. in元素过多
- 七. 数据库在刷脏页
- 7.1 什么是脏页
- 7.2 一条更新语句是如何执行的?
- 7.3 为什么会出现脏页呢?
- 7.4 什么时候会刷脏页(flush)?
- 7.5 为什么刷脏页会导致SQL变慢呢?
- 八. order by 文件排序
- 8.1 order by文件排序效率为什么较低
- 8.1.1 rowid排序
- 8.1.2 全字段排序
- 8.2 如何优化order by的文件排序
- 九. 拿不到锁
声明:以下所做测试基于MySQL8
一.SQL没加索引
很多时候,我们的慢查询,都是因为没有加索引。如果没有加索引的话,会导致全表扫描的。因此,应考虑在where的条件列,建立索引,尽量避免全表扫描
。
二. SQL 索引不生效
2.1 隐式的类型转换,索引失效
我们创建一个用户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;
解析执行计划:
explain select * from user where userId = 123;
结果为:
这是因为,userId字段为字符串类型,查询条件传了一个数字123
过去,会导致索引失效。如果给数字加上''
,也就是说,传的是一个字符串呢
explain select * from user where userId = '123';
当然是走索引,如下图:
为什么第一条语句未加单引号就不走索引了呢?这是因为不加单引号时,是字符串跟数字的比较,它们类型不匹配,MySQL会做隐式的类型转换,把它们转换为浮点数再做比较。隐式的类型转换,索引会失效。
2.2 查询条件包含or,可能导致索引失效
仍然使用2.1中的表结构,其中userId
加了索引,但是age
没有加索引的。我们使用了or,以下SQL是不走索引的,如下:
explain select * from user where userId = '123' or age =12;
对于 or 没有索引的age这种情况,假设它走了userId的索引,但是走到age查询条件时,它还得全表扫描,也就是需要三步过程:全表扫描+索引扫描+合并
。如果它一开始就走全表扫描,直接一遍扫描就完事。Mysql优化器出于效率与成本考虑,遇到or条件,让索引失效,看起来也合情合理嘛。
如果or条件的列都加了索引,索引可能会走也可能不走,大家可以自己试一试哈。但是平时大家使用的时候,还是要注意一下这个or,学会用explain分析。遇到不走索引的时候,
考虑拆开两条SQL
。
2.3 like通配符可能导致索引失效
并不是用了like通配符,索引一定会失效,而是like查询是以%开头,才会导致索引失效
:
explain select * from user where userId like '%123';
把%放后面,发现索引还是正常走的,如下:
explain select * from user where userId like '123%';
2.4 查询条件不满足联合索引的最左匹配原则
MySQl建立联合索引时,会遵循最左前缀匹配的原则,即最左优先。如果你建立一个(a,b,c)
的联合索引,相当于建立了(a)、(a,b)、(a,b,c)
三个索引。因为这个太经典了,这里不加详细说明。
2.5 在索引列上使用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()
,索引失效,如下:
explain select * from user where date_add(login_time,INTERVAL 1 DAY) = '2024-05-19 12:12:12';
一般这种情况怎么优化呢?可以把内置函数的逻辑转移到右边
,如下:
explain select * from user where login_time = date_add('2024-05-19 12:12:12',interval -1 DAY);
2.6 对索引进行列运算(如,+、-、*、/)
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加了索引,但是因为age列进行了运算,索引失效,如下:
explain select * from user where age - 1 = 10;
所以不可以对索引列进行运算,可以
在代码中处理好参数值,再执行SQL
。
2.7 索引字段上使用(!= 或者 < >),索引可能失效
其实这个也是跟MySQL优化器有关,如果优化器觉得即使走了索引,还是需要扫描很多很多行的哈,它觉得不划算,不如直接不走索引。平时我们用!= 或者< >,not in
的时候,留点心眼哈。
剩下导致索引失效的情况还有:索引字段上使用is not null
、关联查询表字符集不一致
等,可以参考
MySQL进阶_6.查询优化和索引优化和MySQL实战问题总结。
三. limit深分页问题
3.1 limit深分页为什么会变慢
新建一张表结构如下:
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> '2024-05-19' limit 100000,10;
这个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 扫描更多的行数,也
意味着回表更多的次数
。
3.2 如何优化深分页问题
3.2.1 标签记录法
就是标记一下上次查询到哪一条了,下次再来查的时候,从该条开始往下扫描。就好像看书一样,上次看到哪里了,你就折叠一下或者夹个书签,下次来看的时候,直接就翻到啦。
假设上一次记录到100000,则SQL可以修改为:
select id,name,balance FROM account where id > 100000 limit 10;
这样的话,后面无论翻多少页,性能都会不错的,因为命中了id索引。但是这种方式有局限性:需要一种类似连续自增的字段
。
3.2.2 延迟关联法
延迟关联法,就是把条件转移到主键索引树,然后减少回表
。如下:
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.1 单表数据量太大为什么会变慢?
一个表的数据量达到好几千万或者上亿时,加索引的效果没那么明显啦。性能之所以会变差,是因为维护索引的B+树结构层级变得更高了
,查询一条数据时,需要经历的磁盘IO变多
,因此查询性能变慢。
4.2 一棵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+树想存储更多的数据,那树结构层级就会更高,查询一条数据时,需要经历的磁盘IO变多,因此查询性能变慢
。
4.3 如何解决单表数据量太大,查询变慢的问题
一般超过千万级别,我们可以考虑分库分表
了。分库分表可能导致的问题:
- 事务问题
- 跨库问题
- 排序问题
- 分页问题
- 分布式ID
因此,大家在评估是否分库分表前,先考虑下,是否可以先把部分历史数据归档,如果可以的话,先不要急着分库分表。如果真的要分库分表,综合考虑和评估方案。比如可以考虑垂直、水平分库分表。水平分库分表策略的话,range范围、hash取模、range+hash取模混合
等等。
五. join 或者子查询过多
一般来说不建议使用子查询
,可以把子查询改成join来优化。而数据库有个规范约定就是:尽量不要有超过3个以上的表连接
。
MySQL中,join的执行算法,分别是:Index Nested-Loop Join
和Block Nested-Loop Join
。
Index Nested-Loop Join
:这个join算法,跟我们写程序时的嵌套查询类似,并且可以用上被驱动表的索引。Block Nested-Loop Join
:这种join算法,被驱动表上没有可用的索引,它会先把驱动表的数据读入线程内存join_buffer中,再扫描被驱动表,把被驱动表的每一行取出来,跟join_buffer中的数据做对比,满足join条件的,作为结果集的一部分返回。
join过多的问题:
一方面,过多的表连接,会大大增加SQL复杂度。另外一方面,如果可以使用被驱动表的索引那还好,并且使用小表来做驱动表,查询效率更佳。如果被驱动表没有可用的索引,join是在join_buffer内存做的,如果匹配的数据量比较小或者join_buffer设置的比较大,速度也不会太慢。但是,如果join的数据量比较大时,mysql会采用在硬盘上创建临时表的方式进行多张表的关联匹配,这种显然效率就极低,本来磁盘的 IO 就不快,还要关联。
一般情况下,如果业务需要的话,关联2~3
个表是可以接受的,但是关联的字段需要加索引
哈。如果需要关联更多的表,建议从代码层面进行拆分,在业务层先查询一张表的数据,然后以关联字段作为条件查询关联表形成map,然后在业务层进行数据的拼装。
六. in元素过多
如果使用了in,即使后面的条件加了索引,还是要注意in后面的元素不要过多哈。in元素一般建议不要超过500个
,如果超过了,建议分组,每次500一组进行哈。
反例:
select user_id,name from user where user_id in (1,2,3...1000000);
如果我们对in的条件不做任何限制的话,该查询语句一次性可能会查询出非常多的数据,很容易导致接口超时。尤其有时候,我们是用的子查询,in后面的子查询,你都不知道数量有多少那种,更容易采坑(所以我把in元素过多抽出来作为一个小节)。如下这种子查询:
select * from user where user_id in (select author_id from artilce where type = 1);
正例是,分批进行,每批500个:
select user_id,name from user where user_id in (1,2,3...500);
七. 数据库在刷脏页
7.1 什么是脏页
当内存数据页跟磁盘数据页内容不一致
的时候,我们称这个内存页为“脏页
”。内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为“干净页”。一般有更新SQL才可能会导致脏页,我们回忆一下,一条更新语句是如何执行的。
7.2 一条更新语句是如何执行的?
update t set c=c+1 where id=666;
- 对于这条更新SQL,执行器会先找引擎取id=666这一行。如果这行所在的数据页本来就在内存中的话,就直接返回给执行器。如果不在内存,就去磁盘读入内存,再返回。
- 执行器拿到引擎给的行数据后,给这一行C的值加一,得到新的一行数据,再调用引擎接口写入这行新数据。
- 引擎将这行新数据更新到内存中,同时将这个更新操作记录到
redo log
里面,但是此时redo log 是处于prepare
状态的哈。 - 执行器生成这个操作的
binlog
,并把binlog写入磁盘。 - 执行器调用引擎的提交事务接口,引擎把刚刚写入的redo log改成提交(
commit
)状态,更新完成。
InnoDB 在处理更新语句的时候,只做了写日志这一个磁盘操作。这个日志叫作redo log(重做日志)。平时更新SQL执行得很快,其实是因为它只是在写内存和redo log日志,等到空闲的时候,才把redo log日志里的数据同步到磁盘中
。
有些小伙伴可能有疑惑,redo log日志不是在磁盘嘛?那为什么不慢?其实是因为写redo log的过程是
顺序写
磁盘的。磁盘顺序写会减少寻道等待时间,速度比随机写要快很多的。
7.3 为什么会出现脏页呢?
更新SQL只是在写内存和redo log日志,等到空闲的时候,才把redo log日志里的数据同步到磁盘中。这时内存数据页跟磁盘数据页内容不一致,就出现脏页。
7.4 什么时候会刷脏页(flush)?
InnoDB存储引擎的redo log大小是固定,且是环型写入
的,如下图:
那什么时候会刷脏页?有几种场景:
redo log
写满了,要刷脏页。这种情况要尽量避免的。因为出现这种情况时,整个系统就不能再接受更新啦,即所有的更新都必须堵住。- 内存不够了,需要新的内存页,就要淘汰一些数据页,这时候会刷脏页。
InnoDB 用缓冲池(buffer pool)管理内存,而当要读入的数据页没有在内存的时候,就必须到缓冲池中申请一个数据页。这时候只能把最久不使用的数据页从内存中淘汰掉:如果要淘汰的是一个干净页,就直接释放出来复用;但如果是脏页呢,就必须将脏页先刷到磁盘,变成干净页后才能复用。
- MySQL 认为
系统空闲
的时候,也会刷一些脏页。 - MySQL 正常关闭时,会把内存的脏页都 flush 到磁盘上
7.5 为什么刷脏页会导致SQL变慢呢?
redo log
写满了,要刷脏页,这时候会导致系统所有的更新堵住,写性能都跌为0了,肯定慢呀。一般要杜绝出现这个情况。- 一个查询要淘汰的脏页个数太多,一样会导致查询的响应时间明显变长。
八. order by 文件排序
order by
就一定会导致慢查询吗?不是这样的哈,因为order by平时用得多,并且数据量一上来,还是走文件排序的话,很容易有慢SQL的。
8.1 order by文件排序效率为什么较低
order by排序,分为全字段排序
和rowid排序
。它是拿max_length_for_sort_data
和结果行数据长度对比,如果结果行数据长度超过max_length_for_sort_data这个值,就会走rowid排序,相反,则走全字段排序。
8.1.1 rowid排序
rowid排序,一般需要回表去找满足条件的数据,所以效率会慢一点。以下这个SQL,使用rowid排序,执行过程是这样:
select name,age,city from staff where city = '深圳' order by age limit 10;
- MySQL 为对应的线程初始化
sort_buffer
,放入需要排序的age字段
,以及主键id
; - 从索引树
idx_city
, 找到第一个满足city='深圳’
条件的主键id,也就是图中的id=9; - 到
主键id索引树
拿到id=9的这一行数据, 取age和主键id
的值,存到sort_buffer; - 从索引树idx_city拿到下一个记录的主键id,即图中的id=13;
- 重复步骤 3、4 直到city的值不等于深圳为止;
- 前面5步已经查找到了所有city为深圳的数据,在sort_buffer中,将所有数据根据age进行排序;
- 遍历排序结果,取前10行,并
按照id的值回到原表
中,取出city、name 和 age三个字段返回给客户端。
8.1.2 全字段排序
同样的SQL,如果是走全字段排序是这样的:
- MySQL 为对应的线程初始化sort_buffer,放入需要查询的
name、age、city
字段; - 从索引树idx_city, 找到第一个满足 city='深圳’条件的主键 id,也就是图中的id=9;
- 到主键id索引树拿到id=9的这一行数据, 取name、age、city三个字段的值,存到sort_buffer;
- 从索引树idx_city 拿到下一个记录的主键id,即图中的id=13;
- 重复步骤 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中的数据,放到临时磁盘文件,等到所有满足条件数据都查完排完,再用归并算法把磁盘的临时排好序的小文件,合并成一个有序的大文件。
8.2 如何优化order by的文件排序
order by使用文件排序,效率会低一点。我们怎么优化呢?
- 因为数据是无序的,所以就需要排序。如果数据本身是有序的,那就不会再用到文件排序啦。而索引数据本身是有序的,我们通过建立索引来优化order by语句。
- 我们还可以通过调整max_length_for_sort_data、sort_buffer_size等参数优化;
- 可以参考看一遍就理解:order by详解
九. 拿不到锁
引用文章:
1.盘点MySQL慢查询的12个原因