binlog 和 redo log详解

binlog 的写入机制

binlog 的写入逻辑比较简单:事务执行过程中,先把日志写到 binlog cache,事务提交的时候,再把 binlog cache 写到 binlog 文件中。一个事务的 binlog 是不能被拆开的,因此不论这个事务多大,也要确保一次性写入。

系统给 binlog cache 分配了一片内存,每个线程一个,参数 binlog_cache_size 用于控制单个线程内 binlog cache 所占内存的大小。如果超过了这个参数规定的大小,就要暂存到磁盘。事务提交的时候,执行器把 binlog cache 里的完整事务写入到 binlog 中,并清空 binlog cache。每个线程有自己 binlog cache,但是共用同一份 binlog 文件。

binlog cache写入binlog也要经历两步:

  1. write,指的就是指把日志写入到文件系统的 page cache,并没有把数据持久化到磁盘,所以速度比较快。

  2. 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 日志。

redo log 的写入机制

事务在执行过程中,生成的 redo log 是要先写到 redo log buffer 的。redo log 可能存在的三种状态:

  1. 存在 redo log buffer 中,物理上是在 MySQL 进程内存中;

  2. 写到磁盘 (write),但是没有持久化(fsync),物理上是在文件系统的 page cache 里面;

  3. 持久化到磁盘,对应的是 hard disk。

日志写到 redo log buffer 是很快的,wirte 到 page cache 也差不多,但是持久化到磁盘的速度就慢多了。

为了控制 redo log 的写入策略,InnoDB 提供了 innodb_flush_log_at_trx_commit 参数,它有三种可能取值:

  • 设置为 0 的时候,表示每次事务提交时都只是把 redo log 留在 redo log buffer 中;

  • 设置为 1 的时候,表示每次事务提交时都将 redo log 直接持久化到磁盘;

  • 设置为 2 的时候,表示每次事务提交时都只是把 redo log 写到 page cache。

InnoDB 有一个后台线程,每隔 1 秒,就会把 redo log buffer 中的日志,调用 write 写到文件系统的 page cache,然后调用 fsync 持久化到磁盘。

注意,事务执行中间过程的 redo log 也是直接写在 redo log buffer 中的,这些 redo log 也会被后台线程一起持久化到磁盘。也就是说,一个没有提交的事务的 redo log,也是可能已经持久化到磁盘的。

还有两种场景会让一个没有提交的事务的 redo log 写入到磁盘中:

  1. 一种是,redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动写盘。注意,由于这个事务并没有提交,所以这个写盘动作只是 write,而没有调用 fsync,也就是只留在了文件系统的 page cache。

  2. 另一种是,并行的事务提交的时候,顺带将这个事务的 redo log buffer 持久化到磁盘。假设一个事务 A 执行到一半,已经写了一些 redo log 到 buffer 中,这时候有另外一个线程的事务 B 提交,如果 innodb_flush_log_at_trx_commit 设置的是 1,那么按照这个参数的逻辑,事务 B 要把 redo log buffer 里的日志全部持久化到磁盘。这时候,就会带上事务 A 在 redo log buffer 里的日志一起持久化到磁盘。

通常我们说 MySQL 的双1配置,指的就是 sync_binlog 和 innodb_flush_log_at_trx_commit 都设置成 1。也就是说,一个事务完整提交前,需要等待两次刷盘,一次是 redo log(prepare 阶段),一次是 binlog。

日志逻辑序列号(log sequence number,LSN)

LSN 是单调递增的,用来对应 redo log 的一个个写入点。每次写入长度为 length 的 redo log, LSN 的值就会加上 length。LSN 也会写到 InnoDB 的数据页中,来确保数据页不会被多次执行重复的 redo log。

组提交

例如我们有三个并发事务 (trx1, trx2, trx3) 在 prepare 阶段,都写完 redo log buffer,持久化到磁盘的过程,对应的 LSN 分别是 50、120 和 160,等 trx1 要开始写盘的时候,这个组里面已经有了三个事务,这时候 LSN 也变成了 160;trx1 去写盘的时候,带的就是 LSN=160,因此等 trx1 返回时,所有 LSN 小于等于 160 的 redo log,都已经被持久化到磁盘;这时候 trx2 和 trx3 就可以直接返回了。

所以,一次组提交里面,组员越多,节约磁盘 IOPS 的效果越好。在并发更新场景下,第一个事务写完 redo log buffer 以后,接下来这个 fsync 越晚调用,组员可能越多,节约 IOPS 的效果就越好。

为了让一次 fsync 带的组员更多,MySQL 有一个很有趣的优化:拖时间,具体流程就是:

1.redo log perpare write -> 2.binlog write -> 3.redo log perpare fsync -> 4.binlog fsync -> 5.redo log commit write;

注意-重点:上述流程都是在redo log 和 binlog 已经写入对应的 cache 中,并且在事务的提交阶段发生的流程,redo log perpare也是在提交阶段。

这么一来,binlog 也可以组提交了。在执行第 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 来实现。

  1. binlog_group_commit_sync_delay 参数,表示延迟多少微秒后才调用 fsync;

  2. binlog_group_commit_sync_no_delay_count 参数,表示累积多少次以后才调用 fsync。

这两个条件是或的关系,也就是说只要有一个满足条件就会调用 fsync。所以,当 binlog_group_commit_sync_delay 设置为 0 的时候,binlog_group_commit_sync_no_delay_count 也无效了。

MySQL IO 性能瓶颈提升方法

  1. 设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 参数,减少 binlog 的写盘次数。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险。

  2. 将 sync_binlog 设置为大于 1 的值(比较常见是 100~1000)。这样做的风险是,主机掉电时会丢 binlog 日志。

  3. 将 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 异常重启时就不会丢数据了,相比之下风险会更小。

总结

为什么 binlog cache 是每个线程自己维护的,而 redo log buffer 是全局共用的?
回答:MySQL 这么设计的主要原因是,binlog 是不能“被打断的”。一个事务的 binlog 必须连续写,因此要整个事务完成后,再一起写到文件里。而 redo log 并没有这个要求,中间有生成的日志可以写到 redo log buffer 中。redo log buffer 中的内容还能“搭便车”,其他事务提交的时候可以被一起写到磁盘中。

事务执行期间,还没到提交阶段,如果发生 crash 的话,redo log 肯定丢了,这会不会导致主备不一致呢?
回答:不会。因为这时候 binlog 也还在 binlog cache 里,没发给备库。crash 以后 redo log 和 binlog 都没有了,从业务角度看这个事务也没有提交,所以数据是一致的。(对应上述组提交流程)

什么情况下会使用双非1配置

  1. 业务高峰期。一般如果有预知的高峰期,DBA 会有预案,把主库设置成“非双 1”。
  2. 备库延迟,为了让备库尽快赶上主库。
  3. 批量导入数据的时候。

MySQL主备一致原理

主备原理

M-S结构

假设MySQL集群有两个节点,节点A和节点B。客户端的读写都直接访问节点 A,而节点 B 是 A 的备库,只是将 A 的更新都同步过来,到本地执行。这样可以保持节点 B 和 A 的数据是相同的。需要切换的时候,我们就将客户端读写访问的节点改为 B,而节点 A 是 B 的备库。 这就是主库和备库的使用原理。

一般情况下:我们都将备库设置为只读readonly)模式,原因有如下几点:

  1. 有时候一些运营类的查询语句会被放到备库上去查,设置为只读可以防止误操作;

  2. 防止切换逻辑有 bug,比如切换过程中出现双写,造成主备不一致;

  3. 可以用 readonly 状态,来判断节点的角色。

备库设置成只读,还怎么跟主库保持同步更新呢?readonly 设置对超级 (super) 权限用户是无效的,而用于同步更新的线程,就拥有超级权限。

双M结构

双 M 结构和 M-S 结构,其实区别只是多了一条线,即:节点 A 和 B 之间总是互为主备关系。这样在切换的时候就不用再修改主备关系。(区别就是没有备库,不需要将其中一个节点设置为 readonly模式)。

