业务高峰期临时提升性能的方法
# 1. MySQL 有哪些“饮鸩止渴”提高性能的方法?
在业务高峰期时,会出现由于 MySQL 压力太大导致无法正常响应,因而可能需要临时提升一些性能,但如果是无损方案的话,肯定不需要临时才上场。这一章就看看有哪些临时方案,并分析一些它们可能存在的风险。
# 1.1 短链接风暴
数据库每一次建立连接的成本并不低,包括三次握手、登录权限判断和数据读写权限校验等,在数据库压力较小时,这些成本并不明显。
当在业务高峰期时,会存在大量的短连接来执行短事务,每个连接执行一个 SQL 便结束,一旦数据库处理得慢一些,连接数就会暴涨。max_connections 参数用来控制一个 MySQL 实例同时存在的连接数的上限,超过这个值,系统就会拒绝接下来的连接请求,并报错提示“Too many connections”。对于被拒绝连接的请求来说,从业务角度看就是数据库不可用。
在 DB 负载较高时,大量短链接的出现就可能超过 max_connections 限制。
碰到这种情况,一个自然的想法就是调高 max_connections 的值。但调高 max_connections 是有风险的。因为设计 max_connections 这个参数的目的是想保护 MySQL,如果我们把它改得太大,让更多的连接都可以进来,那么系统的负载可能会进一步加大,大量的资源耗费在权限验证等逻辑上,结果可能是适得其反,已经连接的线程拿不到 CPU 资源去执行业务的 SQL 请求。
这种情况下还有没有别的方法呢?这里给出两个方法,但他们都是有损的。
# 方法 1:先处理掉那些占着连接但是不工作的线程
max_connections 的计算,不是看谁在 running,是只要连着就占用一个计数位置。对于那些不需要保持的连接,我们可以通过 kill connection 主动踢掉。
这个行为跟事先设置 wait_timeout 的效果是一样的。设置 wait_timeout 参数表示的是,一个线程空闲 wait_timeout 这么多秒之后,就会被 MySQL 直接断开连接。
::: info show processlist
MySQL 命令行中,可以通过 show processlist
显示当前连接的进程:
:::
通过 show processlist 命令可以看到哪些连接在 sleep,但是断掉显示为 sleep 的连接却可能是有损的。如果断掉的是正在事务中的连接,那么 DB 只能按照回滚事务来处理,而断开事务外的连接就没有什么大的影响。因此我们应该优先断开事务外空闲的连接。
我们首先通过 show processlist
看一下哪些事务是 Sleep 状态,然后通过查询 infomation_schema 库的 innodb_trx 表来看一下事务的具体状态,便可以优先选择断开事务外空闲太久的连接。
::: detail innodb_trx 表示例 下面是一个查看 innodb_trx 表的示例:
从上面可以看到,trx_mysql_thread_id: 8
表示 ID 为 8 的线程还处在事务中。
:::
从服务端断开连接使用的是 kill connection + id 的命令, 一个客户端处于 sleep 状态时,它的连接被服务端主动断开后,这个客户端并不会马上知道。直到客户端在发起下一个请求的时候,才会收到这样的报错“ERROR 2013 (HY000): Lost connection to MySQL server during query”。
从数据库端主动断开连接可能是有损的,尤其是有的应用端收到这个错误后,不重新连接,而是直接用这个已经不能用的句柄重试查询。这会导致从应用端看上去,“MySQL 一直没恢复”。
你可能觉得这是一个冷笑话,但实际上我碰到过不下 10 次。所以,如果你是一个支持业务的 DBA,不要假设所有的应用代码都会被正确地处理。即使只是一个断开连接的操作,也要确保通知到业务开发团队。
# 方法 2:减少连接过程的消耗
有的业务代码会在短时间内先大量申请数据库连接做备用,如果现在数据库确认是被连接行为打挂了,那么一种可能的做法,是让数据库跳过权限验证阶段。
跳过权限验证的方法是:重启数据库,并使用–skip-grant-tables 参数启动。这样,整个 MySQL 会跳过所有的权限验证阶段,包括连接过程和语句执行过程在内。
但是,这种方法特别符合我们标题里说的“饮鸩止渴”,风险极高,是我特别不建议使用的方案。尤其你的库外网可访问的话,就更不能这么做了。在 MySQL 8.0 版本里,如果你启用–skip-grant-tables 参数,MySQL 会默认把 --skip-networking 参数打开,表示这时候数据库只能被本地的客户端连接。可见,MySQL 官方对 skip-grant-tables 这个参数的安全问题也很重视。
除了短连接数暴增可能会带来性能问题外,实际上,我们在线上碰到更多的是查询或者更新语句导致的性能问题。其中,查询问题比较典型的有两类,一类是由新出现的慢查询导致的,一类是由 QPS(每秒查询数)突增导致的。而关于更新语句导致的性能问题,我会在下一章和你展开说明。
# 1.2 慢查询性能问题
引发性能导致的慢查询,大体有三种可能:
- 索引没有设计好;
- SQL 语句没写好;
- MySQL 选错了索引。
接下来我们具体分析这三种可能,以及对应的解决方案。
# 可能 1:索引没有设计好
这种场景一般就是通过紧急创建索引来解决。MySQL 5.6 版本以后,创建索引都支持 Online DDL 了,对于那种高峰期数据库已经被这个语句打挂了的情况,最高效的做法就是直接执行 alter table 语句。
比较理想的是能够在备库先执行。假设你现在的服务是一主一备,主库 A、备库 B,这个方案的大致流程是这样的:
- 在备库 B 上执行
set sql_log_bin=off
,也就是不写 binlog,然后执行 alter table 语句加上索引; - 执行主备切换;
- 这时候主库是 B,备库是 A。在 A 上执行 set sql_log_bin=off,然后执行 alter table 语句加上索引。
这是一个“古老”的 DDL 方案。平时在做变更的时候,你应该考虑类似 gh-ost 这样的方案,更加稳妥。但是在需要紧急处理时,上面这个方案的效率是最高的。
# 可能 2:语句没写好
SQL 语句没有使用好索引,可能就会导致慢查询。
这时,我们可以通过改写 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();
2
3
这里,call query_rewrite.flush_rewrite_rules() 这个存储过程,是让插入的新规则生效,也就是我们说的“查询重写”。你可以用下图的方法来确认改写规则是否生效:
# 可能 3:MySQL 选错了索引
如果因为 MySQL 选错索引导致查询性能变慢,应急预案就是给这个语句加上 force index。同样地,使用查询重写功能,给原来的语句加上 force index,也可以解决这个问题。
# 提前预防慢查询
上面讨论的三种导致慢查询的可能情况,实际上出现最多的是前两种,即:索引没设计好和语句没写好。而这两种情况,恰恰是完全可以避免的。比如,通过下面这个过程,我们就可以预先发现问题:
- 上线前,在测试环境,把慢查询日志(slow log)打开,并且把
long_query_time
设置成 0,确保每个语句都会被记录入慢查询日志; - 在测试表里插入模拟线上的数据,做一遍回归测试;
- 观察慢查询日志里每类语句的输出,特别留意 Rows_examined 字段是否与预期一致。
超过
long_query_time
的 SQL 执行都会被记入慢查询日志中
不要吝啬这段花在上线前的“额外”时间,因为这会帮你省下很多故障复盘的时间。
如果新增的 SQL 语句不多,手动跑一下就可以。而如果是新项目的话,或者是修改了原有项目的 表结构设计,全量回归测试都是必要的。这时候,你需要工具帮你检查所有的 SQL 语句的返回结果。比如,你可以使用开源工具 pt-query-digest (opens new window)。
# 1.3 QPS 突增问题
有时候由于业务突然出现高峰,或者应用程序 bug,导致某个语句的 QPS 突然暴涨,也可能导致 MySQL 压力过大,影响服务。
我之前碰到过一类情况,是由一个新功能的 bug 导致的。当然,最理想的情况是让业务把这个功能下掉,服务自然就会恢复。而下掉一个功能,如果从数据库端处理的话,对应于不同的背景,有不同的方法可用。我这里再和你展开说明一下:
- 一种是由全新业务的 bug 导致的。假设你的 DB 运维是比较规范的,也就是说白名单是一个个加的。这种情况下,如果你能够确定业务方会下掉这个功能,只是时间上没那么快,那么就可以从数据库端直接把白名单去掉。
- 如果这个新功能使用的是单独的数据库用户,可以用管理员账号把这个用户删掉,然后断开现有连接。这样,这个新功能的连接不成功,由它引发的 QPS 就会变成 0。
- 如果这个新增的功能跟主体功能是部署在一起的,那么我们只能通过处理语句来限制。这时,我们可以使用上面提到的查询重写功能,把压力最大的 SQL 语句直接重写成"select 1"返回。
当然,这个操作的风险很高,需要你特别细致。它可能存在两个副作用:
- 如果别的功能里面也用到了这个 SQL 语句模板,会有误伤;
- 很多业务并不是靠这一个语句就能完成逻辑的,所以如果单独把这一个语句以 select 1 的结果返回的话,可能会导致后面的业务逻辑一起失败。
所以,方案 3 是用于止血的,跟前面提到的去掉权限验证一样,应该是你所有选项里优先级最低的一个方案。
同时你会发现,其实方案 1 和 2 都要依赖于规范的运维体系:虚拟化、白名单机制、业务账号分离。由此可见,更多的准备,往往意味着更稳定的系统。
# 1.4 小结
今天这篇文章,我以业务高峰期的性能问题为背景,和你介绍了一些紧急处理的手段。这些处理手段中,既包括了粗暴地拒绝连接和断开连接,也有通过重写语句来绕过一些坑的方法;既有临时的高危方案,也有未雨绸缪的、相对安全的预案。
在实际开发中,我们也要尽量避免一些低效的方法,比如避免大量地使用短连接。同时,如果你做业务开发的话,要知道,连接异常断开是常有的事,你的代码里要有正确地重连并重试的机制。
DBA 虽然可以通过语句重写来暂时处理问题,但是这本身是一个风险高的操作,做好 SQL 审计可以减少需要这类操作的机会。
其实,你可以看得出来,在这篇文章中我提到的解决方法主要集中在 server 层。在下一篇文章中,我会继续和你讨论一些跟 InnoDB 有关的处理方法。
# 2. MySQL 是怎么保证数据不丢的?
这一章继续介绍在业务高峰期临时提升性能的方法,从名字可以看出来,这个方法与数据的可靠性有关。
我们之前了解过 WAL 机制,结论是:只要 redo log 和 binlog 保证持久化到磁盘,就能确保 MySQL 异常重启后,数据可以恢复。今天就看一下 MySQL 是写入 binlog 和 redo log 的流程。
# 2.1 binlog 的写入机制
binlog 的写入逻辑比较简单:事务执行过程中,先把日志写到 binlog cache,事务提交的时候,再把 binlog cache 写到 binlog 文件中。
一个事务的 binlog 是不能被拆开的,因此不论这个事务多大,也要确保一次性写入。这就涉及到了 binlog cache 的保存问题:
系统会给 binlog cache 分配一块内存,每个线程一个,参数 binlog_cache_size 用于控制单个线程内 binlog cache 所占内存的大小。如果超过了这个参数规定的大小,就要暂存到磁盘。事务提交的时候,执行器把 binlog cache 里的完整事务写入到 binlog 中,并清空 binlog cache。如下图所示:
可以看到,每个线程有自己 binlog cache,但是共用同一份 binlog 文件
- 图中的 write,指的就是指把日志写入到文件系统的 page cache,并没有把数据持久化到磁盘,所以速度比较快。
- 图中的 fsync,才是将数据持久化到磁盘的操作。一般情况下,我们认为 fsync 才占磁盘的 IOPS。
write 和 fsync 的时机,是由参数 sync_binlog 控制的:
- sync_binlog=0 的时候,表示每次提交事务都只 write,不 fsync;
- sync_binlog=1 的时候,表示每次提交事务都会执行 fsync;
- sync_binlog=N(N>1) 的时候,表示每次提交事务都 write,但累积 N 个事务后才 fsync。
因此,在出现 IO 瓶颈的场景里,将 sync_binlog 设置成一个比较大的值,可以提升性能。在实际的业务场景中,考虑到丢失日志量的可控性,一般不建议将这个参数设成 0,比较常见的是将其设置为 100~1000 中的某个数值。但是,将 sync_binlog 设置为 N,对应的风险是:如果主机发生异常重启,会丢失最近 N 个事务的 binlog 日志。
# 2.2 redo log 的写入机制
# 2.2.1 redo log 何时写入 redo log buffer?
在日志系统中我们介绍了 redo log buffer,生成的 redo log 是要先到 redo log buffer 中,那可能就会有人问,redo log buffer 里面的内容,是不是每次生成后都要直接持久化磁盘中呢?
答案是:不需要。
如果事务执行期间 MySQL 发生异常重启,那这部分日志就丢了。由于事务并没有提交,所以这时日志丢了也不会有损失。
那么,另外一个问题是,事务还没提交的时候,redo log buffer 中的部分日志有没有可能被持久化到磁盘呢?答案是,确实会有。
这个问题,要从 redo log 可能存在的三种状态说起。这三种状态,对应的就是下图中的三个颜色块:
这三种状态分别是:
- 存在 redo log buffer 中,物理上是在 MySQL 进程内存中,就是图中的红色部分;
- 写到磁盘 (write),但是没有持久化(fsync),物理上是在文件系统的 page cache 里面,也就是图中的黄色部分;
- 持久化到磁盘,对应的是 hard disk,也就是图中的绿色部分。
日志写到 redo log buffer 是很快的,wirte 到 page cache 也差不多,但是持久化到磁盘的速度就慢多了。
为了控制 redo log 的写入策略,InnoDB 提供了 innodb_flush_log_at_trx_commit 参数,它有三种可能取值:
- 0:每次 commit 只把 redo log 留在 buffer 中
- 1:每次 commit 都将 redo log 持久化到磁盘中
- 2:每次 commit 都将 redo log 写道 page cache 中
InnoDB 有一个后台线程,每隔 1 秒,就会把 redo log buffer 中的日志,调用 write 写到文件系统的 page cache,然后调用 fsync 持久化到磁盘。注意,事务执行中间过程的 redo log 也是直接写在 redo log buffer 中的,这些 redo log 也会被后台线程一起持久化到磁盘。也就是说,一个没有提交的事务的 redo log,也是可能已经持久化到磁盘的。
实际上,除了后台线程每秒一次的轮询操作外,还有两种场景会让一个没有提交的事务的 redo log 写入到磁盘中:
- 一种是,redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动写盘。注意,由于这个事务并没有提交,所以这个写盘动作只是 write,而没有调用 fsync,也就是只留在了文件系统的 page cache。
- 另一种是,并行的事务提交的时候,顺带将这个事务的 redo log buffer 持久化到磁盘。假设一个事务 A 执行到一半,已经写了一些 redo log 到 buffer 中,这时候有另外一个线程的事务 B 提交,如果 innodb_flush_log_at_trx_commit 设置的是 1,那么按照这个参数的逻辑,事务 B 要把 redo log buffer 里的日志全部持久化到磁盘。这时候,就会带上事务 A 在 redo log buffer 里的日志一起持久化到磁盘。
这里需要说明的是,我们介绍两阶段提交的时候说过,时序上 redo log 先 prepare, 再写 binlog,最后再把 redo log commit。如果把 innodb_flush_log_at_trx_commit 设置成 1,那么 redo log 在 prepare 阶段就要持久化一次,因为有一个崩溃恢复逻辑是要依赖于 prepare 的 redo log,再加上 binlog 来恢复的。(忘了的话,可以看看日志系统那一部分)
每秒一次后台轮询刷盘,再加上崩溃恢复这个逻辑,InnoDB 就认为 redo log 在 commit 的时候就不需要 fsync 了,只会 write 到文件系统的 page cache 中就够了。
通常我们说 MySQL 的双 1 配置,指的就是 sync_binlog 和 innodb_flush_log_at_trx_commit 都设置成 1。也就是说,一个事务完整提交前,需要等待两次刷盘,一次是 redo log(prepare 阶段),一次是 binlog。
# 2.2.2 组提交
但这样每次事务 commit 都会有两次刷盘操作,但实际并不会这么多,原因就在于组提交(group commit)机制。
首先需要介绍一下 LSN(日志逻辑序列号),每条 redo log 有一个 LSN,LSN 是单调递增的,用来对应 redo log 的一个个写入点。每次写入长度为 length 的 redo log, LSN 的值就会加上 length。
LSN 也会写到 InnoDB 的数据页中,来确保数据页不会被多次执行重复的 redo log。
从图中可以看到:
- trx1 是第一个到达的,会被选为这组的 leader;
- 等 trx1 要开始写盘的时候,这个组里面已经有了三个事务,这时候 LSN 也变成了 160;
- trx1 去写盘的时候,带的就是 LSN=160,因此等 trx1 返回时,所有 LSN 小于等于 160 的 redo log,都已经被持久化到磁盘;
- 这时候 trx2 和 trx3 就可以直接返回了。
所以,一次组提交里面,组员越多,节约磁盘 IOPS 的效果越好:
- 如果只有单线程压测,那就只能老老实实地一个事务对应一次持久化操作了。
- 在并发更新场景下,第一个事务写完 redo log buffer 以后,接下来这个 fsync 越晚调用,组员可能越多,节约 IOPS 的效果就越好。
为了让一次 fsync 带的组员更多,MySQL 有一个很有趣的优化:拖时间。在介绍两阶段提交的时候,我曾经给你画了一个图,现在我把它截过来。
图中,我把“写 binlog”当成一个动作。但实际上,写 binlog 是分成两步的:
- 先把 binlog 从 binlog cache 中写到磁盘上的 binlog 文件;
- 调用 fsync 持久化。
MySQL 为了让组提交的效果更好,把 redo log 做 fsync 的时间拖到了步骤 1 之后。也就是说,上面的图变成了这样:
这么一来,binlog 也可以组提交了。在执行图 5 中第 4 步把 binlog fsync 到磁盘时,如果有多个事务的 binlog 已经写完了,也是一起持久化的,这样也可以减少 IOPS 的消耗。不过通常情况下第 3 步执行得会很快,所以 binlog 的 write 和 fsync 间的间隔时间短,导致能集合到一起持久化的 binlog 比较少,因此 binlog 的组提交的效果通常不如 redo log 的效果那么好。
如果你想提升 binlog 组提交的效果,可以通过设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 来实现:
- binlog_group_commit_sync_delay 参数,表示延迟多少微秒后才调用 fsync;
- binlog_group_commit_sync_no_delay_count 参数,表示累积多少次以后才调用 fsync。
这两个条件是“或”的关系,也就是说只要有一个满足条件就会调用 fsync。
所以,当 binlog_group_commit_sync_delay 设置为 0 的时候,binlog_group_commit_sync_no_delay_count 也无效了。
之前有同学问到,WAL 机制是减少磁盘写,可是每次提交事务都要写 redo log 和 binlog,这磁盘读写次数也没变少呀?现在你就能理解了,WAL 机制主要得益于两个方面:
- redo log 和 binlog 都是顺序写,磁盘的顺序写比随机写速度要快;
- 组提交机制,可以大幅度降低磁盘的 IOPS 消耗。
# 2.3 解决 MySQL 的 IO 瓶颈的方法
分析到这里,我们再来回答这个问题:如果你的 MySQL 现在出现了性能瓶颈,而且瓶颈在 IO 上,可以通过哪些方法来提升性能呢?
针对这个问题,可以考虑以下三种方法:
- 设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 参数,减少 binlog 的写盘次数。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险。
- 将 sync_binlog 设置为大于 1 的值(比较常见是 100~1000)。这样做的风险是,主机掉电时会丢 binlog 日志。
- 将 innodb_flush_log_at_trx_commit 设置为 2。这样做的风险是,主机掉电的时候会丢数据。
我不建议你把 innodb_flush_log_at_trx_commit 设置成 0。因为把这个参数设置成 0,表示 redo log 只保存在内存中,这样的话 MySQL 本身异常重启也会丢数据,风险太大。而 redo log 写到文件系统的 page cache 的速度也是很快的,所以将这个参数设置成 2 跟设置成 0 其实性能差不多,但这样做 MySQL 异常重启时就不会丢数据了,相比之下风险会更小。
# 3. 对 crash-safe 的再讨论
以上内容再配上日志系统那一章,希望你对 crash-safe 这个概念有更清晰的理解。下面对一些常见的问题进行集中回答。
问题 1:执行一个 update 语句以后,我再去执行 hexdump 命令直接查看 ibd 文件内容,为什么没有看到数据有改变呢?
回答:这可能是因为 WAL 机制的原因。update 语句执行完成后,InnoDB 只保证写完了 redo log、内存,可能还没来得及将数据写到磁盘。
问题 2:为什么 binlog cache 是每个线程自己维护的,而 redo log buffer 是全局共用的?
回答:MySQL 这么设计的主要原因是,binlog 是不能“被打断的”。一个事务的 binlog 必须连续写,因此要整个事务完成后,再一起写到文件里。而 redo log 并没有这个要求,中间有生成的日志可以写到 redo log buffer 中。redo log buffer 中的内容还能“搭便车”,其他事务提交的时候可以被一起写到磁盘中。
另外,binlog 存储是以 statement 或者 row 格式存储的,而 redo log 是以 page 页格式存储的。page 格式,天生就是共有的,而 row 格式,只跟当前事务相关。
问题 3:事务执行期间,还没到提交阶段,如果发生 crash 的话,redo log 肯定丢了,这会不会导致主备不一致呢?
回答:不会。因为这时候 binlog 也还在 binlog cache 里,没发给备库。crash 以后 redo log 和 binlog 都没有了,从业务角度看这个事务也没有提交,所以数据是一致的。
问题 4:如果 binlog 写完盘以后发生 crash,这时候还没给客户端答复就重启了。等客户端再重连进来,发现事务已经提交成功了,这是不是 bug?
回答:不是。你可以设想一下更极端的情况,整个事务都提交成功了,redo log commit 完成了,备库也收到 binlog 并执行了。但是主库和客户端网络断开了,导致事务成功的包返回不回去,这时候客户端也会收到“网络断开”的异常。这种也只能算是事务成功的,不能认为是 bug。
实际上数据库的 crash-safe 保证的是:
- 如果客户端收到事务成功的消息,事务就一定持久化了;
- 如果客户端收到事务失败(比如主键冲突、回滚等)的消息,事务就一定失败了;
- 如果客户端收到“执行异常”的消息,应用需要重连后通过查询当前状态来继续后续的逻辑。此时数据库只需要保证内部(数据和日志之间,主库和备库之间)一致就可以了。