有一个article表,用于存储文章的基本信息的,有文章id,做者id等一些属性,有一个content表,主要用于存储文章的内容,主键是article_id,需求须要将一些知足条件的做者发布的文章导入到另一个库。java
因此我同事就在项目中先查询出了符合条件的做者id,而后开启了多个线程,每一个线程每次取一个做者id,执行查询和导入工做。面试
查询出做者id是1111,名下的全部文章信息,文章内容相关的信息的SQL以下:算法
SELECT a.*, c.* FROM article a LEFT JOIN content c ON a.id = c.article_id WHERE a.author_id = 1111 AND a.create_time < '2020-04-29 00:00:00' LIMIT 210000,100
由于查询的这个数据库是机械硬盘的,在offset查询到20万时,查询时间已经特别长了。spring
运维同事那边直接收到报警,说这个库已经IO阻塞了,已经屡次进行主从切换了,咱们就去navicat里面试着执行了一下这个语句,也是一直在等待, 而后对数据库执行show proceesslist 命令查看了一下,发现每一个查询都是处于Writing to net的状态。数据库
没办法只能先把导入的项目暂时下线,而后执行kill命令将当前的查询都杀死进程(由于只是客户端Stop的话,MySQL服务端会继续查询)。设计模式
而后咱们开始分析这条命令执行慢的缘由:缓存
一、是不是联合索引的问题网络
当前是索引状况以下:数据结构
article表的主键是id,author_id是一个普通索引 content表的主键是article_id
因此认为当前是执行流程,是先去article表的普通索引author_id里面找到1111的全部文章id,而后根据这些文章id去article表的汇集索引中找到全部的文章,而后拿每一个文章id去content表中找文章内容等信息,而后判断create_time是否知足要求,进行过滤,最终找到offset为20000后的100条数据。框架
因此咱们就将article的author_id索引改为了联合索引(author_id,create_time),这样联合索引(author_id,create_time)中的B+树就是先安装author_id排序,再按照create_time排序,这样一开始在联合(author_id,create_time)查询出来的文章id就是知足create_time < '2020-04-29 00:00:00'条件的,后面就不用进行过滤了,就不会就是符合就不用对create_time过滤。
流程确实是这个流程,可是去查询时,若是limit仍是210000, 100时,仍是查不出数据,几分钟都没有数据,一直到navica提示超时,使用Explain看的话,确实命中索引了,若是将offset调小,调成6000, 100,勉强能够查出数据,可是须要46s,因此瓶颈不在这里。
真实缘由以下:
先看关于深分页的两个查询,id是主键,val是普通索引
二、直接查询法
select * from test where val=4 limit 300000,5;
三、先查主键再join
select * from test a inner join (select id from test where val=4 limit 300000,5) as b on a.id=b.id;
这两个查询的结果都是查询出offset是30000后的5条数据,区别在于第一个查询须要先去普通索引val中查询出300005个id,而后去汇集索引下读取300005个数据页,而后抛弃前面的300000个结果,只返回最后5个结果,过程当中会产生了大量的随机I/O。
第二个查询一开始在普通索引val下就只会读取后5个id,而后去汇集索引下读取5个数据页。
同理咱们业务中那条查询实际上是更加复杂的状况,由于咱们业务的那条SQL不只会读取article表中的210100条结果,并且会每条结果去content表中查询文章相关内容,而这张表有几个TEXT类型的字段,咱们使用show table status命令查看表相关的信息发现
发现两个表的数据量都是200多万的量级,article表的行平均长度是266,content表的平均长度是16847。
简单来讲是当 InnoDB 使用 Compact 或者 Redundant 格式存储极长的 VARCHAR 或者 BLOB 这类大对象时,咱们并不会直接将全部的内容都存放在数据页节点中,而是将行数据中的前 768 个字节存储在数据页中,后面会经过偏移量指向溢出页。
这样再从content表里面查询连续的100行数据时,读取每行数据时,还须要去读溢出页的数据,这样就须要大量随机IO,由于机械硬盘的硬件特性,随机IO会比顺序IO慢不少。因此咱们后来又进行了测试,
只是从article表里面查询limit 200000,100的数据,发现即使存在深分页的问题,查询时间只是0.5s,由于article表的平均列长度是266,全部数据都存在数据页节点中,不存在页溢出,因此都是顺序IO,因此比较快。
//查询时间0.51s SELECT a.* FROM article a WHERE a.author_id = 1111 AND a.create_time < '2020-04-29 00:00:00' LIMIT 200100, 100
相反的,咱们直接先找出100个article_id去content表里面查询数据,发现比较慢,第一次查询时须要3s左右(也就是这些id的文章内容相关的信息都没有过,没有缓存的状况),第二次查询时由于这些溢出页数据已经加载到buffer pool,因此大概0.04s。
SELECT SQL_NO_CACHE c.* FROM article_content c WHERE c.article_id in(100个article_id)
四、解决方案
因此针对这个问题的解决方案主要有两种:
(1)先查出主键id再inner join
非连续查询的状况下,也就是咱们在查第100页的数据时,不必定查了第99页,也就是容许跳页查询的状况。
那么就是使用先查主键再join这种方法对咱们的业务SQL进行改写成下面这样,下查询出210000, 100时主键id,做为临时表temp_table,将article表与temp_table表进行inner join,查询出中文章相关的信息,而且去left Join content表查询文章内容相关的信息。
第一次查询大概1.11s,后面每次查询大概0.15s
SELECT a.*, c.* FROM article a INNER JOIN( SELECT id FROM article a WHERE a.author_id = 1111 AND a.create_time < '2020-04-29 00:00:00' LIMIT 210000 , 100 ) as temp_table ON a.id = temp_table.id LEFT JOIN content c ON a.id = c.article_id
优化结果
优化前,offset达到20万的量级时,查询时间过长,一直到超时。
优化后,offset达到20万的量级时,查询时间为1.11s。
(2)利用范围查询条件来限制取出的数据
这种方法的大体思路以下,假设要查询test_table中offset为10000的后100条数据,假设咱们事先已知第10000条数据的id,值为min_id_value。
select * from test_table where id > min_id_value order by id limit 0, 100
,就是即利用条件id > min_id_value在扫描索引是跳过10000条记录,而后取100条数据便可,这种处理方式的offset值便成为0了。
但此种方式有限制,必须知道offset对应id,而后做为min_id_value,增长id > min_id_value的条件来进行过滤,若是是用于分页查找的话,也就是必须知道上一页的最大的id,因此只能一页一页得查,不能跳页,可是由于咱们的业务需求就是每次100条数据,进行分批导数据,因此咱们这种场景是可使用。
针对这种方法,咱们的业务SQL改写以下:
//先查出最大和最小的id SELECT min(a.id) as min_id , max(a.id) as max_id FROM article a WHERE a.author_id = 1111 AND a.create_time < '2020-04-29 00:00:00' //而后每次循环查找 while(min_id<max_id) { SELECT a.*, c.* FROM article a LEFT JOIN content c ON a.id = c.article_id WHERE a.author_id = 1111 AND a.id > min_id LIMIT 100 //这100条数据导入完毕后,将100条数据数据中最大的id赋值给min_id,以便导入下100条数据 }
优化结果
优化前,offset达到20万的量级时,查询时间过长,一直到超时。
优化后,offset达到20万的量级时,因为知道第20万条数据的id,查询时间为0.34s。
联合索引其实有两个做用:
一、充分利用where条件,缩小范围
例如咱们须要查询如下语句:
SELECT * FROM test WHERE a = 1 AND b = 2
若是对字段a创建单列索引,对b创建单列索引,那么在查询时,只能选择走索引a,查询全部a=1的主键id,而后进行回表,在回表的过程当中,在汇集索引中读取每一行数据,而后过滤出b = 2结果集,或者走索引b,也是这样的过程。
若是对a,b创建了联合索引(a,b),那么在查询时,直接在联合索引中先查到a=1的节点,而后根据b=2继续往下查,查出符合条件的结果集,进行回表。
二、避免回表(此时也叫覆盖索引)
这种状况就是假如咱们只查询某几个经常使用字段,例如查询a和b以下:
SELECT a,b FROM test WHERE a = 1 AND b = 2
对字段a创建单列索引,对b创建单列索引就须要像上面所说的,查到符合条件的主键id集合后须要去汇集索引下回表查询,可是若是咱们要查询的字段自己在联合索引中就都包含了,那么就不用回表了。
三、减小须要回表的数据的行数
这种状况就是假如咱们须要查询a>1而且b=2的数据
SELECT * FROM test WHERE a > 1 AND b = 2
若是创建的是单列索引a,那么在查询时会在单列索引a中把a>1的主键id所有查找出来而后进行回表。
若是创建的是联合索引(a,b),基于最左前缀匹配原则,由于a的查询条件是一个范围查找(=或者in以外的查询条件都是范围查找),这样虽然在联合索引中查询时只能命中索引a的部分,b的部分命中不了,只能根据a>1进行查询,可是因为联合索引中每一个叶子节点包含b的信息,在查询出全部a>1的主键id时,也会对b=2进行筛选,这样须要回表的主键id就只有a>1而且b=2这部分了,因此回表的数据量会变小。
咱们业务中碰到的就是第3种状况,咱们的业务SQL原本更加复杂,还会join其余表,可是因为优化的瓶颈在于创建联合索引,因此进行了一些简化,下面是简化后的SQL:
SELECT a.id as article_id , a.title as title , a.author_id as author_id from article a where a.create_time between '2020-03-29 03:00:00.003' and '2020-04-29 03:00:00.003' and a.status = 1
咱们的需求其实就是从article表中查询出最近一个月,status为1的文章,咱们原本就是针对create_time建了单列索引,结果在慢查询日志中发现了这条语句,查询时间须要0.91s左右,因此开始尝试着进行优化。
为了便于测试,咱们在表中分别对create_time创建了单列索引create_time,对(create_time,status)创建联合索引idx_createTime_status。
强制使用idx_createTime进行查询
SELECT a.id as article_id , a.title as title , a.author_id as author_id from article a FORCE INDEX(idx_createTime) where a.create_time between '2020-03-22 03:00:00.003' and '2020-04-22 03:00:00.003' and a.status = 1
强制使用idx_createTime_status进行查询(即便不强制也是会选择这个索引)
SELECT a.id as article_id , a.title as title , a.author_id as author_id from article a FORCE INDEX(idx_createTime_status) where a.create_time between '2020-03-22 03:00:00.003' and '2020-04-22 03:00:00.003' and a.status = 1
优化结果:
优化前使用idx_createTime单列索引,查询时间为0.91s
优化前使用idx_createTime_status联合索引,查询时间为0.21s
EXPLAIN的结果以下:
idtypekeykey_lenrowsfilteredExtra1rangeidx_createTime431160825.00Using index condition; Using where2rangeidx_createTime_status6310812100.00Using index condition
四、原理分析
先介绍一下EXPLAIN中Extra列的各类取值的含义
Using filesort
当Query 中包含 ORDER BY 操做,并且没法利用索引完成排序操做的时候,MySQL Query Optimizer 不得不选择相应的排序算法来实现。数据较少时从内存排序,不然从磁盘排序。Explain不会显示的告诉客户端用哪一种排序。
Using index
仅使用索引树中的信息从表中检索列信息,而不须要进行附加搜索来读取实际行(使用二级覆盖索引便可获取数据)。当查询仅使用做为单个索引的一部分的列时,可使用此策略。
Using temporary
要解决查询,MySQL须要建立一个临时表来保存结果。若是查询包含不一样列的GROUP BY和ORDER BY子句,则一般会发生这种状况。
官方解释:”为了解决查询,MySQL须要建立一个临时表来容纳结果。典型状况如查询包含能够按不一样状况列出列的GROUP BY和ORDER BY子句时。很明显就是经过where条件一次性检索出来的结果集太大了,内存放不下了,只能经过加临时表来辅助处理。
Using where
表示当where过滤条件中的字段无索引时,MySQL Sever层接收到存储引擎(例如innodb)的结果集后,根据where条件中的条件进行过滤。
Using index condition
Using index condition 会先条件过滤索引,过滤完索引后找到全部符合索引条件的数据行,随后用 WHERE 子句中的其余条件去过滤这些数据行;
咱们的实际案例中,其实就是走单个索引idx_createTime时,只能从索引中查出 知足a.create_time between '2020-03-22 03:00:00.003' and '2020-04-22 03:00:00.003'条件的主键id,而后进行回表,由于idx_createTime索引中没有status的信息,只能回表后查出全部的主键id对应的行。
而后innodb将结果集返回给MySQL Sever,MySQL Sever根据status字段进行过滤,筛选出status为1的字段,因此第一个查询的Explain结果中的Extra才会显示Using where。
filtered字段表示存储引擎返回的数据在server层过滤后,剩下多少知足查询的记录数量的比例,这个是预估值,由于status取值是null,1,2,3,4,因此这里给的25%。
因此第二个查询与第一个查询的区别主要在于一开始去idx_createTime_status查到的结果集就是知足status是1的id,因此去汇集索引下进行回表查询时,扫描的行数会少不少(大概是2.7万行与15万行的区别)。
以后innodb返回给MySQL Server的数据就是知足条件status是1的结果集(2.7万行),不用再进行筛选了,因此第二个查询才会快这么多,时间是优化前的23%。
两种查询方式的EXPLAIN预估扫描行数都是30万行左右是由于idx_createTime_status只命中了createTime,由于createTime不是查单个值,查的是范围。
//查询结果行数是15万行左右 SELECT count(*) from article a where a.post_time between '2020-03-22 03:00:00.003' and '2020-04-22 03:00:00.003' //查询结果行数是2万6行左右 SELECT count(*) from article a where a.post_time between '2020-03-22 03:00:00.003' and '2020-04-22 03:00:00.003' and a.audit_status = 1
发散思考:若是将联合索引(createTime,status)改为(status,createTime)会怎么样?
where a.create_time between '2020-03-22 03:00:00.003' and '2020-04-22 03:00:00.003' and a.status = 1
根据最左匹配的原则,由于咱们的where查询条件是这样,若是是(createTime,status)那么索引就只能用到createTime;
若是是(status,createTime),由于status是查询单个值,因此status,createTime均可以命中,在(status,createTime)索引中扫描行数会减小;
可是因为(createTime,status)这个索引自己值包含createTime,status,id三个字段的信息,数据量比较小,而一个数据页是16k,能够存储1000个以上的索引数据节点,并且是查询到createTime后,进行的顺序IO,因此读取比较快,总得的查询时间二者基本是一致。
下面是测试结果:
首先建立了(status,createTime)名叫idx_status_createTime,
SELECT a.id as article_id , a.title as title , a.author_id as author_id from article a FORCE INDEX(idx_status_createTime) where a.create_time between '2020-03-22 03:00:00.003' and '2020-04-22 03:00:00.003' and a.status = 1
查询时间是0.21,跟第二种方式(createTime,status)索引的查询时间基本一致。
Explain结果对比:
扫描行数确实会少一些,由于在idx_status_createTime的索引中,一开始根据status = 1排除掉了status取值为其余值的状况。
文源网络,仅供学习之用,若有侵权,联系删除。我将面试题和答案都整理成了PDF文档,还有一套学习资料,涵盖Java虚拟机、spring框架、Java线程、数据结构、设计模式等等,但不只限于此。
关注公众号【java圈子】获取资料,还有优质文章每日送达。