但是,双 M 结构还有一个问题需要解决,业务逻辑在节点 A 上更新了一条语句,然后再把生成的 binlog 发给节点 B,节点 B 执行完这条更新语句后也会生成 binlog。那么,如果节点 A 同时是节点 B 的备库,相当于又把节点 B 新生成的 binlog 拿过来执行了一次,然后节点 A 和 B 间,会不断地循环执行这个更新语句,也就是循环复制了。这个要怎么解决呢?这里归结为循环复制的问题,答案在下面小节解释。

主备数据同步的内部流程

主库接收到客户端的更新请求后,执行内部事务的更新逻辑,同时写 binlog。

备库 B 跟主库 A 之间维持了一个长连接。主库 A 内部有一个线程,专门用于服务备库 B 的这个长连接。一个事务日志同步的完整过程是这样的:

  1. 在备库 B 上通过 change master 命令,设置主库 A 的 IP、端口、用户名、密码,以及要从哪个位置开始请求 binlog,这个位置包含文件名和日志偏移量。

  2. 在备库 B 上执行 start slave 命令,这时候备库会启动两个线程,就是 io_thread 和 sql_thread。其中 io_thread 负责与主库建立连接。

  3. 主库 A 校验完用户名、密码后,开始按照备库 B 传过来的位置,从本地读取 binlog,发给 B。

  4. 备库 B 拿到 binlog 后,写到本地文件,称为中转日志(relay log)。

  5. sql_thread 读取中转日志,解析出日志里的命令,并执行。

注意:由于多线程复制方案的引入,sql_thread 演化成为了多个线程

binlog的三种格式

statement

当 binlog_format=statement 时,binlog 里面记录的就是 SQL 语句的原文。

你可以用 show binlog events in ‘master.000001’; 命令看 binlog 中的内容。

statement 格式下,记录到 binlog 里的是语句原文,这样写是有风险的。因为我们执行一条语句,在主库和备库上执行结果又可能是不一样的(例如我们之前学的,MySQL为什么会选错索引,删除的时候用 limit = 1 限制,就会删除的数据不一样),这样就会出现问题。

row

row格式的binlog记录了非常详细的内容,有server id:事务是在 server_id=xxx 的这个库上执行的。Table_map event:用于说明要操作的表。以及我们插入更新删除数据的所有字段信息,字段值信息,新旧数据信息等,非常全面。

这样就保证了,我们每操作一个数据,读不会出现操作错误的问题,因为我们有数据的所有信息。

mixed

为什么会有 mixed 这种 binlog 格式的存在场景?

  1. 因为有些 statement 格式的 binlog 可能会导致主备不一致,所以要使用 row 格式。

  2. 但 row 格式的缺点是,很占空间。比如你用一个 delete 语句删掉 10 万行数据,用 statement 的话就是一个 SQL 语句被记录到 binlog 中,占用几十个字节的空间。但如果用 row 格式的 binlog,就要把这 10 万条记录都写到 binlog 中。这样做,不仅会占用更大的空间,同时写 binlog 也要耗费 IO 资源,影响执行速度。

  3. 所以,MySQL 就取了个折中方案,也就是有了 mixed 格式的 binlog。mixed 格式的意思是,MySQL 自己会判断这条 SQL 语句是否可能引起主备不一致,如果有可能,就用 row 格式,否则就用 statement 格式。

也就是说,mixed 格式可以利用 statment 格式的优点,同时又避免了数据不一致的风险。

现在越来越多的场景要求把 MySQL 的 binlog 格式设置成 row。原因如下:

  1. 比如我们现在执行了一个删除语句,因为row 格式的 binlog 也会把被删掉的行的整行信息保存起来。所以,如果你在执行完一条 delete 语句以后,发现删错数据了,可以直接把 binlog 中记录的 delete 语句转成 insert,把被错删的数据插入回去就可以恢复了。

  2. 如果执行的是 update 语句的话,binlog 里面会记录修改前整行的数据和修改后的整行数据。所以,如果你误执行了 update 语句的话,只需要把这个 event 前后的两行信息对调一下,再去数据库里面执行,就能恢复这个更新操作了。

上述循环复制问题解决

MySQL 在 binlog 中记录了这个命令第一次执行时所在实例的 server id。因此,我们可以用下面的逻辑,来解决两个节点间的循环复制的问题:

  1. 规定两个库的 server id 必须不同,如果相同,则它们之间不能设定为主备关系;

  2. 一个备库接到 binlog 并在重放的过程中,生成与原 binlog 的 server id 相同的新的 binlog;

  3. 每个库在收到从自己的主库发过来的日志后,先判断 server id,如果跟自己的相同,表示这个日志是自己生成的,就直接丢弃这个日志。

按照这个逻辑,如果我们设置了双 M 结构,日志的执行流就会变成这样:

  1. 从节点 A 更新的事务,binlog 里面记的都是 A 的 server id;

  2. 传到节点 B 执行一次以后,节点 B 生成的 binlog 的 server id 也是 A 的 server id;

  3. 再传回给节点 A,A 判断到这个 server id 与自己的相同,就不会再处理这个日志。所以,死循环在这里就断掉了。

MySQL是如何保证高可用的

主备延迟

主备切换可能是一个主动运维动作,比如软件升级、主库所在机器按计划下线等,也可能是被动操作,比如主库所在机器掉电。

先说明一个概念,即“同步延迟”。与数据同步有关的时间点主要包括以下三个:

  1. 主库 A 执行完成一个事务,写入 binlog,我们把这个时刻记为 T1;

  2. 之后传给备库 B,我们把备库 B 接收完这个 binlog 的时刻记为 T2;

  3. 备库 B 执行完成这个事务,我们把这个时刻记为 T3。

所谓主备延迟,就是同一个事务,在备库执行完成的时间和主库执行完成的时间之间的差值,也就是 T3-T1。

可以在备库上执行 show slave status 命令,它的返回结果里面会显示 seconds_behind_master,用于表示当前备库延迟了多少秒。

seconds_behind_master 的计算方法是这样的:

  1. 每个事务的 binlog 里面都有一个时间字段,用于记录主库上写入的时间;

  2. 备库取出当前正在执行的事务的时间字段的值,计算它与当前系统时间的差值,得到 seconds_behind_master。

可以看到,其实 seconds_behind_master 这个参数计算的就是 T3-T1。所以,我们可以用 seconds_behind_master 来作为主备延迟的值,这个值的时间精度是秒。在网络正常的时候,日志从主库传给备库所需的时间是很短的。

所以,主备延迟最直接的表现是,备库消费中转日志(relay log)的速度,比主库生产 binlog 的速度要慢。

主备延迟的来源

  1. 备库所在机器的性能要比主库所在的机器性能差。

  2. 备库的压力大(由于主库直接影响业务,大家使用起来会比较克制,反而忽视了备库的压力控制。结果就是,备库上的查询耗费了大量的 CPU 资源,影响了同步速度,造成主备延迟。这种情况我们可以使用一主多从。除了备库外,可以多接几个从库,让这些从库来分担读的压力)。

  3. 大事务。因为主库上必须等事务执行完成才会写入 binlog,再传给备库。所以,如果一个主库上的语句执行 10 分钟,那这个事务很可能就会导致从库延迟 10 分钟。(一次性地用 delete 语句删除太多数据,这就是一个大事务典型场景。另一种典型的大事务场景,就是大表 DDL)

  4. 备库的并行复制能力(下一章节的重点内容)。

可靠性优先策略

在双M的架构下,我们可以这样保证数据的可靠性:

  1. 判断备库 B 现在的 seconds_behind_master,如果小于某个值(比如 5 秒)继续下一步,否则持续重试这一步;

  2. 把主库 A 改成只读状态,即把 readonly 设置为 true;

  3. 判断备库 B 的 seconds_behind_master 的值,直到这个值变成 0 为止;

  4. 把备库 B 改成可读写状态,也就是把 readonly 设置为 false;

  5. 把业务请求切到备库 B。

