一、数据库状态判断
1.1、select 1 判断(主库并发阻塞无法检测–MHA(Master High Availability)的默认方案)
实际上,select 1 成功返回,只能说明这个库的进程还在,并不能说明主库没问题。现在,我们来看一下这个场景。
set global innodb_thread_concurrency=3;
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t values(1,1)
innodb_thread_concurrency 设置成 3,表示 InnoDB 只允许 3 个线程并行
执行。而在例子中,前三个 session 中的 sleep(100),使得这三个语句都处于“执
行”状态,以此来模拟大查询。
1.2、查表判断(类似系统提供健康接口探查,无法检测事务阻塞情况)
为了能够检测 InnoDB 并发线程数过多导致的系统不可用情况,我们需要找一个访问
InnoDB 的场景。一般的做法是,在系统库(mysql 库)里创建一个表,比如命名为
health_check,里面只放一行数据,然后定期执行:
1 mysql> select * from mysql.health_check;
但是,我们马上还会碰到下一个问题,即:空间满了以后,这种方法又会变得不好使。
我们知道,更新事务要写 binlog,而一旦 binlog 所在磁盘的空间占用率达到 100%,那么
所有的更新语句和事务提交的 commit 语句就都会被堵住。但是,系统这时候还是可以正常读数据的。
1.3、更新判断(耗时长,外部检测随机性)
既然要更新,就要放个有意义的字段,常见做法是放一个 timestamp 字段,用来表示最后
一次执行检测的时间。这条更新语句类似于:
1 mysql> update mysql.health_check set t_modified=now();
节点可用性的检测都应该包含主库和备库。如果用更新来检测主库的话,那么备库也要进行更新检测。
但,备库的检测也是要写 binlog 的。由于我们一般会把数据库 A 和 B 的主备关系设计为双 M 结构,所以在备库 B 上执行的检测命令,也要发回给主库 A。
但是,如果主库 A 和备库 B 都用相同的更新命令,就可能出现行冲突,也就是可能会导致主备同步停止。所以,现在看来 mysql.health_check 这个表就不能只有一行数据了。
为了让主备之间的更新不产生冲突,我们可以在 mysql.health_check 表上存入多行数据,并用 A、B 的 server_id 做主键。
由于 MySQL 规定了主库和备库的 server_id 必须不同(否则创建主备关系的时候就会报错),这样就可以保证主、备库各自的检测命令不会发生冲突。
设想一个日志盘的 IO 利用率已经是 100% 的场景。这时候,整个系统响应非常慢,
已经需要做主备切换了。
但是你要知道,IO 利用率 100% 表示系统的 IO 是在工作的,每个请求都有机会获得 IO资源,执行自己的任务。而我们的检测使用的 update 命令,需要的资源很少,所以可能在拿到 IO 资源的时候就可以提交成功,并且在超时时间 N 秒未到达之前就返回给了检测系统。
1.4、内部统计(性能损耗10%左右)
MySQL 5.6 版本以后提供的 performance_schema 库,就在file_summary_by_event_name 表里统计了每次 IO 请求的时间。
file_summary_by_event_name 表里有很多行数据,我们先来看event_name='wait/io/file/innodb/innodb_log_file’这一行。
图中这一行表示统计的是 redo log 的写入时间,第一列 EVENT_NAME 表示统计的类型。接下来的三组数据,显示的是 redo log 操作的时间统计。
第一组五列,是所有 IO 类型的统计。其中,COUNT_STAR 是所有 IO 的总次数,接下来四列是具体的统计项, 单位是皮秒;前缀 SUM、MIN、AVG、MAX,顾名思义指的就是总和、最小值、平均值和最大值。
第二组六列,是读操作的统计。最后一列 SUM_NUMBER_OF_BYTES_READ 统计的是,总共从 redo log 里读了多少个字节。
第三组六列,统计的是写操作。
最后的第四组数据,是对其他类型数据的统计。在 redo log 里,你可以认为它们就是对
fsync 的统计。
在 performance_schema 库的 file_summary_by_event_name 表里,binlog 对应的是
event_name = "wait/io/file/sql/binlog"这一行。各个字段的统计逻辑,与 redo log 的
各个字段完全相同。这里,我就不再赘述了。
因为我们每一次操作数据库,performance_schema 都需要额外地统计这些信息,所以我们打开这个统计功能是有性能损耗的
1.5、合理方案建议
是优先考虑 update 系统表,然后再配合增加检测performance_schema 的信息。
二、误删数据
2.1、分类
使用 delete 语句误删数据行;
使用 drop table 或者 truncate table 语句误删数据表;
使用 drop database 语句误删数据库;
使用 rm 命令误删整个 MySQL 实例。
2.2、误删行
我们提到如果是使用 delete 语句误删了数据行,可以用 Flashback 工具通过闪回把数据恢复回来。
Flashback 恢复数据的原理,是修改 binlog 的内容,拿回原库重放。而能够使用这个方案的前提是,需要确保 binlog_format=row 和 binlog_row_image=FULL。具体恢复数据时,对单个事务做如下处理:
对于 insert 语句,对应的 binlog event 类型是 Write_rows event,把它改成Delete_rows event 即可;
同理,对于 delete 语句,也是将 Delete_rows event 改为 Write_rows event;
而如果是 Update_rows 的话,binlog 里面记录了数据行修改前和修改后的值,对调这两行的位置即可。
如果误操作不是一个,而是多个,会怎么样呢?比如下面三个事务:
(A)delete ...
(B)insert ...
(C)update ...
现在要把数据库恢复回这三个事务操作之前的状态,用 Flashback 工具解析 binlog 后,写回主库的命令是:
(reverse C)update ...
(reverse B)delete ...
(reverse A)insert ...
也就是说,如果误删数据涉及到了多个事务的话,需要将事务的顺序调过来再执行。
需要说明的是,我不建议你直接在主库上执行这些操作。
恢复数据比较安全的做法,是恢复出一个备份,或者找一个从库作为临时库,在这个临时库上执行这些操作,然后再将确认过的临时库的数据,恢复回主库。
为什么要这么做呢?
这是因为,一个在执行线上逻辑的主库,数据状态的变更往往是有关联的。可能由于发现数据问题的时间晚了一点儿,就导致已经在之前误操作的基础上,业务代码逻辑又继续修改了其他数据。所以,如果这时候单独恢复这几行数据,而又未经确认的话,就可能会出现对数据的二次破坏。
2.3、事前预防
把 sql_safe_updates 参数设置为 on。这样一来,如果我们忘记在 delete 或者 update
语句中写 where 条件,或者 where 条件里面没有包含索引字段的话,这条语句的执行
就会报错。
代码上线前,必须经过 SQL 审计。
你可能会说,设置了 sql_safe_updates=on,如果我真的要把一个小表的数据全部删掉,
应该怎么办呢?
如果你确定这个删除操作没问题的话,可以在 delete 语句中加上 where 条件,比如where id>=0。
但是,delete 全表是很慢的,需要生成回滚日志、写 redo、写 binlog。所以,从性能角度考虑,你应该优先考虑使用 truncate table 或者 drop table 命令。
使用 delete 命令删除的数据,你还可以用 Flashback 来恢复。而使用 truncate /drop
table 和 drop database 命令删除的数据,就没办法通过 Flashback 来恢复了。为什么
呢?
这是因为,即使我们配置了 binlog_format=row,执行这三个命令时,记录的 binlog 还是 statement 格式。binlog 里面就只有一个 truncate/drop 语句,这些信息是恢复不出数据的。
那么,如果我们真的是使用这几条命令误删数据了,又该怎么办呢?
2.4、误删库 / 表
这种情况下,要想恢复数据,就需要使用全量备份,加增量日志的方式了。这个方案要求线上有定期的全量备份,并且实时备份 binlog。
在这两个条件都具备的情况下,假如有人中午 12 点误删了一个库,恢复数据的流程如下:
- 取最近一次全量备份,假设这个库是一天一备,上次备份是当天 0 点;
- 用备份恢复出一个临时库;
- 从日志备份里面,取出凌晨 0 点之后的日志;
- 把这些日志,除了误删除数据的语句外,全部应用到临时库
- 为了加速数据恢复,如果这个临时库上有多个数据库,你可以在使用 mysqlbinlog 命令时,加上一个–database 参数,用来指定误删表所在的库。这样,就避免了在恢复数据时还要应用其他库日志的情况。
- 在应用日志的时候,需要跳过 12 点误操作的那个语句的 binlog:不过,即使这样,使用 mysqlbinlog 方法恢复数据还是不够快,主要原因有两个:如果原实例没有使用 GTID 模式,只能在应用到包含 12 点的 binlog 文件的时候,先用–stop-position 参数执行到误操作之前的日志,然后再用–start-position 从误操作之后的日志继续执行;
如果实例使用了 GTID 模式,就方便多了。假设误操作命令的 GTID 是 gtid1,那么只需要执行 set gtid_next=gtid1;begin;commit; 先把这个 GTID 加到临时实例的 GTID集合,之后按顺序执行 binlog 的时候,就会自动跳过误操作的语句。
1:如果是误删表,最好就是只恢复出这张表,也就是只重放这张表的操作,但是
mysqlbinlog 工具并不能指定只解析一个表的日志;
2:用 mysqlbinlog 解析出日志应用,应用日志的过程就只能是单线程。
一种加速的方法是,在用备份恢复出临时实例之后,将这个临时实例设置成线上备库的从库,这样:
1:在 start slave 之前,先通过执行
change replication filter replicate_do_table = (tbl_name) 命令,就可以让临时库只
同步误操作的表;2:这样做也可以用上并行复制技术,来加速整个数据恢复过程。
可以看到,图中 binlog 备份系统到线上备库有一条虚线,是指如果由于时间太久,备库上
已经删除了临时实例需要的 binlog 的话,我们可以从 binlog 备份系统中找到需要的
binlog,再放回备库中。
2.5、延迟复制备库(MySQL5.6版本+核心业务方案)
如果有非常核心的业务,不允许太长的恢复时间,我们可以考虑搭建延迟复制的备库。这个功能是 MySQL 5.6 版本引入的。
一般的主备复制结构存在的问题是,如果主库上有个表被误删了,这个命令很快也会被发给所有从库,进而导致所有从库的数据表也都一起被误删了。
延迟复制的备库是一种特殊的备库,通过 CHANGE MASTER TO MASTER_DELAY = N 命令,可以指定这个备库持续保持跟主库有 N 秒的延迟。
比如你把 N 设置为 3600,这就代表了如果主库上有数据被误删了,并且在 1 小时内发现了这个误操作命令,这个命令就还没有在这个延迟复制的备库执行。这时候到这个备库上执行 stop slave,再通过之前介绍的方法,跳过误操作命令,就可以恢复出需要的数据。
这样的话,你就随时可以得到一个,只需要最多再追 1 小时,就可以恢复出数据的临时实例,也就缩短了整个数据恢复需要的时间
2.6、预防误删库 / 表的方法
2.6.1、账号分离
我们只给业务开发同学 DML 权限,而不给 truncate/drop 权限。而如果业务开发人员有 DDL 需求的话,也可以通过开发管理系统得到支持。
即使是 DBA 团队成员,日常也都规定只使用只读账号,必要的时候才使用有更新权限的账号。
2.6.2、操作规范
在删除数据表之前,必须先对表做改名操作。然后,观察一段时间,确保对业务无影响以后再删除这张表。
改表名的时候,要求给表名加固定的后缀(比如加 _to_be_deleted),然后删除表的动作必须通过管理系统执行。并且,管理系删除表的时候,只能删除固定后缀的表。
2.7、rm 删除数据
尽量把你的备份跨机房,或者最好是跨城市保存。
三、kill不生效
3.1、线程没有执行到判断线程状态的逻辑。
跟这种情况相同的,还有由于 IO 压力过大,读写 IO 的函数一直无法返回,导致不能及时判断线程
的状态。
3.2、耗时长
超大事务执行期间被 kill。这时候,回滚操作需要对事务执行期间生成的所有新数据版本
做回收操作,耗时很长。
大查询回滚。如果查询过程中生成了比较大的临时文件,加上此时文件系统压力大,删
除临时文件可能需要等待 IO 资源,导致耗时较长。
DDL 命令执行到最后阶段,如果被 kill,需要删除中间过程的临时文件,也可能受 IO 资
源影响耗时较久。
如果你发现一个线程处于 Killed 状态,你可以做的事情就是,通过影响系统环境,
让这个 Killed 状态尽快结束。
比如,如果是第一个例子里 InnoDB 并发度的问题,你就可以临时调大
innodb_thread_concurrency 的值,或者停掉别的线程,让出位子给这个线程执行。
而如果是回滚逻辑由于受到 IO 资源限制执行得比较慢,就通过减少系统压力让它加速。
四、MySQL大查询会不会把内存打爆
MySQL 采用的是边算边发的逻辑,因此对于数据量很大的查询结果来说,不会在
server 端保存完整的结果集。所以,如果客户端读结果不及时,会堵住 MySQL 的查询过程,但是不会把内存打爆。
而对于 InnoDB 引擎内部,由于有淘汰策略,大查询也不会导致内存暴涨。并且,由于InnoDB 对 LRU 算法做了改进,冷数据的全表扫描,对 Buffer Pool 的影响也能做到可控。
当然,我们前面文章有说过,全表扫描还是比较耗费 IO 资源的,所以业务高峰期还是不能直接在线上主库执行全表扫描的。
五、Join的使用问题
5.1、能不能使用Join( 能,不建议)
5.1.1、控制驱动表
select * from t1 straight_join t2 on (t1.a=t2.a);
如果直接使用 join 语句,MySQL 优化器可能会选择表 t1 或 t2 作为驱动表,这样会影响我们分析 SQL 语句的执行过程。所以,为了便于分析执行过程中的性能问题,我改用straight_join 让 MySQL 使用固定的连接方式执行查询,这样优化器只会按照我们指定的方式去 join。在这个语句里,t1 是驱动表,t2 是被驱动表。
可以看到,在这条语句里,被驱动表 t2 的字段 a 上有索引,join 过程用上了这个索引,因此这个语句的执行流程是这样的:
- 从表 t1 中读入一行数据 R;
- 从数据行 R 中,取出 a 字段到表 t2 里去查找;
- 取出表 t2 中满足条件的行,跟 R 组成一行,作为结果集的一部分;
- 重复执行步骤 1 到 3,直到表 t1 的末尾循环结束。
这个过程是先遍历表 t1,然后根据从表 t1 中取出的每行数据中的 a 值,去表 t2 中查找满足条件的记录。在形式上,这个过程就跟我们写程序时的嵌套查询类似,并且可以用上被驱动表的索引,所以我们称之为“Index Nested-Loop Join”,简称 NLJ。
在这个流程里:
- 对驱动表 t1 做了全表扫描,这个过程需要扫描 100 行;
- 而对于每一行 R,根据 a 字段去表 t2 查找,走的是树搜索过程。由于我们构造的数据都是一一对应的,因此每次的搜索过程都只扫描一行,也是总共扫描 100 行;
- 所以,整个执行流程,总扫描行数是 200。
假设单表查询
5.1.2、不控制驱动表
被驱动表上没有可用的索引,算法的流程是这样的:
select * from t1 straight_join t2 on (t1.a=t2.b);
- 把表 t1 的数据读入线程内存 join_buffer 中,由于我们这个语句中写的是 select *,因此是把整个表 t1 放入了内存;
-
扫描表 t2,把表 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回
可以看到,在这个过程中,对表 t1 和 t2 都做了一次全表扫描,因此总的扫描行数是
1100。由于 join_buffer 是以无序数组的方式组织的,因此对表 t2 中的每一行,都要做
100 次判断,总共需要在内存中做的判断次数是:100*1000=10 万次。
前面我们说过,如果使用 Simple Nested-Loop Join 算法进行查询,扫描行数也是 10 万
行。因此,从时间复杂度上来说,这两个算法是一样的。但是,Block Nested-Loop Join
算法的这 10 万次判断是内存操作,速度上会快很多,性能也更好
小结
- 如果可以使用 Index Nested-Loop Join 算法,也就是说可以用上被驱动表上的索引,其实是没问题的;
- 如果使用 Block Nested-Loop Join 算法,扫描行数就会过多。尤其是在大表上的 join操作,这样可能要扫描被驱动表很多次,会占用大量的系统资源。所以这种 join 尽量不要用。
所以你在判断要不要使用 join 语句时,就是看 explain 结果里面,Extra 字段里面有没有出现“Block Nested Loop”字样。
5.2、如果有两个大小不同的表做 join,应该用哪个表做驱动表呢?
- 如果是 Index Nested-Loop Join 算法,应该选择小表做驱动表;
- 如果是 Block Nested-Loop Join 算法:在 join_buffer_size 足够大的时候,是一样的;在 join_buffer_size 不够大的时候(这种情况更常见),应该选择小表做驱动表。
在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与 join 的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。
5.3、Join语句如何优化
5.3.1、Multi-Range Read 优化
Multi-Range Read 优化 (MRR)。这个优化的主要目的是尽量使用顺序读盘。介绍 InnoDB 的索引结构时,提到了“回表”的概念。回表是指,InnoDB 在普通索引 a 上查到主键 id 的值后,再根据一个个主键 id 的值到主键索引上去查整行数据的过程。
主键索引是一棵 B+ 树,在这棵树上,每次只能根据一个主键 id 查到一行数据。因此,回表肯定是一行行搜索主键索引的,基本流程如图 1 所示。
如果随着 a 的值递增顺序查询的话,id 的值就变成随机的,那么就会出现随机访问,性能相对较差。虽然“按行查”这个机制不能改,但是调整查询的顺序,还是能够加速的。
因为大多数的数据都是按照主键递增顺序插入得到的,所以我们可以认为,如果按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能。
这,就是 MRR 优化的设计思路。此时,语句的执行流程变成了这样:
select * from t1 where a>=1 and a<=100;
- 根据索引 a,定位到满足条件的记录,将 id 值放入 read_rnd_buffer 中 ;
- 将 read_rnd_buffer 中的 id 进行递增排序;
- 排序后的 id 数组,依次到主键 id 索引中查记录,并作为结果返回。另外需要说明的是,如果你想要稳定地使用 MRR 优化的话,需要设置setoptimizer_switch=“mrr_cost_based=off”。(官方文档的说法,是现在的优化器策略,判断消耗的时候,会更倾向于不使用 MRR,把 mrr_cost_based 设置为 off,就是固定使用 MRR 了。)
MRR 能够提升性能的核心在于,这条查询语句在索引 a 上做的是一个范围查询(也就是
说,这是一个多值查询),可以得到足够多的主键 id。这样通过排序以后,再去主键索引
查数据,才能体现出“顺序性”的优势。
5.3.2、Batched Key Access
理解了 MRR 性能提升的原理,我们就能理解 MySQL 在 5.6 版本后开始引入的 Batched Key Access(BKA) 算法了。这个 BKA 算法,其实就是对 NLJ 算法的优化。
NLJ 算法执行的逻辑是:从驱动表 t1,一行行地取出 a 的值,再到被驱动表 t2 去做
join。也就是说,对于表 t2 来说,每次都是匹配一个值。这时,MRR 的优势就用不上了。
那怎么才能一次性地多传些值给表 t2 呢?方法就是,从表 t1 里一次性地多拿些行出来,一起传给表 t2。
既然如此,我们就把表 t1 的数据取出来一部分,先放到一个临时内存。这个临时内存不是别人,就是 join_buffer。
通过上一篇文章,我们知道 join_buffer 在 BNL 算法里的作用,是暂存驱动表的数据。但是在 NLJ 算法里并没有用。那么,我们刚好就可以复用 join_buffer 到 BKA 算法中
开启优化
set optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on';
5.3.3、BNL 算法的性能问题
InnoDB 的 LRU 算法的时候提到,由于 InnoDB 对 BuffferPool 的 LRU 算法做了优化,即:第一次从磁盘读入内存的数据页,会先放在 old 区域。
如果 1 秒之后这个数据页不再被访问了,就不会被移动到 LRU 链表头部,这样对 BufferPool 的命中率影响就不大。
但是,如果一个使用 BNL 算法的 join 语句,多次扫描一个冷表,而且这个语句执行时间超过 1 秒,就会在再次扫描冷表的时候,把冷表的数据页移到 LRU 链表头部。
这种情况对应的,是冷表的数据量小于整个 Buffer Pool 的 3/8,能够完全放入 old 区域的情况。
如果这个冷表很大,就会出现另外一种情况:业务正常访问的数据页,没有机会进入
young 区域。
由于优化机制的存在,一个正常访问的数据页,要进入 young 区域,需要隔 1 秒后再次被访问到。但是,由于我们的 join 语句在循环读磁盘和淘汰内存页,进入 old 区域的数据
页,很可能在 1 秒之内就被淘汰了。这样,就会导致这个 MySQL 实例的 Buffer Pool 在这段时间内,young 区域的数据页没有被合理地淘汰。也就是说,这两种情况都会影响 Buffer Pool 的正常运作。
大表 join 操作虽然对 IO 有影响,但是在语句执行结束后,对 IO 的影响也就结束了。但是,对 Buffer Pool 的影响就是持续性的,需要依靠后续的查询请求慢慢恢复内存命中率。为了减少这种影响,你可以考虑增大 join_buffer_size 的值,减少对被驱动表的扫描次数。也就是说,BNL 算法对系统的影响主要包括三个方面:
- 可能会多次扫描被驱动表,占用磁盘 IO 资源;
- 判断 join 条件需要执行 M*N 次对比(M、N 分别是两张表的行数),如果是大表就会占用非常多的 CPU 资源;
- 可能会导致 Buffer Pool 的热数据被淘汰,影响内存命中率。
我们执行语句之前,需要通过理论分析和查看 explain 结果的方式,确认是否要使用 BNL算法。如果确认优化器会使用 BNL 算法,就需要做优化。优化的常见做法是,给被驱动表的 join 字段加上索引,把 BNL 算法转成 BKA 算法。临时表替换为业务代码里面做哈希。