• 进入"运维那点事"后,希望您第一件事就是阅读“关于”栏目,仔细阅读“关于Ctrl+c问题”,不希望误会!

MySQL从库 crash-safe 问题(单线程回放)

MySQL 彭东稳 7年前 (2018-01-19) 30069次浏览 已收录 0个评论

一、从库 crash safe 解决历程

MySQL 数据库的成功离不开其复制功能,相对于 Oracle DG 和 Microsoft SQL Server Log Shipping 来说,其简单易上手,基本上 1,2 分钟内根据手册就能完成环境的搭建。然而,随着使用的深入,复制功能自身的问题会慢慢显露,其中非 crash safe 的特性使得许多 DBA 感到头疼,甚至不能理解其所发问题的原因。简单来说,crash safe 是指当 master/slave 任何一个节点发生宕机等意外情况下,服务器重启后 master/slave 的数据依然能够保证一致性。下面主要介绍 slave crash safe 是如何保证的。

slave crash safe 的情况就有些复杂,而这可能是 DBA 更为常见的问题。例如 slave 不断的报 1062 错误,或者发现主从数据不一致(特别是表没有主键的情况)。而这时 DBA 的选择通常也很无奈,基本就是全库重建了。所以说,当你有运维超过 200 台以上的 MySQL 服务器的经验时,就会发现这是一个很大的问题。

导致不能实现 slave crash safe 有两方面的原因,即复制中的 sql thread 和 io thread。

在一个从节点上,同步涉及到 2 个线程:把主节点的二进制日志(binary log)复制到本地并写入中继日志(relay log)的 IO 线程,和执行中继日志中的语句/事件的 SQL 线程。每个线程执行的当前的位置都存储在文件中:IO 线程拉取 master binlog position 信息存在 master.info 文件,SQL 线程执行 relay log position 信息存在 relay-log.info 文件。当从节点重启后可以根据 master.info 文件中记录的 position 信息继续同步主库 binlog,而 sql 线程则继续根据 relay-log.info 文件中记录的 position 信息继续执行 relay log。看上去一切很美好。

Note

master.info 是在 io_thread 向 master 发送 request_dump 时被使用,然后写 relay log 后文件被更新,因此 master.info 记录代表了 io_thread 读取的 master binlog 信息的位置。

relay-log.info 是在 sql_thread 读取未执行的 event 时使用并更新,所以 relay-log.info 代表的是当前 sql_thread 执行到 relay log 的位置。

目前为止,还不错。现在第一个问题来了,这些文件不是每次写入都同步到磁盘:如果发生崩溃,写入到文件中的位置很可能都是不准确的。MySQL 5.5 对这个进行了修复,提供了 sync_master_info & sync_relay_log_info 两个参数,你可以设置 sync_master_info=1 来确保 io thread 每读取主库一个 event 后就进行同步 master.info 信息到磁盘;同时可以设置 sync_relay_log_info=1 来确保 sql thread 每执行一个 transaction 后进行同步 relay-log.info 信息到磁盘,这两个参数默认值都是 10000,因为磁盘实时同步对性能损耗非常大。

但是,等等,尽管设置了 sync_master_info=1 和 sync_relay_log_info=1,还是可能会出现问题。io thread 拉取 master binlog 和写 master.info 是两个步骤,并不是原子的;sql thread 更新数据库操作和 relay-log.info 文件也是两个步骤,也并不是原子的。所以,如果出现数据库宕机就可能会出现 master.info 和 relay-log.info 信息不准确,从而导致数据不一致,甚至复制报错。

master_info_repository 和 relay_log_info_repository

从 MySQL 5.6 开始提供了 slave crash safe 的功能,意思是说在 slave crash 后,把 slave 重新拉起来可以继续从 master 进行复制,不会出现复制错误,也不会出现数据不一致。MySQL 5.6 采用了一种方法,就是将复制信息保存在 InnoDB 的事务表中,而不是之前的日志文件中,来解决这个问题。

新增加了两个参数:

  • 参数 master_info_repository 可设置 slave io thread 拉取 master binlog 信息是存储在 master.info 文件还是 slave_master_info 表;设置为 FILE 表示记录为文件,设置为 TABLE 表示记录为数据库表。
  • 参数 relay_log_info_repository 可设置 slave sql thread 执行 relay log 的信息是存储在 relay-log.info 文件还是存储在 slave_relay_log_info 表;设置为 FILE 表示记录为文件,设置为 TABLE 表示记录为数据库表。