这个切换流程中是有不可用时间的。因为在步骤 2 之后,主库 A 和备库 B 都处于 readonly 状态,也就是说这时系统处于不可写状态,直到步骤 5 完成后才能恢复。试想如果一开始主备延迟就长达 30 分钟,而不先做判断直接切换的话,系统的不可用时间就会长达 30 分钟,这种情况一般业务都是不可接受的。

可用性优先策略

如果强行把上面可靠性优先步骤 4、5 调整到最开始执行,也就是说不等主备数据同步,直接把连接切到备库 B,并且让备库 B 可以读写,那么系统几乎就没有不可用时间了。我们把这个切换流程,暂时称作可用性优先流程。这个切换流程的代价,就是可能出现数据不一致的情况

大多数情况下,都建议使用可靠性优先策略。毕竟对数据服务来说的话,数据的可靠性一般还是要优于可用性的。

备库并行复制能力

为什么要有多线程复制呢?这是因为单线程复制的能力全面低于多线程复制,对于更新压力较大的主库,备库是可能一直追不上主库的。从现象上看就是,备库上 seconds_behind_master 的值越来越大。

日志在备库上的执行,就是主备数据同步流程中备库上 sql_thread 更新数据 (DATA) 的逻辑。如果是用单线程的话,就会导致备库应用日志不够快,造成主备延迟。在官方的 5.6 版本之前,MySQL 只支持单线程复制,由此在主库并发高、TPS 高时就会出现严重的主备延迟问题。

MariaDB 的并行复制策略

