You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

221 lines
16 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 22 | MySQL有哪些“饮鸩止渴”提高性能的方法
不知道你在实际运维过程中有没有碰到这样的情景业务高峰期生产环境的MySQL压力太大没法正常响应需要短期内、临时性地提升一些性能。
我以前做业务护航的时候,就偶尔会碰上这种场景。用户的开发负责人说,不管你用什么方案,让业务先跑起来再说。
但,如果是无损方案的话,肯定不需要等到这个时候才上场。今天我们就来聊聊这些临时方案,并着重说一说它们可能存在的风险。
# 短连接风暴
正常的短连接模式就是连接到数据库后执行很少的SQL语句就断开下次需要的时候再重连。如果使用的是短连接在业务高峰期的时候就可能出现连接数突然暴涨的情况。
我在第1篇文章[《基础架构一条SQL查询语句是如何执行的》](https://time.geekbang.org/column/article/68319)中说过MySQL建立连接的过程成本是很高的。除了正常的网络连接三次握手外还需要做登录权限判断和获得这个连接的数据读写权限。
在数据库压力比较小的时候,这些额外的成本并不明显。
但是短连接模型存在一个风险就是一旦数据库处理得慢一些连接数就会暴涨。max\_connections参数用来控制一个MySQL实例同时存在的连接数的上限超过这个值系统就会拒绝接下来的连接请求并报错提示“Too many connections”。对于被拒绝连接的请求来说从业务角度看就是数据库不可用。
在机器负载比较高的时候处理现有请求的时间变长每个连接保持的时间也更长。这时再有新建连接的话就可能会超过max\_connections的限制。
碰到这种情况时一个比较自然的想法就是调高max\_connections的值。但这样做是有风险的。因为设计max\_connections这个参数的目的是想保护MySQL如果我们把它改得太大让更多的连接都可以进来那么系统的负载可能会进一步加大大量的资源耗费在权限验证等逻辑上结果可能是适得其反已经连接的线程拿不到CPU资源去执行业务的SQL请求。
那么这种情况下,你还有没有别的建议呢?我这里还有两种方法,但要注意,这些方法都是有损的。
**第一种方法:先处理掉那些占着连接但是不工作的线程。**
max\_connections的计算不是看谁在running是只要连着就占用一个计数位置。对于那些不需要保持的连接我们可以通过kill connection主动踢掉。这个行为跟事先设置wait\_timeout的效果是一样的。设置wait\_timeout参数表示的是一个线程空闲wait\_timeout这么多秒之后就会被MySQL直接断开连接。
但是需要注意在show processlist的结果里踢掉显示为sleep的线程可能是有损的。我们来看下面这个例子。
![](https://static001.geekbang.org/resource/image/90/2a/9091ff280592c8c68665771b1516c62a.png)
图1 sleep线程的两种状态
在上面这个例子里如果断开session A的连接因为这时候session A还没有提交所以MySQL只能按照回滚事务来处理而断开session B的连接就没什么大影响。所以如果按照优先级来说你应该优先断开像session B这样的事务外空闲的连接。
但是怎么判断哪些是事务外空闲的呢session C在T时刻之后的30秒执行show processlist看到的结果是这样的。
![](https://static001.geekbang.org/resource/image/ae/25/ae6a9ceecf8517e47f9ebfc565f0f925.png)
图2 sleep线程的两种状态show processlist结果
图中id=4和id=5的两个会话都是Sleep 状态。而要看事务具体状态的话你可以查information\_schema库的innodb\_trx表。
![](https://static001.geekbang.org/resource/image/ca/e8/ca4b455c8eacbf32b98d1fe9ed9876e8.png)
图3 从information\_schema.innodb\_trx查询事务状态
这个结果里trx\_mysql\_thread\_id=4表示id=4的线程还处在事务中。
因此,如果是连接数过多,你可以优先断开事务外空闲太久的连接;如果这样还不够,再考虑断开事务内空闲太久的连接。
从服务端断开连接使用的是kill connection + id的命令 一个客户端处于sleep状态时它的连接被服务端主动断开后这个客户端并不会马上知道。直到客户端在发起下一个请求的时候才会收到这样的报错“ERROR 2013 (HY000): Lost connection to MySQL server during query”。
从数据库端主动断开连接可能是有损的尤其是有的应用端收到这个错误后不重新连接而是直接用这个已经不能用的句柄重试查询。这会导致从应用端看上去“MySQL一直没恢复”。
你可能觉得这是一个冷笑话但实际上我碰到过不下10次。
所以如果你是一个支持业务的DBA不要假设所有的应用代码都会被正确地处理。即使只是一个断开连接的操作也要确保通知到业务开发团队。
**第二种方法:减少连接过程的消耗。**
有的业务代码会在短时间内先大量申请数据库连接做备用,如果现在数据库确认是被连接行为打挂了,那么一种可能的做法,是让数据库跳过权限验证阶段。
跳过权限验证的方法是重启数据库并使用skip-grant-tables参数启动。这样整个MySQL会跳过所有的权限验证阶段包括连接过程和语句执行过程在内。
但是,这种方法特别符合我们标题里说的“饮鸩止渴”,风险极高,是我特别不建议使用的方案。尤其你的库外网可访问的话,就更不能这么做了。
在MySQL 8.0版本里如果你启用skip-grant-tables参数MySQL会默认把 --skip-networking参数打开表示这时候数据库只能被本地的客户端连接。可见MySQL官方对skip-grant-tables这个参数的安全问题也很重视。
除了短连接数暴增可能会带来性能问题外实际上我们在线上碰到更多的是查询或者更新语句导致的性能问题。其中查询问题比较典型的有两类一类是由新出现的慢查询导致的一类是由QPS每秒查询数突增导致的。而关于更新语句导致的性能问题我会在下一篇文章和你展开说明。
# 慢查询性能问题
在MySQL中会引发性能问题的慢查询大体有以下三种可能
1. 索引没有设计好;
2. SQL语句没写好
3. MySQL选错了索引。
接下来,我们就具体分析一下这三种可能,以及对应的解决方案。
**导致慢查询的第一种可能是,索引没有设计好。**
这种场景一般就是通过紧急创建索引来解决。MySQL 5.6版本以后创建索引都支持Online DDL了对于那种高峰期数据库已经被这个语句打挂了的情况最高效的做法就是直接执行alter table 语句。
比较理想的是能够在备库先执行。假设你现在的服务是一主一备主库A、备库B这个方案的大致流程是这样的
1. 在备库B上执行 set sql\_log\_bin=off也就是不写binlog然后执行alter table 语句加上索引;
2. 执行主备切换;
3. 这时候主库是B备库是A。在A上执行 set sql\_log\_bin=off然后执行alter table 语句加上索引。
这是一个“古老”的DDL方案。平时在做变更的时候你应该考虑类似gh-ost这样的方案更加稳妥。但是在需要紧急处理时上面这个方案的效率是最高的。
**导致慢查询的第二种可能是,语句没写好。**
比如我们犯了在第18篇文章[《为什么这些SQL语句逻辑相同性能却差异巨大》](https://time.geekbang.org/column/article/74059)中提到的那些错误,导致语句没有使用上索引。
这时我们可以通过改写SQL语句来处理。MySQL 5.7提供了query\_rewrite功能可以把输入的一种语句改写成另外一种模式。
比如,语句被错误地写成了 select \* from t where id + 1 = 10000你可以通过下面的方式增加一个语句改写规则。
```
mysql> insert into query_rewrite.rewrite_rules(pattern, replacement, pattern_database) values ("select * from t where id + 1 = ?", "select * from t where id = ? - 1", "db1");
call query_rewrite.flush_rewrite_rules();
```
这里call query\_rewrite.flush\_rewrite\_rules()这个存储过程是让插入的新规则生效也就是我们说的“查询重写”。你可以用图4中的方法来确认改写规则是否生效。
![](https://static001.geekbang.org/resource/image/47/8a/47a1002cbc4c05c74841591d20f7388a.png)
图4 查询重写效果
**导致慢查询的第三种可能就是碰上了我们在第10篇文章**[**《MySQL为什么有时候会选错索引》**](https://time.geekbang.org/column/article/71173)**中提到的情况MySQL选错了索引。**
这时候应急方案就是给这个语句加上force index。
同样地使用查询重写功能给原来的语句加上force index也可以解决这个问题。
上面我和你讨论的由慢查询导致性能问题的三种可能情况,实际上出现最多的是前两种,即:索引没设计好和语句没写好。而这两种情况,恰恰是完全可以避免的。比如,通过下面这个过程,我们就可以预先发现问题。
1. 上线前在测试环境把慢查询日志slow log打开并且把long\_query\_time设置成0确保每个语句都会被记录入慢查询日志
2. 在测试表里插入模拟线上的数据,做一遍回归测试;
3. 观察慢查询日志里每类语句的输出特别留意Rows\_examined字段是否与预期一致。我们在前面文章中已经多次用到过Rows\_examined方法了相信你已经动手尝试过了。如果还有不明白的欢迎给我留言我们一起讨论
不要吝啬这段花在上线前的“额外”时间,因为这会帮你省下很多故障复盘的时间。
如果新增的SQL语句不多手动跑一下就可以。而如果是新项目的话或者是修改了原有项目的 表结构设计全量回归测试都是必要的。这时候你需要工具帮你检查所有的SQL语句的返回结果。比如你可以使用开源工具pt-query-digest([https://www.percona.com/doc/percona-toolkit/3.0/pt-query-digest.html](https://www.percona.com/doc/percona-toolkit/3.0/pt-query-digest.html))。
# QPS突增问题
有时候由于业务突然出现高峰或者应用程序bug导致某个语句的QPS突然暴涨也可能导致MySQL压力过大影响服务。
我之前碰到过一类情况是由一个新功能的bug导致的。当然最理想的情况是让业务把这个功能下掉服务自然就会恢复。
而下掉一个功能,如果从数据库端处理的话,对应于不同的背景,有不同的方法可用。我这里再和你展开说明一下。
1. 一种是由全新业务的bug导致的。假设你的DB运维是比较规范的也就是说白名单是一个个加的。这种情况下如果你能够确定业务方会下掉这个功能只是时间上没那么快那么就可以从数据库端直接把白名单去掉。
2. 如果这个新功能使用的是单独的数据库用户可以用管理员账号把这个用户删掉然后断开现有连接。这样这个新功能的连接不成功由它引发的QPS就会变成0。
3. 如果这个新增的功能跟主体功能是部署在一起的那么我们只能通过处理语句来限制。这时我们可以使用上面提到的查询重写功能把压力最大的SQL语句直接重写成"select 1"返回。
当然,这个操作的风险很高,需要你特别细致。它可能存在两个副作用:
1. 如果别的功能里面也用到了这个SQL语句模板会有误伤
2. 很多业务并不是靠这一个语句就能完成逻辑的所以如果单独把这一个语句以select 1的结果返回的话可能会导致后面的业务逻辑一起失败。
所以方案3是用于止血的跟前面提到的去掉权限验证一样应该是你所有选项里优先级最低的一个方案。
同时你会发现其实方案1和2都要依赖于规范的运维体系虚拟化、白名单机制、业务账号分离。由此可见更多的准备往往意味着更稳定的系统。
# 小结
今天这篇文章,我以业务高峰期的性能问题为背景,和你介绍了一些紧急处理的手段。
这些处理手段中,既包括了粗暴地拒绝连接和断开连接,也有通过重写语句来绕过一些坑的方法;既有临时的高危方案,也有未雨绸缪的、相对安全的预案。
在实际开发中,我们也要尽量避免一些低效的方法,比如避免大量地使用短连接。同时,如果你做业务开发的话,要知道,连接异常断开是常有的事,你的代码里要有正确地重连并重试的机制。
DBA虽然可以通过语句重写来暂时处理问题但是这本身是一个风险高的操作做好SQL审计可以减少需要这类操作的机会。
其实你可以看得出来在这篇文章中我提到的解决方法主要集中在server层。在下一篇文章中我会继续和你讨论一些跟InnoDB有关的处理方法。
最后,又到了我们的思考题时间了。
今天,我留给你的课后问题是,你是否碰到过,在业务高峰期需要临时救火的场景?你又是怎么处理的呢?
你可以把你的经历和经验写在留言区,我会在下一篇文章的末尾选取有趣的评论跟大家一起分享和分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
前两期我给你留的问题是下面这个图的执行序列中为什么session B的insert语句会被堵住。
![](https://static001.geekbang.org/resource/image/3a/1e/3a7578e104612a188a2d574eaa3bd81e.png)
我们用上一篇的加锁规则来分析一下看看session A的select语句加了哪些锁
1. 由于是order by c desc第一个要定位的是索引c上“最右边的”c=20的行所以会加上间隙锁(20,25)和next-key lock (15,20\]。
2. 在索引c上向左遍历要扫描到c=10才停下来所以next-key lock会加到(5,10\]这正是阻塞session B的insert语句的原因。
3. 在扫描过程中c=20、c=15、c=10这三行都存在值由于是select \*所以会在主键id上加三个行锁。
因此session A 的select语句锁的范围就是
1. 索引c上 (5, 25)
2. 主键索引上id=15、20两个行锁。
这里我再啰嗦下你会发现我在文章中每次加锁都会说明是加在“哪个索引上”的。因为锁就是加在索引上的这是InnoDB的一个基础设定需要你在分析问题的时候要一直记得。
评论区留言点赞板:
> @HuaMax 给出了正确的解释。
> @Justin 同学提了个好问题,<=到底是间隙锁还是行锁其实这个问题你要跟“执行过程”配合起来分析。在InnoDB要去找“第一个值”的时候是按照等值去找的用的是等值判断的规则找到第一个值以后要在索引内找“下一个值”对应于我们规则中说的范围查找。
> @信信 提了一个不错的问题要知道最终的加锁是根据实际执行情况来的。所以如果一个select \* from … for update 语句优化器决定使用全表扫描那么就会把主键索引上next-key lock全加上。
> @nero 同学的问题提示我需要提醒大家注意“有行”才会加行锁。如果查询条件没有命中行那就加next-key lock。当然等值判断的时候需要加上优化2索引上的等值查询向右遍历时且最后一个值不满足等值条件的时候next-key lock退化为间隙锁。
> @小李子、@发条橙子同学,都提了很好的问题,这期高质量评论很多,你也都可以去看看。
最后,我要为元旦期间还坚持学习的同学们,点个赞 ^\_^