当 relay_log_info_repository=TABLE 时,会创建 mysql.slave_relay_log_info 表,当 master_info_repository=TABLE 时,会创建 mysql.slave_master_info 表。这时两个操作都是数据库操作,在一个事务中就能得到原子性。想法很简单,我们可以把复制信息的更新包含在事务当中,确保它和数据同步/一致。

然而,这并没有看起来那么简单。对于 sql thread 而言,可以把对 relay log 回放的操作与复制信息的更新(update slave_relay_log_info)放在同一个事务中,使得 sql thread 的位置与数据保持一致。事实上在 5.6.0-5.6.5 的版本,slave_relay_log_info 表默认使用的是 MyISAM 引擎,之后的版本才改为 InnoDB,不过再考虑到 MySQL 5.6.10 才 GA,这个坑踩过的人应该不多。

但对于 IO 线程而言,接收 event 写 relay log 与更新 slave_master_info 表是两个维度的操作,一个是系统层面,一个是数据库层面,没办法是一个原子的操作,所以还是存在 slave_master_info 表记录跟 relay log 不一致的情况,后面再说怎么解决这个问题的。先看 slave_relay_log_info 表,存储 slave sql thread 的工作位置。

在从库启动的时候时,读取 slave_relay_log_info 表信息到内存中,并把值传给 show slave status 命令中输出的 Relay_Log_File、Relay_Log_Pos 字段,当 start slave 是从这个位置开始继续回放 relay log。

slave_relay_log_info 表存储的是持久化的状态、show slave status 命令输出的是内存中的状态:

  • 两者输出的位置可能不一样
  • stop slave 或者正常关闭 mysqld,都会将内存中的状态持久化到磁盘上(slave_relay_log_info 表中)
  • 启动 mysqld 时会读取磁盘状态,初始化给内存状态
  • start slave 时生效的是内存状态

但是同一个事务在从库 relay log 中的 position 和主库 binlog 中的 position 是不相等的,slave_relay_log_info 表通过 Master_log_name、Master_log_pos 这两个字段记录了 relay log 中事务对应在主库 binlog 中的 position。通过 show slave status 查看复制信息时 Relay_Master_Log_File、Exec_Master_Log_Pos 字段就是读取的 Master_log_name、Master_log_pos 记录的信息,我们才知道当前从库回放到主库的对应位置是什么,这很重要。

我们知道如果 slave io thread 重复、遗漏的读取主库 binlog 写入到 relay log 中,sql thread 也会重复、遗漏地回放这些 relay log。也就是说从库的数据是否正确,io thread 的位置是否正确也非常重要。

回到 io thread,上面说了对于 IO 线程而言,读取 master binlog event 写 relay log 与更新 slave_master_info 表是两个维度的操作,一个是系统层面,一个是数据库层面,没办法是一个原子的操作,所以还是存在 slave_master_info 表记录跟 relay log 不一致的情况。我们最多可以做的就是调整 sync_master_info & sync_relay_log 参数,默认值都是 10000,可以都调整为 1,表示 IO 线程会每接收 1 个事件就会更新一次 slave_master_info 表和 relay log 日志。

如果设置 master_info_repository=TABLE & sync_master_info=1 & sync_relay_log=1 会导致写放大很严重,性能损耗大。但就算能够容忍损耗点性能,但由于读取 master binlog event 写 relay log 与更新 slave_master_info 表不是原子的,所以还是会有不一致的问题。为此,MySQL 又引入了另一个更加优雅的解决方法,那就是开启 relay_log_recovery 参数,但它要求重启 MySQL 服务。

relay_log_recovery

当启用 relay_log_recovery 参数后,从库启动恢复过程会以 sql thread 的位置创建一个新的 relay log 文件,并初始化 io thread 和 sql thread 的位置,表现为:

  • io thread:直接使用 slave_relay_log_info 内存结构中的 Master_log_name 和 Master_log_pos 覆盖 slave_master_info 内存结构中的 Master_log_name 和 Master_log_pos。
  • sql_thread:将 slave_relay_log_info 表内存结构中的 Relay_Log_Name 值更新为最新的日志名,Relay_Log_Pos 值更新为一个固定值 4(应该是头部固定信息占 4个偏移量)。