MariaDB 的并行复制策略利用的是 redo log 组提交 (group commit) 优化 的特性:

  1. 能够在同一组里提交的事务,一定不会修改同一行;(重点理解

  2. 主库上可以并行执行的事务,备库上也一定是可以并行执行的。

在实现上,MariaDB 是这么做的:

  1. 在一组里面一起提交的事务,有一个相同的 commit_id,下一组就是 commit_id+1;

  2. commit_id 直接写到 binlog 里面;

  3. 传到备库应用的时候,相同 commit_id 的事务分发到多个 worker 执行;

  4. 这一组全部执行完成后,coordinator 再去取下一批。

但是,这个策略有一个问题,它并没有实现“真正的模拟主库并发度”这个目标。在主库上,一组事务在 commit 的时候,下一组事务是同时处于“执行中”状态的。在备库上执行的时候,要等第一组事务完全执行完成后,第二组事务才能开始执行,这样系统的吞吐量就不够。另外,这个方案很容易被大事务拖后腿,例如有三个事务(trx1, trx2, trx3),这一组事务需要全部执行都完成后,coordinator 再去取下一批,假设 trx2 是一个超大事务,那么在备库应用的时候,trx1 和 trx3 执行完成后,就只能等 trx2 完全执行完成,下一组才能开始执行。这段时间,只有一个 worker 线程在工作,是对资源的浪费。

MySQL 5.7 的并行复制策略

在 MariaDB 并行复制实现之后,官方的 MySQL5.7 版本也提供了类似的功能,由参数 slave-parallel-type 来控制并行复制策略:

  1. 配置为 DATABASE,表示使用 MySQL 5.6 版本的按库并行策略;

  2. 配置为 LOGICAL_CLOCK,表示的就是类似 MariaDB 的策略。不过,MySQL 5.7 这个策略,针对并行度做了优化。这个优化的思路也很有趣儿。

同时处于“执行状态”的所有事务,是不是可以并行?答案是,不能。因为,这里面可能有由于锁冲突而处于锁等待状态的事务。如果这些事务在备库上被分配到不同的 worker,就会出现备库跟主库不一致的情况。而上面提到的 MariaDB 这个策略的核心,是“所有处于 commit”状态的事务可以并行。事务处于 commit 状态,表示已经通过了锁冲突的检验了。

其实,不用等到 commit 阶段,只要能够到达 redo log prepare 阶段,就表示事务已经通过锁冲突的检验了。因此,MySQL 5.7 并行复制策略的思想是:

  1. 同时处于 prepare 状态的事务,在备库执行时是可以并行的;

  2. 处于 prepare 状态的事务,与处于 commit 状态的事务之间,在备库执行时也是可以并行的。

MySQL 5.7.22 的并行复制策略

在 2018 年 4 月份发布的 MySQL 5.7.22 版本里,MySQL 增加了一个新的并行复制策略,基于 WRITESET 的并行复制。相应地,新增了一个参数 binlog-transaction-dependency-tracking,用来控制是否启用这个新策略。这个参数的可选值有以下三种:

  1. COMMIT_ORDER,表示的就是前面介绍的,根据同时进入 prepare 和 commit 来判断是否可以并行的策略。

  2. WRITESET,表示的是对于事务涉及更新的每一行,计算出这一行的 hash 值,组成集合 writeset。如果两个事务没有操作相同的行,也就是说它们的 writeset 没有交集,就可以并行。(为了唯一标识,这个 hash 值是通过“库名 + 表名 + 索引名 + 值”计算出来的。如果一个表上除了有主键索引外,还有其他唯一索引,那么对于每个唯一索引,insert 语句对应的 writeset 就要多增加一个 hash 值。)

  3. WRITESET_SESSION,是在 WRITESET 的基础上多了一个约束,即在主库上同一个线程先后执行的两个事务,在备库执行的时候,要保证相同的先后顺序。

MySQL 官方的这个实现还是有很大的优势:

  1. writeset 是在主库生成后直接写入到 binlog 里面的,这样在备库执行的时候,不需要解析 binlog 内容(event 里的行数据),节省了很多计算量;

  2. 不需要把整个事务的 binlog 都扫一遍才能决定分发到哪个 worker,更省内存;

  3. 由于备库的分发策略不依赖于 binlog 内容,所以 binlog 是 statement 格式也是可以的。

主备切换

今天我们要讨论的就是,在一主多从架构下,主库故障后的主备切换问题。

假设我们有一个MySQL集群,主备关系: A 和 A’互为主备, 从库 B、C、D 指向的是主库 A。一主多从的设置,一般用于读写分离,主库负责所有的写入和一部分读,其他的读请求则由从库分担。

基于位点的主备切换

当我们把节点 B 设置成节点 A’的从库的时候,需要执行一条 change master 命令:

1
2
3
4
5
6
7
CHANGE MASTER TO
MASTER_HOST=$host_name
MASTER_PORT=$port
MASTER_USER=$user_name
MASTER_PASSWORD=$password
MASTER_LOG_FILE=$master_log_name
MASTER_LOG_POS=$master_log_pos

这条命令有这么 6 个参数:MASTER_HOST、MASTER_PORT、MASTER_USER 和 MASTER_PASSWORD 四个参数,分别代表了主库 A’的 IP、端口、用户名和密码。最后两个参数 MASTER_LOG_FILE 和 MASTER_LOG_POS 表示,要从主库的 master_log_name 文件的 master_log_pos 这个位置的日志继续同步。而这个位置就是我们所说的同步位点,也就是主库对应的文件名和日志偏移量。

节点 B 要设置成 A’的从库,就要执行 change master 命令,就不可避免地要设置位点的这两个参数,但是这两个参数到底应该怎么设置呢?原来节点 B 是 A 的从库,本地记录的也是 A 的位点。但是相同的日志,A 的位点和 A’的位点是不同的。因此,从库 B 要切换的时候,就需要先经过“找同步位点”这个逻辑。这个位点很难精确取到,只能取一个大概位置。

考虑到切换过程中不能丢数据,所以我们找位点的时候,总是要找一个“稍微往前”的,然后再通过判断跳过那些在从库 B 上已经执行过的事务。例如我们找到的位点,但是我们需要稍微往前一点,正好这里面包了了A’库和B库都执行过了的一条插入语句,这样B库在执行的时候,就会把插入 R 这一行数据的 binlog 又同步到从库 B 去执行。这时候,从库 B 的同步线程就会报告 Duplicate entry ‘id_of_R’ for key ‘PRIMARY’ 错误,提示出现了主键冲突,然后停止同步。

通常情况下,我们在切换任务的时候,要先主动跳过这些错误,有两种常用的方法:

  1. 主动跳过一个事务。跳过命令的写法是:
1
2
set global sql_slave_skip_counter=1;
start slave;

因为切换过程中,可能会不止重复执行一个事务,所以我们需要在从库 B 刚开始接到新主库 A’时,持续观察,每次碰到这些错误就停下来,执行一次跳过命令,直到不再出现停下来的情况,以此来跳过可能涉及的所有事务.

  1. 通过设置 slave_skip_errors 参数,直接设置跳过指定的错误:
1
2
1062 错误是插入数据时唯一键冲突;
1032 错误是删除数据时找不到行

这里需要注意的是,这种直接跳过指定错误的方法,针对的是主备切换时,由于找不到精确的同步位点,所以只能采用这种方法来创建从库和新主库的主备关系。等到主备间的同步关系建立完成,并稳定执行一段时间之后,我们还需要把这个参数设置为空,以免之后真的出现了主从数据不一致,也跳过了。

GTID

通过 sql_slave_skip_counter 跳过事务和通过 slave_skip_errors 忽略错误的方法,虽然都最终可以建立从库 B 和新主库 A’的主备关系,但这两种操作都很复杂,而且容易出错。所以,MySQL 5.6 版本引入了 GTID,彻底解决了这个困难。

GTID 的全称是 Global Transaction Identifier,也就是全局事务 ID,是一个事务在提交的时候生成的,是这个事务的唯一标识。它由两部分组成,格式是:GTID=server_uuid:gno

  • server_uuid 是一个实例第一次启动时自动生成的,是一个全局唯一的值;

  • gno 是一个整数,初始值是 1,每次提交事务的时候分配给这个事务,并加 1。

GTID 模式的启动也很简单,我们只需要在启动一个 MySQL 实例的时候,加上参数 gtid_mode=on 和 enforce_gtid_consistency=on 就可以了。

在 GTID 模式下,每个事务都会跟一个 GTID 一一对应。这个 GTID 有两种生成方式,而使用哪种方式取决于 session 变量 gtid_next 的值(重点)。

  1. 如果 gtid_next=automatic,代表使用默认值。这时,MySQL 就会把 server_uuid:gno 分配给这个事务。a. 记录 binlog 的时候,先记录一行 SET @@SESSION.GTID_NEXT=‘server_uuid:gno’; b. 把这个 GTID 加入本实例的 GTID 集合。

  2. 如果 gtid_next 是一个指定的 GTID 的值,比如通过 set gtid_next=’current_gtid’指定为 current_gtid,那么就有两种可能:a. 如果 current_gtid 已经存在于实例的 GTID 集合中,接下来执行的这个事务会直接被系统忽略;b. 如果 current_gtid 没有存在于实例的 GTID 集合中,就将这个 current_gtid 分配给接下来要执行的事务,也就是说系统不需要给这个事务生成新的 GTID,因此 gno 也不用加 1。

这样,每个 MySQL 实例都维护了一个 GTID 集合,用来对应“这个实例执行过的所有事务”。

基于 GTID 的主备切换

在 GTID 模式下,备库 B 要设置为新主库 A’的从库的语法如下:

1
2
3
4
5
6
CHANGE MASTER TO
MASTER_HOST=$host_name
MASTER_PORT=$port
MASTER_USER=$user_name
MASTER_PASSWORD=$password
master_auto_position=1

其中,master_auto_position=1 就表示这个主备关系使用的是 GTID 协议。可以看到,前面让我们头疼不已的 MASTER_LOG_FILE 和 MASTER_LOG_POS 参数,已经不需要指定了。

我们把现在这个时刻,实例 A’的 GTID 集合记为 set_a,实例 B 的 GTID 集合记为 set_b。接下来,我们就看看现在的主备切换逻辑。我们在实例 B 上执行 start slave 命令,取 binlog 的逻辑是这样的:

  1. 实例 B 指定主库 A’,基于主备协议建立连接。

  2. 实例 B 把 set_b 发给主库 A’。

  3. 实例 A’算出 set_a 与 set_b 的差集,也就是所有存在于 set_a,但是不存在于 set_b 的 GTID 的集合,判断 A’本地是否包含了这个差集需要的所有 binlog 事务。a. 如果不包含,表示 A’已经把实例 B 需要的 binlog 给删掉了,直接返回错误;b. 如果确认全部包含,A’从自己的 binlog 文件里面,找出第一个不在 set_b 的事务,发给 B;

  4. 之后就从这个事务开始,往后读文件,按顺序取 binlog 发给 B 去执行。

由于不需要找位点了,所以从库 B、C、D 只需要分别执行 change master 命令指向实例 A’即可。其实,严谨地说,主备切换不是不需要找位点了,而是找位点这个工作,在实例 A’内部就已经自动完成了。但由于这个工作是自动的,所以对 HA 系统的开发人员来说,非常友好。

GTID 和在线 DDL

业务高峰期的慢查询性能问题时,分析到如果是由于索引缺失引起的性能问题,我们可以通过在线加索引来解决。但是,考虑到要避免新增索引对主库性能造成的影响,我们可以先在备库加索引,然后再切换。

假设,这两个互为主备关系的库还是实例 X 和实例 Y,且当前主库是 X,并且都打开了 GTID 模式。这时的主备切换流程可以变成下面这样:

  1. 在实例 X 上执行 stop slave。

  2. 在实例 Y 上执行 DDL 语句。注意,这里并不需要关闭 binlog。

  3. 执行完成后,查出这个 DDL 语句对应的 GTID,并记为 server_uuid_of_Y:gno。

  4. 到实例 X 上执行以下语句序列:

1
2
3
4
5
set GTID_NEXT="server_uuid_of_Y:gno";
begin;
commit;
set gtid_next=automatic;
start slave;

这样做的目的在于,既可以让实例 Y 的更新有 binlog 记录,同时也可以确保不会在实例 X 上执行这条更新。

  1. 接下来,执行完主备切换,然后照着上述流程再执行一遍即可。

读写分离的主从延迟

读写分离的主要目标就是分摊主库的压力。一种架构是:客户端(client)主动做负载均衡,这种模式下一般会把数据库的连接信息放在客户端的连接层。也就是说,由客户端来选择后端数据库进行查询。第二种架构是:在 MySQL 和客户端之间有一个中间代理层 proxy,客户端只连接 proxy, 由 proxy 根据请求类型和上下文决定请求的分发路由。

客户端直连和带 proxy 的读写分离架构,各有哪些特点:

  1. 客户端直连方案,因为少了一层 proxy 转发,所以查询性能稍微好一点儿,并且整体架构简单,排查问题更方便。但是这种方案,由于要了解后端部署细节,所以在出现主备切换、库迁移等操作的时候,客户端都会感知到,并且需要调整数据库连接信息。你可能会觉得这样客户端也太麻烦了,信息大量冗余,架构很丑。其实也未必,一般采用这样的架构,一定会伴随一个负责管理后端的组件,比如 Zookeeper,尽量让业务端只专注于业务逻辑开发。

  2. 带 proxy 的架构,对客户端比较友好。客户端不需要关注后端细节,连接维护、后端信息维护等工作,都是由 proxy 完成的。但这样的话,对后端维护团队的要求会更高。而且,proxy 也需要有高可用架构。因此,带 proxy 架构的整体就相对比较复杂。

不论使用哪种架构,都会碰到问题:由于主从可能存在延迟,客户端执行完一个更新事务后马上发起查询,如果查询选择的是从库的话,就有可能读到刚刚的事务更新之前的状态。“在从库上会读到系统的一个过期状态”的现象,称为过期读

处理过期读的方案

处理过期读包括以下6种方案:

  • 强制走主库方案;
  • sleep 方案;
  • 判断主备无延迟方案;
  • 配合 semi-sync 方案;
  • 等主库位点方案;
  • 等 GTID 方案

强制走主库方案

强制走主库方案其实就是,将查询请求做分类。通常情况下,我们可以将查询请求分为这么两类:

  1. 对于必须要拿到最新结果的请求,强制将其发到主库上。

  2. 对于可以读到旧数据的请求,才将其发到从库上。

这个方案最大的问题在于,有时候你会碰到“所有查询都不能是过期读”的需求,比如一些金融类的业务。这样的话,你就要放弃读写分离,所有读写压力都在主库,等同于放弃了扩展性。

Sleep 方案

主库更新后,读从库之前先 sleep 一下。具体的方案就是,类似于执行一条 select sleep(1) 命令。这个方案的假设是,大多数情况下主备延迟在 1 秒之内,做一个 sleep 可以有很大概率拿到最新的数据。

这个方案比较麻瓜,个人觉得还是不要用了。

判断主备无延迟方案

要确保备库无延迟,通常有三种做法:

  1. 判断seconds_behind_master 参数的值确保无延迟:每次从库执行查询请求前,先判断 seconds_behind_master 是否已经等于 0。如果还不等于 0 ,那就必须等到这个参数变为 0 才能执行查询请求(seconds_behind_master 的单位是秒,存在精度不够的问题)。

  2. 对比位点确保主备无延迟:

1
2
Master_Log_File 和 Read_Master_Log_Pos,表示的是读到的主库的最新位点;
Relay_Master_Log_File 和 Exec_Master_Log_Pos,表示的是备库执行的最新位点。

如果 Master_Log_File 和 Relay_Master_Log_File、Read_Master_Log_Pos 和 Exec_Master_Log_Pos 这两组值完全相同,就表示接收到的日志已经同步完成。

  1. 对比 GTID 集合确保主备无延迟:
1
2
3
Auto_Position=1,表示这对主备关系使用了 GTID 协议。
Retrieved_Gtid_Set,是备库收到的所有日志的 GTID 集合;
Executed_Gtid_Set,是备库所有已经执行完成的 GTID 集合。

如果这两个集合相同,也表示备库接收到的日志都已经同步完成。可见,对比位点和对比 GTID 这两种方法,都要比判断 seconds_behind_master 是否为 0 更准确。

我们上面判断主备无延迟的逻辑,是备库收到的日志都执行完成了。但是,从 binlog 在主备之间状态的分析中,不难看出还有一部分日志,处于客户端已经收到提交确认,而备库还没收到日志的状态。这种情况下,我们去备库做查询,按照我们上面的逻辑,从库认为已经没有同步延迟,但还是没有查到主库上最新的事务,严格地说,就是出现了过期读。所以还是有有几率出现问题。

配合 semi-sync

引入半同步复制,也就是 semi-sync replication。semi-sync 做了这样的设计:

  1. 事务提交的时候,主库把 binlog 发给从库;

  2. 从库收到 binlog 以后,发回给主库一个 ack,表示收到了;

  3. 主库收到这个 ack 以后,才能给客户端返回“事务完成”的确认。

也就是说,如果启用了 semi-sync,就表示所有给客户端发送过确认的事务,都确保了备库已经收到了这个日志。这样,semi-sync 配合前面关于位点的判断,就能够确定在从库上执行的查询请求,可以避免过期读。

但是,semi-sync+ 位点判断的方案,只对一主一备的场景是成立的。在一主多从场景中,主库只要等到一个从库的 ack,就开始给客户端返回确认。这时,在从库上执行查询请求,就有两种情况:

  1. 如果查询是落在这个响应了 ack 的从库上,是能够确保读到最新数据;

  2. 但如果是查询落到其他从库上,它们可能还没有收到最新的日志,就会产生过期读的问题。

判断同步位点的方案还有另外一个潜在的问题,即:如果在业务更新的高峰期,主库的位点或者 GTID 集合更新很快,那么上面的两个位点等值判断就会一直不成立,很可能出现从库上迟迟无法响应查询请求的情况,主库事务一直在发生,GTIO一直在更新,导致从库一直得不到ack结束的状态。

到这里,我们小结一下,semi-sync 配合判断主备无延迟的方案,存在两个问题:

  1. 一主多从的时候,在某些从库执行查询请求会存在过期读的现象;

  2. 在持续延迟的情况下,可能出现过度等待的问题。

等主库位点方案

要理解等主库位点方案,需要介绍一条命令:select master_pos_wait(file, pos[, timeout]);

这条命令的逻辑如下:它是在从库执行的;参数 file 和 pos 指的是主库上的文件名和位置;timeout 可选,设置为正整数 N 表示这个函数最多等待 N 秒。

这个命令正常返回的结果是一个正整数 M,表示从命令开始执行,到应用完 file 和 pos 表示的 binlog 位置,执行了多少事务。除了正常返回一个正整数 M 外,这条命令还会返回一些其他结果,包括:

  1. 如果执行期间,备库同步线程发生异常,则返回 NULL;
  2. 如果等待超过 N 秒,就返回 -1;
  3. 如果刚开始执行的时候,就发现已经执行过这个位置了,则返回 0。

此方案我们执行一个事务然后执行查询语句的具体逻辑如下:

  1. trx1 事务更新完成后,马上执行 show master status 得到当前主库执行到的 File 和 Position;

  2. 选定一个从库执行查询语句;

  3. 在从库上执行 select master_pos_wait(File, Position, 1);

  4. 如果返回值是 >=0 的正整数,则在这个从库执行查询语句;

  5. 否则,到主库执行查询语句。

这里我们假设,这条 select 查询最多在从库上等待 1 秒。那么,如果 1 秒内 master_pos_wait 返回一个大于等于 0 的整数,就确保了从库上执行的这个查询结果一定包含了 trx1 的数据。步骤 5 到主库执行查询语句,是这类方案常用的退化机制。因为从库的延迟时间不可控,不能无限等待,所以如果等待超时,就应该放弃,然后到主库去查。你可能会说,如果所有的从库都延迟超过 1 秒了,那查询压力不就都跑到主库上了吗?确实是这样。

GTID 方案

MySQL 中同样提供了一个类似的命令:select wait_for_executed_gtid_set(gtid_set, 1);

这条命令的逻辑是:等待,直到这个库执行的事务中包含传入的 gtid_set,返回 0;超时返回 1。

在前面等位点的方案中,我们执行完事务后,还要主动去主库执行 show master status。而 MySQL 5.7.6 版本开始,允许在执行完更新类事务后,把这个事务的 GTID 返回给客户端,这样等 GTID 的方案就可以减少一次查询。这时,等 GTID 的执行流程就变成了:

  1. trx1 事务更新完成后,从返回包直接获取这个事务的 GTID,记为 gtid1;

  2. 选定一个从库执行查询语句;

  3. 在从库上执行 select wait_for_executed_gtid_set(gtid1, 1);

  4. 如果返回值是 0,则在这个从库执行查询语句;

  5. 否则,到主库执行查询语句。

跟等主库位点的方案一样,等待超时后是否直接到主库查询,需要业务开发同学来做限流考虑。

MySQL 在执行事务后,返回包中带上 GTID,只需要将参数 session_track_gtids 设置为 OWN_GTID,然后通过 API 接口 mysql_session_track_get_first 从返回包解析出 GTID 的值即可。

小结

上述几种方案中,有的方案看上去是做了妥协,有的方案看上去不那么靠谱儿,但都是有实际应用场景的,你需要根据业务需求选择。即使是最后等待位点和等待 GTID 这两个方案,虽然看上去比较靠谱儿,但仍然存在需要权衡的情况。如果所有的从库都延迟,那么请求就会全部落到主库上,这时候会不会由于压力突然增大,把主库打挂了呢?

在实际应用中,这几个方案是可以混合使用的。比如,先在客户端对请求做分类,区分哪些请求可以接受过期读,而哪些请求完全不能接受过期读;然后,对于不能接受过期读的语句,再使用等 GTID 或等位点的方案。

判断主库是否出问题的策略

主备切换有两种场景,一种是主动切换,一种是被动切换。而其中被动切换,往往是因为主库出问题了,由 HA 系统发起的。

外部检测

外部检测都需要定时轮询,所以系统可能已经出问题了,但是却需要等到下一个检测发起执行语句的时候,我们才有可能发现问题。而且,如果你的运气不够好的话,可能第一次轮询还不能发现,这就会导致切换慢的问题。以下是一些外部检测的常用方案。

select 1 判断

select 1 成功返回,只能说明这个库的进程还在,并不能说明主库没问题。

并发连接和并发查询:并发连接和并发查询,并不是同一个概念。你在 show processlist 的结果里,看到的几千个连接,指的就是并发连接。而“当前正在执行”的语句,才是我们所说的并发查询。并发连接数达到几千个影响并不大,就是多占一些内存而已。我们应该关注的是并发查询,因为并发查询太高才是 CPU 杀手。这也是为什么我们需要设置 innodb_thread_concurrency 参数的原因。在线程进入锁等待以后,并发线程的计数会减一,也就是说等行锁(也包括间隙锁)的线程是不算在innodb_thread_concurrency 里面的。

查表判断

为了能够检测 InnoDB 并发线程数过多导致的系统不可用情况,我们需要找一个访问 InnoDB 的场景。一般的做法是,在系统库(mysql 库)里创建一个表,比如命名为 health_check,里面只放一行数据,然后定期执行查询操作。使用这个方法,我们可以检测出由于并发线程过多导致的数据库不可用的情况。但是,空间满了以后,这种方法又会变得不好使。

我们知道,更新事务要写 binlog,而一旦 binlog 所在磁盘的空间占用率达到 100%,那么所有的更新语句和事务提交的 commit 语句就都会被堵住。但是,系统这时候还是可以正常读数据的。所以,此种判断逻辑,还是存在问题的。

更新判断

既然要更新,就要放个有意义的字段,常见做法是放一个 timestamp 字段,用来表示最后一次执行检测的时间。节点可用性的检测都应该包含主库和备库。如果用更新来检测主库的话,那么备库也要进行更新检测。但,备库的检测也是要写 binlog 的。由于我们一般会把数据库 A 和 B 的主备关系设计为双 M 结构,所以在备库 B 上执行的检测命令,也要发回给主库 A。如果主库 A 和备库 B 都用相同的更新命令,就可能出现行冲突,也就是可能会导致主备同步停止。所以,现在看来 mysql.health_check 这个表就不能只有一行数据了。为了让主备之间的更新不产生冲突,我们可以在 mysql.health_check 表上存入多行数据,并用 A、B 的 server_id 做主键。由于 MySQL 规定了主库和备库的 server_id 必须不同(否则创建主备关系的时候就会报错),这样就可以保证主、备库各自的检测命令不会发生冲突。

更新判断是一个相对比较常用的方案了,不过依然存在一些问题。比如 判断慢:首先,所有的检测逻辑都需要一个超时时间 N。执行一条 update 语句,超过 N 秒后还不返回,就认为系统不可用。你可以设想一个日志盘的 IO 利用率已经是 100% 的场景。这时候,整个系统响应非常慢,已经需要做主备切换了。但是你要知道,IO 利用率 100% 表示系统的 IO 是在工作的,每个请求都有机会获得 IO 资源,执行自己的任务。而我们的检测使用的 update 命令,需要的资源很少,所以可能在拿到 IO 资源的时候就可以提交成功,并且在超时时间 N 秒未到达之前就返回给了检测系统。检测系统一看,update 命令没有超时,于是就得到了“系统正常”的结论。

内部统计

针对磁盘利用率这个问题,如果 MySQL 可以告诉我们,内部每一次 IO 请求的时间,那我们判断数据库是否出问题的方法就可靠得多了。其实,MySQL 5.6 版本以后提供的 performance_schema 库,就在 file_summary_by_event_name 表里统计了每次 IO 请求的时间。因为我们每一次操作数据库,performance_schema 都需要额外地统计这些信息,所以我们打开这个统计功能是有性能损耗的。

可以通过 MAX_TIMER 的值来判断数据库是否出问题了。比如,你可以设定阈值,单次 IO 请求时间超过 200 毫秒属于异常,然后使用类似下面这条语句作为检测逻辑:

1
mysql> select event_name,MAX_TIMER_WAIT  FROM performance_schema.file_summary_by_event_name where event_name in ('wait/io/file/innodb/innodb_log_file','wait/io/file/sql/binlog') and MAX_TIMER_WAIT>200*1000000000;

发现异常后,取到你需要的信息,再通过下面这条语句:

1
mysql> truncate table performance_schema.file_summary_by_event_name;

把之前的统计信息清空。这样如果后面的监控中,再次出现这个异常,就可以加入监控累积值了。

误删数据的处理方案

误删行

误删行的恢复措施

如果是使用 delete 语句误删了数据行,可以用 Flashback 工具通过闪回把数据恢复回来。Flashback 恢复数据的原理,是修改 binlog 的内容,拿回原库重放。而能够使用这个方案的前提是,需要确保 binlog_format=row 和 binlog_row_image=FULL。

具体恢复数据时,对单个事务做如下处理:

  1. 对于 insert 语句,对应的 binlog event 类型是 Write_rows event,把它改成 Delete_rows event 即可;

  2. 同理,对于 delete 语句,也是将 Delete_rows event 改为 Write_rows event;

  3. 而如果是 Update_rows 的话,binlog 里面记录了数据行修改前和修改后的值,对调这两行的位置即可。

误删行的预防措施

  1. 把 sql_safe_updates 参数设置为 on。这样一来,如果我们忘记在 delete 或者 update 语句中写 where 条件,或者 where 条件里面没有包含索引字段的话,这条语句的执行就会报错。

  2. 代码上线前,必须经过 SQL 审计。

误删库 / 表

误删库 / 表的恢复措施

这种情况下,要想恢复数据,就需要使用全量备份,加增量日志的方式了。这个方案要求线上有定期的全量备份,并且实时备份 binlog。

在这两个条件都具备的情况下,假如有人中午 12 点误删了一个库,恢复数据的流程如下:

  1. 取最近一次全量备份,假设这个库是一天一备,上次备份是当天 0 点;
  2. 用备份恢复出一个临时库;
  3. 从日志备份里面,取出凌晨 0 点之后的日志;
  4. 把这些日志,除了误删除数据的语句外,全部应用到临时库。

注意:在应用日志的时候,需要跳过 12 点误操作的那个语句的 binlog:如果实例使用了 GTID 模式,假设误操作命令的 GTID 是 gtid1,那么只需要执行 set gtid_next=gtid1;begin;commit; 先把这个 GTID 加到临时实例的 GTID 集合,之后按顺序执行 binlog 的时候,就会自动跳过误操作的语句。

这种方式恢复数据还是不够快,主要原因有两个:

  1. 如果是误删表,最好就是只恢复出这张表,也就是只重放这张表的操作,但是 mysqlbinlog 工具并不能指定只解析一个表的日志;
  2. 用 mysqlbinlog 解析出日志应用,应用日志的过程就只能是单线程。我们之前学习的并行复制的方法这里都用不上。

延迟复制备库

一般的主备复制结构存在的问题是,如果主库上有个表被误删了,这个命令很快也会被发给所有从库,进而导致所有从库的数据表也都一起被误删了。

延迟复制的备库是一种特殊的备库,通过 CHANGE MASTER TO MASTER_DELAY = N 命令,可以指定这个备库持续保持跟主库有 N 秒的延迟。

比如你把 N 设置为 3600,这就代表了如果主库上有数据被误删了,并且在 1 小时内发现了这个误操作命令,这个命令就还没有在这个延迟复制的备库执行。这时候到这个备库上执行 stop slave,再通过之前介绍的方法,跳过误操作命令,就可以恢复出需要的数据。这样的话,你就随时可以得到一个,只需要最多再追 1 小时,就可以恢复出数据的临时实例,也就缩短了整个数据恢复需要的时间。

误删库 / 表的预防措施

第一条建议是,账号分离。这样做的目的是,避免写错命令。比如:

  1. 我们只给业务开发同学 DML 权限,而不给 truncate/drop 权限。而如果业务开发人员有 DDL 需求的话,也可以通过开发管理系统得到支持。
  2. 即使是 DBA 团队成员,日常也都规定只使用只读账号,必要的时候才使用有更新权限的账号。

第二条建议是,制定操作规范。这样做的目的,是避免写错要删除的表名。比如:

  1. 在删除数据表之前,必须先对表做改名操作。然后,观察一段时间,确保对业务无影响以后再删除这张表。
  2. 改表名的时候,要求给表名加固定的后缀(比如加 _to_be_deleted),然后删除表的动作必须通过管理系统执行。并且,管理系删除表的时候,只能删除固定后缀的表。

rm 删除数据

对于一个有高可用机制的 MySQL 集群来说,最不怕的就是 rm 删除数据了。只要不是恶意地把整个集群删除,而只是删掉了其中某一个节点的数据的话,HA 系统就会开始工作,选出一个新的主库,从而保证整个集群的正常工作。这时,你要做的就是在这个节点上把数据恢复回来,再接入整个集群。

为什么还有kill不掉的语句?

在 MySQL 中有两个 kill 命令:一个是 kill query + 线程 id,表示终止这个线程中正在执行的语句;一个是 kill connection + 线程 id,这里 connection 可缺省,表示断开这个线程的连接,当然如果这个线程有语句正在执行,也是要先停止正在执行的语句的。

有的时候我们发现,使用了 kill 命令,却没能断开这个连接。再执行 show processlist 命令,看到这条语句的 Command 列显示的是 Killed。

收到 kill 以后,线程做什么?

kill 并不是马上停止的意思,而是告诉执行线程说,这条语句已经不需要继续执行了,可以开始“执行停止的逻辑了”。其实,这跟 Linux 的 kill 命令类似,kill -N pid 并不是让进程直接停止,而是给进程发一个信号,然后进程处理这个信号,进入终止逻辑。只是对于 MySQL 的 kill 命令来说,不需要传信号量参数,就只有“停止”这个命令。

当用户执行 kill query + 线程 id 命令时,MySQL 里处理 kill 命令的线程做了两件事:

  1. 把 线程(session) 的运行状态改成 THD::KILL_QUERY(将变量 killed 赋值为 THD::KILL_QUERY);

  2. 给 线程(session) 的执行线程发一个信号。

为什么要发信号呢?

session处于锁等待状态,如果只是把 session 的线程状态设置 THD::KILL_QUERY,线程并不知道这个状态变化,还是会继续等待。发一个信号的目的,就是让 session 退出等待,来处理这个 THD::KILL_QUERY 状态。

上面的分析中,隐含了这么三层意思:

  1. 一个语句执行过程中有多处“埋点”,在这些“埋点”的地方判断线程状态,如果发现线程状态是 THD::KILL_QUERY,才开始进入语句终止逻辑;

  2. 如果处于等待状态,必须是一个可以被唤醒的等待,否则根本不会执行到“埋点”处;

  3. 语句从开始进入终止逻辑,到终止逻辑完全完成,是有一个过程的。

到这里你就知道了,原来不是“说停就停的”。

示例:一个 kill 不掉的例子,首先,执行 set global innodb_thread_concurrency=1,将 InnoDB 的并发线程上限数设置为 1;然后执行两条sql语句:

1
2
3
4
5
session A:
select sleep(100) from t;

session B:
select * from t;

我们先执行sessionA,然后执行sessionB,这时,sessionB是被阻塞住的,因为我们设置了并发线程为1,所以只有sessionA在执行。

这时候我们通过sessionC执行:kill query B; 发现是没有什么效果的。为什么呢? 在这个例子里,sessionB 线程的等待逻辑是这样的:每 10 毫秒判断一下是否可以进入 InnoDB 执行,如果不行,就调用 nanosleep 函数进入 sleep 状态。也就是说,虽然 sessionB 线程的状态已经被设置成了 KILL_QUERY,但是在这个等待进入 InnoDB 的循环过程中,线程没有被空闲出来,并没有去判断线程的状态,因此根本不会进入终止逻辑阶段。

我们可以通过sessionD执行:kill B;这个时候我们发现断开了 sessionB 的连接,提示“Lost connection to MySQL server during query”, 但是这时候,如果在 sessionD 中执行 show processlist,发现 id=sessionB 这个线程的 Commnad 列显示的是 Killed。也就是说,客户端虽然断开了连接,但实际上服务端上这条语句还在执行过程中。

sessionD 执行 kill connection 命令时,是这么做的:

  1. 把 12 号线程状态设置为 KILL_CONNECTION;
  2. 关掉 12 号线程的网络连接。因为有这个操作,所以你会看到,这时候 session C 收到了断开连接的提示。

那为什么执行 show processlist 的时候,会看到 Command 列显示为 killed 呢?其实,这就是因为在执行 show processlist 的时候,有一个特别的逻辑:如果一个线程的状态是KILL_CONNECTION,就把Command列显示成Killed。

所以其实,即使是客户端退出了,这个线程的状态仍然是在等待中。那这个线程什么时候会退出呢?答案是,只有等到满足进入 InnoDB 的条件后,session C 的查询语句继续执行,然后才有可能判断到线程状态已经变成了 KILL_QUERY 或者 KILL_CONNECTION,再进入终止逻辑阶段。

kill无效的情况总结

  1. 线程没有执行到判断线程状态的逻辑。由于 IO 压力过大,读写 IO 的函数一直无法返回,导致不能及时判断线程的状态。或者上述示例中,线程被占满,无法释放出来。

  2. 终止逻辑耗时较长。时候,从 show processlist 结果上看也是 Command=Killed,需要等到终止逻辑完成,语句才算真正完成。这类情况,比较常见的场景有以下几种:

1
2
3
1. 超大事务执行期间被 kill。这时候,回滚操作需要对事务执行期间生成的所有新数据版本做回收操作,耗时很长。
2. 大查询回滚。如果查询过程中生成了比较大的临时文件,加上此时文件系统压力大,删除临时文件可能需要等待 IO 资源,导致耗时较长。
3. DDL 命令执行到最后阶段,如果被 kill,需要删除中间过程的临时文件,也可能受 IO 资源影响耗时较久。

如果直接在客户端通过 Ctrl+C 命令,是不是就可以直接终止线程呢?答案是,不可以。其实在客户端的操作只能操作到客户端的线程,客户端和服务端只能通过网络交互,是不可能直接操作服务端线程的。

关于客户端的误解

如果库里面的表特别多,连接就会很慢

有些线上的库,会包含很多表(我见过最多的一个库里有 6 万个表)。这时候,你就会发现,每次用客户端连接都会卡住很长时间,但是我们之前学习过,个客户端在和服务端建立连接的时候,需要做的事情就是 TCP 握手、用户校验、获取权限。但这几个操作,显然跟库里面表的个数无关。实际上,当使用默认参数连接的时候,MySQL 客户端会提供一个本地库名和表名补全的功能。为了实现这个功能,客户端在连接成功后,需要多做一些操作:

  1. 执行 show databases;
  2. 切到 db1 库,执行 show tables;
  3. 把这两个命令的结果用于构建一个本地的哈希表。

在这些操作中,最花时间的就是第三步在本地构建哈希表的操作。所以,当一个库中的表个数非常多的时候,这一步就会花比较长的时间。也就是说,我们感知到的连接过程慢,其实并不是连接慢,也不是服务端慢,而是客户端慢。

如果在连接命令中加上 -A,就可以关掉这个自动补全的功能,然后客户端就可以快速返回了。

除了加 -A 以外,加–quick(或者简写为 -q) 参数,也可以跳过这个阶段。但是,这个–quick 是一个更容易引起误会的参数,也是关于客户端常见的一个误解。实际上,设置了这个参数可能会降低服务端的性能。为什么这么说呢?

MySQL 客户端发送请求后,接收服务端返回结果的方式有两种:

  1. 一种是本地缓存,也就是在本地开一片内存,先把结果存起来。如果你用 API 开发,对应的就是 mysql_store_result 方法。

  2. 另一种是不缓存,读一个处理一个。如果你用 API 开发,对应的就是 mysql_use_result 方法。

MySQL 客户端默认采用第一种方式,而如果加上–quick 参数,就会使用第二种不缓存的方式。

采用不缓存的方式时,如果本地处理得慢,就会导致服务端发送结果被阻塞,因此会让服务端变慢。但是这个参数存在的作用还是有的,使用这个参数可以达到以下三点效果:

  1. 就是前面提到的,跳过表名自动补全功能。

  2. mysql_store_result 需要申请本地内存来缓存查询结果,如果查询结果太大,会耗费较多的本地内存,可能会影响客户端本地机器的性能;

  3. 是不会把执行命令记录到本地的命令历史文件。

所以,–quick 参数的意思,是让客户端变得更快。

查询对数据库内存影响

如果我们的数据库主机内存只有 100G,现在要对一个 200G 的大表做全表扫描,会不会把数据库主机的内存用光了?但是反过来想想,逻辑备份的时候,可不就是做整库扫描吗?如果这样就会把内存吃光,逻辑备份不是早就挂了?所以说,对大表做全表扫描,看来应该是没问题的。

全表扫描对 server 层的影响

假设,我们现在要对一个 200G 的 InnoDB 表 db1. t,执行一个全表扫描。

InnoDB 的数据是保存在主键索引上的,所以全表扫描实际上是直接扫描表 t 的主键索引。这条查询语句由于没有其他的判断条件,所以查到的每一行都可以直接放到结果集里面,然后返回给客户端。那么,这个“结果集”存在哪里呢?实际上,服务端并不需要保存一个完整的结果集。取数据和发数据的流程是这样的:

  1. 获取一行,写到 net_buffer 中。这块内存的大小是由参数 net_buffer_length 定义的,默认是 16k。

  2. 重复获取行,直到 net_buffer 写满,调用网络接口发出去。

  3. 如果发送成功,就清空 net_buffer,然后继续取下一行,并写入 net_buffer。

  4. 如果发送函数返回 EAGAIN 或 WSAEWOULDBLOCK,就表示本地网络栈(socket send buffer)写满了,进入等待。直到网络栈重新可写,再继续发送。

从这个流程中,你可以看到:

  1. 一个查询在发送过程中,占用的 MySQL 内部的内存最大就是 net_buffer_length 这么大,并不会达到 200G;

  2. socket send buffer 也不可能达到 200G(默认定义 /proc/sys/net/core/wmem_default),如果 socket send buffer 被写满,就会暂停读数据的流程。

也就是说,MySQL 是“边读边发的”,这个概念很重要。这就意味着,如果客户端接收得慢,会导致 MySQL 服务端由于结果发不出去,这个事务的执行时间变长。

如果我们在服务端执行 show processlist 看到的结果有 State 的值一直处于“Sending to client”,就表示服务器端的网络栈写满了(上一节中曾提到,如果客户端使用–quick 参数,会使用 mysql_use_result 方法。这个方法是读一行处理一行。你可以想象一下,假设有一个业务的逻辑比较复杂,每读一行数据以后要处理的逻辑如果很慢,就会导致客户端要过很久才会去取下一行数据,就会出现这种情况)。

因此,对于正常的线上业务来说,如果一个查询的返回结果不会很多的话,我都建议你使用 mysql_store_result 这个接口,直接把查询结果保存到本地内存。

实际上,一个查询语句的状态变化是这样的(这里略去了其他无关的状态):

  1. MySQL 查询语句进入执行阶段后,首先把状态设置成“Sending data”;
  2. 然后,发送执行结果的列相关的信息(meta data) 给客户端;
  3. 再继续执行语句的流程;
  4. 执行完成后,把状态设置成空字符串。

也就是说,“Sending data”并不一定是指“正在发送数据”,而可能是处于执行器过程中的任意阶段。比如,你可以构造一个锁等待的场景,就能看到 Sending data 状态。

仅当一个线程处于“等待客户端接收结果”的状态,才会显示”Sending to client”;而如果显示成“Sending data”,它的意思只是“正在执行”。

全表扫描对 InnoDB 的影响

WAL 机制中InnoDB 内存的一个作用,是保存更新的结果,再配合 redo log,就避免了随机写盘。内存的数据页是在 Buffer Pool (BP) 中管理的,在 WAL 里 Buffer Pool 起到了加速更新的作用。而实际上,Buffer Pool 还有一个更重要的作用,就是加速查询。当事务提交的时候,磁盘上的数据页是旧的,这时候马上有一个查询要来读这个数据页,这时候查询根本不需要读磁盘,直接从内存拿结果,速度是很快的。所以说,Buffer Pool 还有加速查询的作用。

Buffer Pool 对查询的加速效果,依赖于一个重要的指标,即:内存命中率。InnoDB Buffer Pool 的大小是由参数 innodb_buffer_pool_size 确定的,一般建议设置成可用物理内存的 60%~80%。

你可以在 show engine innodb status 结果中,查看一个系统当前的 BP 命中率。一般情况下,一个稳定服务的线上系统,要保证响应时间符合要求的话,内存命中率要在 99% 以上。

如果所有查询需要的数据页都能够直接从内存得到,那是最好的,对应的命中率就是 100%。但,这在实际生产上是很难做到的。innodb_buffer_pool_size 小于磁盘的数据量是很常见的。如果一个 Buffer Pool 满了,而又要从磁盘读入一个数据页,那肯定是要淘汰一个旧数据页的。InnoDB 内存管理用的是最近最少使用 (Least Recently Used, LRU) 算法,这个算法的核心就是淘汰最久未使用的数据。

在 InnoDB 实现上,按照 5:3 的比例把整个 LRU 链表分成了 young 区域和 old 区域。 LRU 算法执行流程变成了下面这样:

  1. 扫描过程中,需要新插入的数据页,都被放到 old 区域 ;

  2. 一个数据页里面有多条记录,这个数据页会被多次访问到,但由于是顺序扫描,这个数据页第一次被访问和最后一次被访问的时间间隔不会超过 1 秒,因此还是会被保留在 old 区域;

  3. 再继续扫描后续的数据,之前的这个数据页之后也不会再被访问到,于是始终没有机会移到链表头部(也就是 young 区域),很快就会被淘汰出去。

可以看到,这个策略最大的收益,就是在扫描这个大表的过程中,虽然也用到了 Buffer Pool,但是对 young 区域完全没有影响,从而保证了 Buffer Pool 响应正常业务的查询命中率。

小结

由于 MySQL 采用的是边算边发的逻辑,因此对于数据量很大的查询结果来说,不会在 server 端保存完整的结果集。所以,如果客户端读结果不及时,会堵住 MySQL 的查询过程,但是不会把内存打爆。

而对于 InnoDB 引擎内部,由于有淘汰策略,大查询也不会导致内存暴涨。并且,由于 InnoDB 对 LRU 算法做了改进,冷数据的全表扫描,对 Buffer Pool 的影响也能做到可控。

最后更新: 2020年08月27日 17:13

原始链接: https://jjw-story.github.io/2020/07/31/MySQL-实践二/

× 请我吃糖~
打赏二维码