1. 案例实战-千万级用户场景下的sql调优
1) 业务背景
千万级用户,日活百万。
通过一些条件筛选出大量的用户,接着针对这些用户做一些推送,是互联网公司的运营系统里常见的一种功能,在这个过程中,比较坑爹,也比较耗时的,其实是筛选用户的这个过程。
SELECT id, name FROM users WHERE id IN (SELECT user_id FROM users_extent_infoWHERE latest_login_time < xxxxx)
意思就是说一般存储用户数据的表会分为两张表,一个表用来存储用户的核心数据,比如id、name、昵称、手机号之类的信息,也就是上面SQL语句里的users表
另外一个表可能会存储用户的一些拓展信息,比如说家庭住址、兴趣爱好、最近一次登录时间之类的,就是上面的users_extent_info表
2) explain
+----+-------------+-------+------------+-------+---------------+----------+---------+------+| id | select_type | table | type | key | rows | filtered | Extra |+----+-------------+-------+------------+-------+---------------+----------+---------+------+| 1 | SIMPLE | <subquery2> | ALL | NULL | NULL | 100.00 | NULL || 1 | SIMPLE | users | ALL | NULL | 49651 | 10.00 | Using where; Using join buffer(Block Nested Loop) || 2 | MATERIALIZED | users_extent_info | range | idx_login_time | 4561 | 100.00 | NULL |+----+-------------+-------+------------+-------+---------------+----------+---------+------+
针对子查询,是执行计划里的第三行实现的,他清晰的表明,针对users_extent_info,使用了idx_login_time这个索引,做了range类型的索引范围扫描,查出来了4561条数据,没有做其他的额外筛选,所以filtered是100%。
他这里的MATERIALIZED,表明了这里把子查询的4561条数据代表的结果集进行了物化,物化成了一个临时表,这个临时表物化,一定是会把4561条数据临时落到磁盘文件里去的,这个过程其实就挺慢的。
然后第二条执行计划表明,接着就是针对users表做了一个全表扫描,在全表扫描的时候扫出来了49651条数据,同时大家注意看Extra字段,显示了一个Using join buffer的信息,这个明确表示,此处居然在执行join操作???
接着看执行计划里的第一条,这里他是针对子查询产出的一个物化临时表,也就是
原因就是在让users表的每一条数据,都要去跟物化临时表里的数据进行join,所以针对users表里的每一条数据,只能是去全表扫描一遍物化临时表,找找物化临时表里哪条数据是跟他匹配的,才能筛选出来一条结果。
第二条执行计划的全表扫描的结果表明是一共扫到了49651条数据,但是全表扫描的过程中,因为去跟物化临时表执行了一个join操作,而物化临时表里就4561条数据,所以最终第二条执行计划的filtered显示的是10%,也就是说,最终从users表里筛选出了也是4000多条数据。
3) 解决方案
A. 慢的原因
这里为什么会跑的这么慢呢?
对子查询的结果做了一次物化表,落地磁盘了
全表扫描了users表的所有数据
每一条数据居然跑到一个没有索引的物化临时表里再做一次全表扫描找匹配数据。
在这个过程里,对users表的全表扫描耗时不耗时?
对users表的每一条数据跑到物化临时表里做全表扫描,耗时不耗时?
所以这个过程必然是非常慢的,几乎就没怎么用到索引。
B. show warnings命令
一个技巧,就是在执行完上述SQL的EXPLAIN命令,看到执行计划之后,可以执行一下show warnings命令。
这个show warnings命令此时显示出来的内容如下:
/ select#1 / select count(d2.users.user_id`) ASCOUNT(users.user_id)<br />fromd2.users`users semi join xxxxxx,下面省略一大段内容,因为可读性实在不高,大家关注的应该是这里的semi join这个关键字
这里就显而易见了!MySQL在这里,生成执行计划的时候,自动就把一个普通的IN子句,“优化”成了基于semi join来进行IN+子查询的操作,这个semi join是什么意思呢?
-- 构造半连接查询EXPLAIN select count(1) from sys_announcement_send WHERE user_id in (SELECT user_id from sys_user_role where id > '1387003029116174337')-- user_id 不是索引EXPLAIN select count(1) from sys_announcement_send WHERE ( user_id in (SELECT user_id from sys_user_role where id > '1387003029116174337') or user_id<-1)
简单来说,对users表不是全表扫描了么?对users表里每一条数据,去对物化临时表全表扫描做semi join,不需要把users表里的数据真的跟物化临时表里的数据join上。只要users表里的一条数据,在物化临时表里可以找到匹配的数据,那么users表里的数据就会返回,这就叫做semi join,他是用来筛选的。
所以慢,也就慢在这里了,那既然知道了是semi join和物化临时表导致的问题,应该如何优化呢?
C. 关闭掉半连接优化
执行SET optimizer_switch=’semijoin=off’,也就是关闭掉半连接优化,此时执行EXPLAIN命令看一下此时的执行计划,发现此时会恢复为一个正常的状态。
就是有一个SUBQUERY的子查询,基于range方式去扫描索引搜索出4561条数据,接着有一个PRIMARY类型的主查询,直接是基于id这个PRIMARY主键聚簇索引去执行的搜索,然后再把这个SQL语句真实跑一下看看,发现性能一下子提升了几十倍,变成了100多毫秒!
D. 解决方案
因此到此为止,这个SQL的性能问题,真相大白,其实反而是他自动执行的semi join半连接优化,给咱们导致了问题,一旦禁止掉semi join自动优化,用正常的方式让他基于索引去执行,性能那是嗖嗖的。
当然,在生产环境是不能随意更改这些设置的,所以后来我们想了一个办法,多种办法尝试去修改SQL语句的写法,在不影响他语义的情况下,尽可能的去改变SQL语句的结构和格式,最终被我们尝试出了一个写法,如下所示:
SELECT COUNT(id)FROM usersWHERE ( id IN (SELECT user_id FROM users_extent_info WHERE latest_login_time < xxxxx)OR id IN (SELECT user_id FROM users_extent_info WHERE latest_login_time < -1))
在上述写法下,WHERE语句的OR后面的第二个条件,根本是不可能成立的,因为没有数据的latest_login_time是小于-1的,所以那是不会影响SQL语义的,但是我们发现改变了SQL的写法之后,执行计划也随之改变。
他并没有再进行semi join优化了,而是正常的用了子查询,主查询也是基于索引去执行的,这样我们在线上上线了这个SQL语句,性能从几十秒一下子就变成几百毫秒了。
认真体会这个SQL调优案例里的方法,其实最核心的,还是看懂SQL的执行计划,然后去分析到底他为什么会那么慢,接着你就是要想办法避免他全表扫描之类的操作,一定要让他去用索引,用索引是王道,是最重要的!
2. 实战案例-亿级数量商品场景下的sql调优
1) 业务背景
某一天晚上,我们突然收到了线上数据库的频繁报警,这个报警的意思大致就是说,数据库突然涌现出了大量的慢查询,而且因为大量的慢查询,导致每一个数据库连接执行一个慢查询都要耗费很久。
那这样的话,必然会导致突然过来的很多查询需要让数据库开辟出来更多的连接,因此这个时候报警也告诉我们,数据库的连接突然也暴增了,而且每个连接都打满,每个连接都要执行一个慢查询,慢查询还跑的特别慢。
接着引发的问题,就是数据库的连接全部打满,没法开辟新的连接了,但是还持续的有新的查询发送过来,导致数据库没法处理新的查询,很多查询发到数据库直接就阻塞然后超时了,这也直接导致线上的商品系统频繁的报警,出现了大量的数据库查询超时报错的异常!
当时看到这一幕报警,让人是非常揪心的,因为这种情况,基本意味着你的商品数据库以及商品系统濒临于崩溃了,大量慢查询耗尽了数据库的连接资源,而且一直阻塞在数据库里执行,数据库没法执行新的查询,商品数据库没法执行查询,用户没法使用商品系统,也就没法查询和筛选电商网站里的商品了!
而且大家要知道,当时正好是晚上晚高峰的时候!也就是一个电商网站比较繁忙的时候,虽说商品数据是有多级缓存架构的,但是实际上在下单等过程中,还是会大量的请求商品系统的,所以晚高峰的时候,商品系统本身TPS大致是在每秒几千的。
因此这个时候,发现数据库的监控里显示,每分钟的慢查询超过了10w+!!!也就是说商品系统大量的查询都变成了慢查询!!!
那么慢查询的都是一些什么语句呢?其实主要就是下面这条语句,大家可以看一下,我们做了一个简化:
select * from products where category='xx' and sub_category='xx' order by id desc limit xx,xx
这其实是一个很稀松平常的SQL语句,他就是用户在电商网站上根据商品的品类以及子类在进行筛选,当然真实的SQL语句里,可能还包含其他的一些字段的筛选,比如什么品牌以及销售属性之类的,我们这里是做了一个简化,然后按id倒序排序,最后是分页,就这么一个语句。
这个语句执行的商品表里大致是1亿左右的数据量,这个量级已经稳定了很长时间了,主要也就是这么多商品,但是上面的那个语句居然一执行就是几十秒!
几十秒,这还得了?基本上数据库的连接全部被慢查询打满,一个连接要执行几十秒的SQL,然后才能执行下一个SQL,此时数据库基本就废了,没法执行什么查询了!!!
所以难怪商品系统本身也大量的报警说查询数据库超时异常了!
2) explain分析
KEY index_category(catetory,sub_category)肯定是存在的,所以基本可以确认上面的SQL绝对是可以用上索引的。
possible_keys里是有我们的index_category的,结果实际用的key不是这个索引,而是PRIMARY!!而且Extra里清晰写了Using where
本质上就是在主键的聚簇索引上进行扫描,一边扫描,一边还用了where条件里的两个字段去进行筛选,所以这么扫描的话,那必然就是会耗费几十秒了!
因此此时为了快速解决这个问题,就需要强制性的改变MySQL自动选择这个不合适的聚簇索引进行扫描的行为
那么怎么改变呢?交给大家一个办法,就是使用force index语法,如下:
select * from products force index(index_category)where category='xx' and sub_category='xx' order by id desc limit xx,xx
使用上述语法过后,强制让SQL语句使用了你指定的索引,此时再次执行这个SQL语句,会发现他仅仅耗费100多毫秒而已!性能瞬间就提升上来了!
因此当时在紧急关头中,一下子就把这个问题给解决了,这里也是告诉大家这样的一个实战技巧,就是你如何去强制改变MySQL的执行计划,之前就有一个朋友来问我们说,面试官问我,如果MySQL使用了错误的执行计划,应该怎么办?
其实答案很简单,就是这个案例里的情况,方法就是force index语法就可以了。
3) 解决方案
问题1:
为什么在这个案例中MySQL默认会选择对主键的聚簇索引进行扫描?
为什么没使用index_category这个二级索引进行扫描?
答案:
mysql优化的时候认为,从索引查出来的数据太多,还要导入磁盘去排序,limit 10,再回表,性能会比较差。
因此它就优化了一下,对id倒序扫描,where匹配,limit10。
问题2:
即使用了聚簇索引,为什么这个SQL以前没有问题,现在突然就有问题了?
答案:
以前全表扫描很快就limit10满了10条数据,现状这个where条件,找不到10条数据,一直在上亿级别的数据里面全表扫描
这个表是一个亿级数据量的大表,那么对于他来说,index_category这个二级索引也是比较大的
所以此时对于MySQL来说,他有这么一个判断,他觉得如果要是从index_category二级索引里来查找到符合where条件的一波数据,接着还得回表,回到聚簇索引里去。
因为SQL语句是要select *的,所以这里必然涉及到一次回表操作,回到聚簇索引里去把所有字段的数据都查出来,但是在回表之前,他必然要做完order by id desc limit xx,xx这个操作
举个例子吧,比如他根据where category=’xx’ and sub_category=’xx’,从index_category二级索引里查找出了一大堆数据。
比如从二级索引里假设搂出来了几万条数据,接着因为二级索引里是包含主键id值的,所以此时他就得按照order by id desc这个排序语法,对这几万条数据基于临时磁盘文件进行filesort磁盘排序,排序完了之后,再按照limit xx,xx语法,把指定位置的几条数据拿出来,假设就是limit 0,10,那么就是把10条数据拿出来。
拿出来10条数据之后,再回到聚簇索引里去根据id查找,把这10条数据的完整字段都查出来,这就是MySQL认为如果你使用index_category的话,可能会发生的一个情况。
所以他担心的是,你根据where category=’xx’ and sub_category=’xx’,从index_category二级索引里查出来的数据太多了,还得在临时磁盘里排序,可能性能会很差,因此MySQL就把这种方式判定为一种不太好的方式。
因此他才会选择换一种方式,也就是说,直接扫描主键的聚簇索引,因为聚簇索引都是按照id值有序的,所以扫描的时候,直接按order by id desc这个倒序顺序扫描过去就可以了,然后因为他知道你是limit 0,10的,也就知道你仅仅只要拿到10条数据就行了。
所以他在按顺序扫描聚簇索引的时候,就会对每一条数据都采用Using where的方式,跟where category=’xx’ and sub_category=’xx’条件进行比对,符合条件的就直接放入结果集里去,最多就是放10条数据进去就可以返回了。
此时MySQL认为,按顺序扫描聚簇索引,拿到10条符合where条件的数据,应该速度是很快的,很可能比使用index_category二级索引那个方案更快,因此此时他就采用了扫描聚簇索引的这种方式!
那接下来我们又要考虑一个问题了,那就是这个SQL语句,实际上之前在线上系统运行一直没什么问题,也就是说,之前在线上系统而言,即使采用扫描聚簇索引的方案,其实这个SQL语句也确实一般都运行不慢,最起码是不会超过1s的。
那么为什么会在某一天晚上突然的就大量报慢查询,耗时几十秒了呢?
原因也很简单,其实就是因为之前的时候,where category=’xx’ and sub_category=’xx’这个条件通常都是有返回值的,就是说根据条件里的取值,扫描聚簇索引的时候,通常都是很快就能找到符合条件的值以及返回的,所以之前其实性能也没什么问题。
但是后来可能是商品系统里的运营人员,在商品管理的时候加了几种商品分类和子类,但是这几种分类和子类的组合其实没有对应的商品
也就是说,那一天晚上,很多用户使用这种分类和子类去筛选商品,where category=’新分类’ and sub_category=’新子类’这个条件实际上是查不到任何数据的!
所以说,底层在扫描聚簇索引的时候,扫来扫去都扫不到符合where条件的结果,一下子就把聚簇索引全部扫了一遍,等于是上亿数据全表扫描了一遍,都没找到符合where category=’新分类’ and sub_category=’新子类’这个条件的数据。
也正是因为如此,才导致这个SQL语句频繁的出现几十秒的慢查询,进而导致MySQL连接资源打满,商品系统崩溃!
3. 实战案例-十亿级评论场景下的sql调优
1) 业务背景
这个商品评论系统的数据量非常大,拥有多达十亿量级的评论数据,所以当时对这个评论数据库,我们是做了分库分表的,基本上分完库和表过后,单表的评论数据在百万级别。
每一个商品的所有评论都是放在一个库的一张表里的,这样可以确保你作为用户在分页查询一个商品的评论时,一般都是直接从一个库的一张表里执行分页查询语句就可以了
好,那么既然提到了商品评论分页查询的问题,我们就可以从这里开始讲我们的案例了。
大家都知道,在电商网站里,有一些热门的商品,可能销量多达上百万,商品的评论可能多达几十万条。
然后呢,有一些用户,可能就喜欢看商品评论,他就喜欢不停的对某个热门商品的评论不断的进行分页,一页一页翻,有时候还会用上分页跳转功能,就是直接输入自己要跳到第几页去。
所以这个时候,就会涉及到一个问题,针对一个商品几十万评论的深分页问题。
先来看看一个经过我们简化后的对评论表进行分页查询的SQL语句:
SELECT * FROM comments WHERE product_id =’xx’ and is_good_comment=’1’ ORDER BY id desc LIMIT 100000,20
其实他的意思就是,比如用户选择了查看某个商品的评论,因此必须限定Product_id,同时还选了只看好评,所以is_good_commit也要限定一下
接着他要看第5001页评论,那么此时limit的offset就会是(5001 - 1) * 20,其中20就是每一页的数量,此时起始offset就是100000,所以limit后100000,20
对这个评论表呢,最核心的索引就是一个,那就是index_product_id,所以对上述SQL语句,正常情况下,肯定是会走这个索引的,也就是说,会通过index_product_id索引,根据product_id =’xx’这个条件从表里先删选出来这个表里指定商品的评论数据。
那么接下来第二步呢?当然是得按照 is_good_comment=’1’ 条件,筛选出这个商品评论数据里的所有好评了!但是问题来了,这个index_product_id的索引数据里,并没有is_good_commet字段的值,所以此时只能很尴尬的进行回表了。
也就是说,对这个商品的每一条评论,都要进行一次回表操作,回到聚簇索引里,根据id找到那条数据,取出来is_good_comment字段的值,接着对is_good_comment=’1’条件做一个比对,筛选符合条件的数据。
那么假设这个商品的评论有几十万条,岂不是要做几十万次回表操作?虽然每次回表都是根据id在聚簇索引里快速查找的,但还是架不住你每条数据都回表啊!!!
接着对于筛选完毕的所有符合WHERE product_id =’xx’ and is_good_comment=’1’条件的数据,假设有十多万条吧,接着就是按照id做一个倒序排序,此时还得基于临时磁盘文件进行倒序排序,又得耗时很久。
排序完毕了,就得基于limit 100000,20获取第5001页的20条数据,最后返回。
这个过程,因为有几十万次回表查询,还有十多万条数据的磁盘文件排序,所以当时发现,这条SQL语句基本要跑个1秒~2秒
但是我们这个案例里,就不是这么回事了,因为WHERE product_id =’xx’ and is_good_comment=’1’这两个条件,不是一个联合索引,所以必须会出现大量的回表操作,这个耗时是极高的。
2) 解决方案
因此对于这个案例,我们通常会采取如下方式改造分页查询语句:SELECT * from comments a,(SELECT id FROM comments WHERE product_id =’xx’ and is_good_comment=’1’ ORDER BY id desc LIMIT 100000,20) b WHERE a.id=b.id
上面那个SQL语句的执行计划就会彻底改变他的执行方式,他通常会先执行括号里的子查询,子查询反而会使用PRIMARY聚簇索引,按照聚簇索引的id值的倒序方向进行扫描,扫描过程中就把符合WHERE product_id =’xx’ and is_good_comment=’1’条件的数据给筛选出来。
比如这里就筛选出了十万多条的数据,并不需要把符合条件的数据都找到,因为limit后跟的是100000,20,理论上,只要有100000+20条符合条件的数据,而且是按照id有序的,此时就可以执行根据limit 100000,20提取到5001页的这20条数据了。
接着你会看到执行计划里会针对这个子查询的结果集,一个临时表,
所以针对我们的这个场景,反而是优化成这种方式来执行分页,他会更加合适一些,他只有一个扫描聚簇索引筛选符合你分页所有数据的成本,你的分页深度越深,扫描数据越多,分页深度越浅,那扫描数据就越少,然后再做一页20条数据的20次回表查询就可以了。
当时我们做了这个分页优化之后,发现这个分页语句一下子执行时间降低到了几百毫秒了,此时就达到了我们优化的目的。
但是这里还是要给大家提醒一点,大家会发现,SQL调优实际上是没有银弹的,比如对于第二个案例来说,按顺序扫描聚簇索引方案可能会因为找不到数据导致亿级数据量的全表扫描,所以对第二个案例而言,必须得根据二级索引去查找。
但是对于我们这第三个案例而言,因为前提是做了分库分表,评论表单表数据一般在一百万左右,所以首先,他即使一个商品没有评论,有全表扫描,也绝对不会像扫描上亿数据表那么慢
其次,如果你根据product_id的二级索引查找,反而可能出现几十万次回表查询,所以二级索引查找方式反而不适合,而按照聚簇索引顺序扫描的方式更加适合。
简而言之,针对不同的案例,要具体情况具体分析,他慢,慢的原因在哪儿,为什么慢,然后再用针对性的方式去优化他。
4. 实战案例-千万级数据删除导致的慢查询
1) 业务背景
这个案例的开始,当时是从线上收到大量的慢查询告警开始的,当我们收到大量的慢查询告警之后,就去检查慢查询的SQL,结果发现不是什么特别的SQL,这些SQL语句主要都是针对一个表的,同时也比较简单,而且基本都是单行查询,看起来似乎不应该会慢查询。
所以这个时候我们是感觉极为奇怪的,因为SQL本身完全不应该有慢查询,按说那种SQL语句,基本上都是直接根据索引查找出来的,性能应该是极高的。
那么有没有另外一种可能,慢查询不是SQL的问题,而是MySQL生产服务器的问题呢?
这里给大家解释一下,实际上个别特殊情况下,MySQL出现慢查询并不是SQL语句的问题,而是他自己生产服务器的负载太高了,导致SQL语句执行很慢。
给大家举个例子,比如现在MySQL服务器的磁盘IO负载特别高,也就是每秒执行大量的高负载的随机IO,但是磁盘本身每秒能执行的随机IO是有限的。
结果呢,就导致你正常的SQL语句去磁盘上执行的时候,如果要跑一些随机IO,你的磁盘太繁忙了,顾不上你了,导致你本来很快的一个SQL,要等很久才能执行完毕,这个时候就可能导致正常SQL语句也会变成慢查询!
所以同理,除了磁盘之外,还有一个例子就是网络,也许网络负载很高,就可能会导致你一个SQL语句要发送到MySQL上去,光是等待获取一个跟MySQL的连接,都很难,要等很久,或者MySQL自己网络负载太高了,带宽打满,带宽打满了之后,你一个SQL也许执行很快,但是他查出来的数据返回给你,网络都送不出去,此时也会变成慢查询。
另外一个关键的点就是CPU负载,如果说CPU负载过高的话,也会导致CPU过于繁忙去执行别的任务了,没时间执行你这个SQL语句,此时也有可能会导致你的SQL语句出现问题的,所以这个大家也得注意。
所以说慢查询本身不一定是SQL导致的,如果你觉得SQL不应该慢查询,结果他那个时间段跑这个SQL就是慢,此时你应该排查一下当时MySQL服务器的负载,尤其看看磁盘、网络以及CPU的负载,是否正常
如果你发现那个时间段MySQL生产服务器的磁盘、网络或者CPU负载特别高,那么可能是服务器负载导致的问题
举个例子,我们之前解决过一个典型的问题,就是当某个离线作业瞬间大批量把数据往MySQL里灌入的时候,他一瞬间服务器磁盘、网络以及CPU的负载会超高。
此时你一个正常SQL执行下去,短时间内一定会慢查询的,针对类似的问题,优化手段更多的是控制你导致MySQL负载过高的那些行为,比如灌入大量数据,最好在凌晨低峰期灌入,别影响线上系统运行。
结果奇怪的是,当时我们看了下MySQL服务器的磁盘、网络以及CPU负载,一切正常,似乎也不是这个问题导致的。
这个时候,似乎看起来有点无解了是不是?别着急,这个案例的排查过程是极为漫长的,涉及到MySQL大量的调优知识,最终解决这个问题,甚至要深入我们之前讲过的MySQL内核级原理,才能分析清楚以及解决问题。
今天我们先站在当时的角度,给大家分析我们的头两步排查手段,一个是检查SQL是否有问题,主要就是看他的执行计划,这个我们之前都讲过了,另外一个是检查MySQL服务器的负载,今天我们也说明了背后的一些知识
那么在这两种办法都不奏效之后,下一次我们就要给大家讲当时我们排查问题的第三步,就是用MySQL profilling工具去细致的分析SQL语句的执行过程和耗时。
2) 分析情况
好,今天我们继续讲解这个案例,在当时这个案例的场景发生之后,也就是针对某个表的大量简单的单行数据查询SQL变成慢查询,我们先排查了SQL执行计划以及MySQL服务器负载,发现都没有问题。
此时就必须用上一个SQL调优的利器了,也就是profiling工具,这个工具可以对SQL语句的执行耗时进行非常深入和细致的分析,使用这个工具的过程,大致如下所示
首先要打开这个profiling,使用set profiling=1这个命令,接着MySQL就会自动记录查询语句的profiling信息了。
此时如果执行show profiles命令,就会给你列出各种查询语句的profiling信息,这里很关键的一点,就是他会记录下来每个查询语句的query id,所以你要针对你需要分析的query找到对他的query id,我们当时就是针对慢查询的那个SQL语句找到了query id。
然后就可以针对单个查询语句,看一下他的profiling具体信息,使用show profile cpu, block io for query xx,这里的xx是数字,此时就可以看到具体的profile信息了
除了cpu以及block io以外,你还可以指定去看这个SQL语句执行时候的其他各项负载和耗时,具体使用方法,大家自行网上搜索就行了,并不难。
他这里会给你展示出来SQL语句执行时候的各种耗时,比如磁盘IO的耗时,CPU等待耗时,发送数据耗时,拷贝数据到临时表的耗时,等等吧,反正SQL执行过程中的各种耗时都会展示出来的。
这里我们当时仔细检查了一下这个SQL语句的profiling信息,重点发现了一个问题,他的Sending Data的耗时是最高的,几乎使用了1s的时间,占据了SQL执行耗时的99%,这就很坑爹了。
因为其他环节耗时低是可以理解的,毕竟这种简单SQL执行速度真的很快,基本就是10ms级别的,结果跑成了1s,那肯定Sending Data就是罪魁祸首了!
这个Sending Data是在干什么呢?
MySQL的官方释义如下:为一个SELECT语句读取和处理数据行,同时发送数据给客户端的过程,简单来说就是为你的SELECT语句把数据读出来,同时发送给客户端。
可是为什么这个过程会这么慢呢?profiling确实是提供给我们更多的线索了,但是似乎还是没法解决掉问题。但是毕竟我们已经捕获到了第一个比较异常的点了,就是Sending Data的耗时很高!请大家记住这个线索。
有时候针对MySQL这种复杂数据库软件的调优过程,就跟福尔摩斯破案一样,你要通过各种手段和工具去检查MySQL的各种状态,然后把有异常的一些指标记下来,作为一个线索,当你线索足够多的时候,往往就能够汇总大量的线索整理出一个思路了,那也就是一个破案的时刻了!
接着我们又用了一个命令:show engine innodb status,看一下innodb存储引擎的一些状态,此时发现了一个奇怪的指标,就是history list length这个指标,他的值特别高,达到了上万这个级别。
这里我们给大家解释一下这个指标,当然如果大家自己在调优的时候发现了类似的情况,不知道一个指标什么意思,直接google一下就可以了,很快就会查到,这里我们直接给大家一个结论了。
大家应该还记得之前我们讲解过的MVCC机制吧?MVCC机制,说穿了就是多个事务在对同一个数据,有人写,有人读,此时可以有多种隔离级别,这个大家应该还记得吧。
至于这个MVCC和隔离级别的实现原理,跟一个Read View机制是有关系的,同时还有一个至关重要的机制,就是数据的undo多版本快照链条。
你必须对一个数据得有一个多版本快照链条,才能实现MVCC和各种隔离级别,这个具体的原理,我们这里不多说了,大家有遗忘的,建议回看之前的文章。
所以当你有大量事务执行的时候,就会构建这种undo多版本快照链条,此时history list length的值就会很高。然后在事务提交之后,会有一个多版本快照链条的自动purge清理机制,只要有清理,那么这个值就会降低。
一般来说,这个值是不应该过于高的,所以我们在这里注意到了第二个线索,history list length值过高!大量的undo多版本链条数据没被清理!推测很可能就是有的事务长时间运行,所以他的多版本快照不能被purge清理,进而导致了这个history list length的值过高!
第二个线索Get!基本可以肯定的一点是,经过两个线索的推测,在大量简单SQL语句变成慢查询的时候,SQL是因为Sending Data环节异常耗时过高,同时此时出现了一些长事务长时间运行,大量的频繁更新数据,导致有大量的undo多版本快照链条,还无法purge清理。
但是这两个线索之间的关系是什么呢?是第二个线索推导出的事务长时间运行现象的发生,进而导致了第一个线索发现的Sending Data耗时过高的问题吗?可是二者之间的关系是什么呢?是不是还得找到更多的线索还行呢?
3) 解决
简单来说,当时经过排查,一直排查到117讲末尾的时候,发现有大量的更新语句在活跃,而且有那种长期活跃的超长事务一直在跑没有结束,结果一问系统负责人,发现他在后台跑了一个定时任务,定时清理数据,结果清理的时候一下子清理了上千万的数据。
这个清理是怎么做的呢?他居然开了一个事务,然后在一个事务里删除上千万数据,导致这个事务一直在运行,所以才看到117讲末尾发现的一些现象。
然后呢,这种长事务的运行会导致一个问题,那就是你删除的时候仅仅只是对数据加了一个删除标记,事实上并没有彻底删除掉。此时你如果跟长事务同时运行的其他事务里在查询,他在查询的时候是可能会把那上千万被标记为删除的数据都扫描一遍的。
因为每次扫描到一批数据,都发现标记为删除了,接着就会再继续往下扫描,所以才导致一些查询语句会那么的慢。
那么可能有人会问了,为什么你启动一个事务,在事务里查询,凭什么就要去扫描之前那个长事务标记为删除状态的上千万的垃圾数据呢?按说那些数据都被删除了,跟你没关系了,你可以不用去扫描他们啊!
这个问题的关键点就在于,那个删除千万级数据的事务是个长事务!
也就是说,当你启动新事务查询的时候,那个删除千万级数据的长事务一直在运行,是活跃的!所以大家还记得我们之前讲解MVCC的时候,提到的一个Read View的概念么?MVCC是如何实现的?不就是基于一个Read View机制来实现的么?
当你启动一个新事务查询的时候,会生成一个Read View,里面包含了当前活跃事务的最大id、最小id和事务id集合,然后他有一个判定规则,具体判定规则大家不记得可以回顾一下当时我们讲过的内容。
总之就是,你的新事务查询的时候,会根据ReadView去判断哪些数据是你可见的,以及你可见的数据版本是哪个版本,因为一个数据有一个版本链条,有的时候你可能可见的仅仅是这个数据的一个历史版本而已。
所以正是因为这个长事务一直在运行还在删除大量的数据,而且这些数据仅仅是标记为删除,实际还没删除,所以此时你新开事务的查询是会读到所有被标记为删除的数据的,就会出现千万级的数据扫描,才会造成慢查询!
针对这个问题,其实大家要知道的一点是,永远不要在业务高峰期去运行那种删除大量数据的语句,因为这可能导致一些正常的SQL都变慢查询,因为那些SQL也许会不断扫描你标记为删除的大量数据,好不容易扫描到一批数据,结果发现是标记为删除的,于是继续扫描下去,导致了慢查询!
所以当时的解决方案也很简单,直接kill那个正在删除千万级数据的长事务,所有SQL很快会恢复正常,从此以后,对于大量数据清理全部放在凌晨去执行,那个时候就没什么人使用系统了,所以查询也很少。