io thread 这个位置的初始化思路就是:既然以前记录的位置不确定是否准确,那就直接不要了,sql thread 回放到哪,我就从哪开始重新拉取主库的 binlog,重新执行,这样一定能保证正确性。

一个事务在 relay log 中的 position 对应到主库 binlog 的 position 是这样来确定的:

  • slave_relay_log_info 表中的 Relay_log_name 与 Master_log_name,Relay_Log_Pos 与 Master_Log_Pos 始终一一对应,代表同一个事务的位置。

所以,即使 sync_master_info 表的持久化无法保证,relay_log_recovery 也会将 io_thread 需要的信息重置到已经回放的那个位置。因此,你只需要开启 relay_log_info_repository=TABLE & relay_log_recovery=ON 就能保证 slave crash safe。所以也可以不设置 master_info_repository=TABLE & sync_master_info=1 了。但最好不管是 io thread 还是 sql thread 都设置为 TABLE 是有必要的,对性能有提升作用。

安全&性能都高的配置建议:

但在使用 MTS(multi-threaded slave)时,为保证 slave crash safe,在基于 position 模式的复制时还需要设置 sync_relay_log=1 & log_slave_updates=ON & sync_binlog=1,因为从库在 crash 恢复时必须先通过读取 relay log 补齐 MTS 导致的事务空洞。具体后面会介绍。

另外,relay_log_recovery 的另一个作用是防止 relay log 的损坏,因为默认 relay log 是不保证持久化的(也不推荐设置 sync_relay_log=1),当操作系统或者 mysqld crash 后,sql thread 可能会因为 relay log 的损坏、丢失导致错误。

细说 FILE 和 TABLE

所以到这里我们基本弄清楚了 master_info 和 relay_log_info 的来龙去脉。下面我们细看一下这些信息被存储在 TABLE 中和 FILE 中的区别在哪?实际上这个区别可以认为是文件系统和数据库事务的区别。

MySQL从库 crash-safe 问题(单线程回放)

master info 会通过 mi->flush_info 方法更新到 FILE 或者 TABLE,relay log info 通过 rli->flush_info 更新 FILE 或 TABLE。

在 FILE 的实现中,实际上每次执行 flush_io_cache 都会写文件,我们读文件可以看到已经被更新了,但是新的写入并不会立马同步到磁盘,在 sync_period(也就是参数 sync_master_info/sync_relay_log_info)次后 MySQL 会通过 fdatasync 写到磁盘。那么这样就存在的问题就在于如果系统宕机,那么还没有持久化到磁盘的信息就会丢失,当然我们可以把 sync_period 设置为 1,那么每次写操作都会进行 sync,但是系统的 sync 操作并不是一个高效的行为,这样势必会牺牲一些性能。

而 TABLE 方式是依赖数据库事务实现的,如果 relay log 表支持事务,上述 relay log 进行 flush_info 操作会在事务 commit 时一同提交,这里就可以保证 relay log info 中存储的点和当前真正执行事务的点是一致的。如果服务宕掉或者系统宕机,如果设置了 relay_log_recover=1 参数,recovery 时就会创建新的 relay log,重新初始化 sql_thread 和 io_thread 从真正执行的点开始执行,从而保证复制的正确。

到这里,我们可以看出 master_info_repository 和 relay_log_info_repository 默认值被设置为TABLE(默认 InnoDB),主要还是从安全的方面考虑,依赖数据库的事务,可以保证 relay_log_info_repository 存储的点和数据库真正执行的点一致,这一点 FILE 方式是无法做到的。同时在性能上,和文件系统的直接交互会增加 io,而写 table 是直接写内存中的 buffer pool,在性能上也能有一定的提升。

注意到上面 sync_period 在 master info 和 relay log info 中分别是代表了 event 和 transaction,这是因为 io_thread 是按照 event 来一个一个读取后 flush_info,而 sql_thread 虽然也是以 event 为单位执行的,但是会等待事务 commit 时才会 flush_info,否则如果事务没有提交已经刷新了 relay log info,然后 recovery 时会把未 commit 的事务 rollback 。此时重新从 relay log info 的 pos 开始执行就会出错。

基于 5.6 GTID auto_position 的复制

在 gtid 模式下,开启 master_auto_position=1 后,从库可以根据 Retrieved_Gtid_Set 发送到主库自动确认 binary log 的拉取位置。此时,意味着从库不在依赖 relay log info 信息了。怎么拿到 Retrieved_Gtid_Set 值呢?

在 MySQL 启动时,如果 relay_log_recovery 参数没有开启,初始化时就需要从 relay log 文件中获取已接收的 gtid set 并更新 Retrieved_Gtid_Set,并且 sql 线程也需要从宕机前的位置继续读取 relay log 继续应用。因此就需要保证 relay log 文件的完整性,设置 sync_relay_log=1,性能会严重下降,不推荐。

开启 relay_log_recovery 是推荐的做法,这种方式避免了上面一种方式的缺点,首先 Retrieved_Gtid_Set 不需要初始化了,只需要 Executed_Gtid_Set 的值就可以了,这个很容易观察到,设置--skip-slave-start后重启 MySQL 实例,观察 show slave status 中的 Retrieved_Gtid_Set 是否有值就可以了。有了 Executed_Gtid_Set 后,主库 dump 线程将把 Executed_Gtid_Set 之后的全部事务发送给从库,这也符合使用执行位置重新拉取 binlog 的理念,这种情况下 relay log 和 slave_master_info 都不重要了。

但对于 Executed_Gtid_Set 的获取,在 MySQL 5.6 和 MySQL >= 5.7 是有不同的表现的。

在基于 5.6 GTID 的复制下,由于 gtid_executed 变量是一个内存值,没有持久化。所以在从库重启后,它自己没办法知道自己执行了哪些 GTID 事务,需要从自身的 binlog 中解析 gtid_executed。从库就需要记录 binlog 信息了,并且 binlog 必须和 InnoDB 存储引擎的数据保持一致。要保持数据一致,就需要把 sync_binlog & innodb_flush_log_at_trx_commit 都设置为 1,即所谓的”双1″。

这样,对于基于 5.6 GTID 的复制,安全&性能最高的配置建议(小于 MySQL 5.7 版本设置):

关于如何设置以确保 slave crash safe,官方文档有明确记载,见 17.3.2 Handling an Unexpected Halt of a Replication Slave。

基于 5.7 GTID auto_position 的复制

由于 MySQL 5.7 已经将已执行的 gtid set 信息实时记录到系统表 mysql.gtid_executed 中,而 mysql.gtid_executed 表中的记录是和用户事务一起提交的,因此可以保证和实际数据的一致。所以可以关闭 log_slave_updates(也就是不需要记录 binlog 了)参数了。

关于 relay_log_recovery 参数当然也是需要开启的,不然就会有上面同样的问题。

安全&性能较高的配置建议:

如果是异常宕机,且宕机后丢失了 master info 或者 relay log info 信息,重启时 io_thread 可能会拉取到重复的 relay log,sql_thread 可能会重复执行同一个事务,这样就会导致复制出错。但是开启 GTID 就可以避免这个问题,虽然 io_thread 和 sql_thread 的动作是一样的,但是如果事务的 gtid 是已经存在,ev->apply_event 时就会忽略这个事务。因此不会重复执行同一个事务。

这里思考一个问题:开启 master_auto_position 后,还需要 slave_relay_log_info、slave_master_info 表中记录的位点信息吗?

其实 slave_relay_log_info 和 slave_master_info 表依然发挥作用:

  • 当第一次或者 reset slave 后,执行 start slave,io thread 将从库的 Executed_Gtid_Set 发往主库,获取到对应的 File、Position,之后更新到从库的 slave_relay_log_info、slave_master_info 表中。
  • 当 slave_relay_log_info、slave_master_info 表中存在位置信息后,此后无论是重启复制还是重启 mysqld,都是直接从这两个地方获取 File、Position,并从这里开始读取 binlog 和回放 relay log。
注意:执行 reset slave 会删除从库上的 relay log,并且重置 slave_relay_log_info 表,即重置复制位置。如果 master_auto_position=0,下次启动复制时会从新开始获取并回放主库的 binlog,造成错误。

以上都是对于从库单线程配置下的 crash safe 演进,下一篇说说多线程回放(MTS)下是如何保证 crash safe 的。

<参考>

slave_relay_log_info 表认知的一些展开

与MySQL传统复制相比,GTID有哪些独特的复制姿势?

从源码入手,详解参数master_info_repository和relay_log_info_repository


如果您觉得本站对你有帮助,那么可以支付宝扫码捐助以帮助本站更好地发展,在此谢过。
喜欢 (0)
[资助本站您就扫码 谢谢]
分享 (0)

您必须 登录 才能发表评论!