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

MySQL 5.7:并行复制原理(MTS)

MySQL 5.7 彭东稳 6年前 (2016-04-18) 29511次浏览 已收录 1个评论

众所周知,MySQL 的复制延迟是一直被诟病的问题之一,在 MySQL 5.7 版本已经支持“真正”的并行复制功能,官方称为为 enhanced multi-threaded slave(简称 MTS),因此复制延迟问题已经得到了极大的改进。总之,MySQL 5.7 版本后,复制延迟问题“永不存在”,这里是引号哦。

一、MySQL 5.6 并行复制架构

从 MySQL 5.6.3 版本开始就支持所谓的并行复制了,但是其并行只是基于 schema 的,也就是基于库的。如果用户的 MySQL 数据库实例中存在多个 schema,对于从机复制的速度的确可以有比较大的帮助。但在一般的 MySQL 使用中,一库多表比较常见,所以 MySQL 5.6 的并行复制对真正用户来说属于雷声大雨点小,不太合适生产使用。

MySQL 5.6 并行复制的架构如下所示:

MySQL 5.7:并行复制原理(MTS)

在 MySQL 5.6 版本之前,Slave 服务器上有两个线程,分别是 I/O 线程和 SQL 线程。I/O 线程负责接收二进制日志(更准确的说是二进制日志的 event),SQL 线程进行回放二进制日志。如果在 MySQL 5.6 版本开启并行复制功能,那么 SQL 线程就变为了 coordinator(协调者)线程,coordinator 线程主要负责两部分的内容:

  1. 判断事务是否可以并行回放,若判断可以并行回放,那么选择 worker 线程执行事务的二进制日志。
  2. 若判断不可以并行执行,如该操作是 DDL,亦或者是事务跨 schema 操作,则等待所有的 worker 线程执行完成之后,再执行当前的日志。

这意味着 coordinator 线程并不是仅将日志发送给 worker 线程,自己也可以回放日志,但是所有可以并行的操作交付由 worker 线程完成。coordinator 线程与 worker 是典型的生产者与消费者模型。

上述机制实现了基于 schema 的并行复制存在两个问题,首先是 crash safe 功能不好做,因为可能之后执行的事务由于并行复制的关系先完成执行,那么当发生 crash 的时候,这部分的处理逻辑是比较复杂的。从代码上看,5.6 这里引入了 Low-Water-Mark 标记来解决该问题,从设计上看(WL#5569),其是希望借助于日志的幂等性来解决该问题,不过 5.6 的二进制日志回放还不能实现幂等性。另一个最为关键的问题是这样设计的并行复制效果并不高,如果用户实例仅有一个库,那么就无法实现并行回放,甚至性能会比原来的单线程更差。而单库多表是比多库多表更为常见的一种情形。

二、MySQL 5.7 并行复制原理

MySQL 5.6 基于库的并行复制出来后,基本无人问津,在沉寂了一段时间之后,MySQL 5.7 出来了,它的并行复制以一种全新的姿态出现在了 DBA 面前。MySQL 5.7 才可称为真正的并行复制,这其中最为主要的原因就是 slave 服务器的回放与 master 是一致的,即 master 服务器上是怎么并行执行的,那么 slave 上就怎样进行并行回放。不再有库的并行复制限制,对于二进制日志格式也无特殊的要求(基于库的并行复制也没有要求)。

从 MySQL 官方来看,其并行复制的原本计划是支持表级的并行复制和行级的并行复制,行级的并行复制通过解析 ROW 格式的二进制日志的方式来完成,WL#4648。但是最终出现给小伙伴的确是在开发计划中称为:MTS(Prepared transactions slave parallel applier),可见:WL#6314。该并行复制的思想最早是由 MariaDB 的 Kristain 提出,并已在 MariaDB 10 中出现。

下面来看看 MySQL 5.7 中的并行复制究竟是如何实现的?

从 MySQL 5.7.17 开始,增加了基于 writeset 的并行复制方式,简单来说就是提供了不一样的 last_committed 和 sequence_number 生成方式。目前为止归纳一下就是提供了三种生成 last_committed 和 sequence_number 的方式:

  • commit_order
  • writeset
  • writeset_session

其中 commit_order 就是基于 group commit 方式生成 last_committed 和 sequence_number,另外两种生成算法由于内容也很多,就不在这篇文章介绍了。可以看相关文章。

组提交(group commit)

MySQL 5.6 中引入 Group Commit 技术,这是为了解决事务提交的时候需要 fsync 导致并发性不够而引入的。简单来说,就是由于事务提交时必须将 Binlog 写入到磁盘上而调用 fsync,这是一个代价比较高的操作,事务并发提交的情况下,每个事务各自获取日志锁并进行 fsync 会导致事务实际上以串行的方式写入 Binlog 文件,这样就大大降低了事务提交的并发程度。5.6 中采用的 Group Commit 技术将事务的提交阶段分成了 Flush,Sync,Commit 三个阶段,每个阶段维护一个队列,并且由该队列中第一个线程负责执行该步骤,这样实际上就达到了一次可以将一批事务的 Binlog fsync 到磁盘的目的,这样的一批同时提交的事务称为同一个 Group 的事务。

Group Commit 虽然是属于并行提交的技术,但是却意外的解决了从机上事务并行回放的一个难题————既如何判断哪些事务可以并行回放。如果一批事务是同时 Commit 的,那么这些事务必然不会互斥的持有锁,也不会有执行上的相互依赖,因此这些事务必然可以并行的回放。反过来说,如果有冲突,则后来的操作会等已经获取资源的事务完成之后才能继续,故而不会进入事务的 prepare 阶段。

因此 MySQL 5.7 中引入了新的并行回放类型,为了兼容 MySQL 5.6 基于库的并行复制,5.7 引入了新的变量 slave_parallel_type,其可以配置的值有:

  • DATABASE:基于库的并行复制方式
  • LOGICAL_CLOCK:基于组提交的并行复制方式

同时参数 slave_parallel_workers 是用来设置并发的 worker 线程数量。

注意 slave_parallel_workers 设置的 worker 线程的个数,且不包括 coordinator 协调线程,因此如果不想使用 MTS,应该设置该参数为 0,然后 stop slave, start slave 才能生效。因为 worker 线程是在启动的时候初始化完成的。如果将 slave_parallel_workers 设置为 1,则 SQL 线程功能转化为 coordinator 线程,但是只有 1 个 worker 线程进行回放,也是单线程回放。然而,这两种性能却又有一些的区别,因为多了一次 coordinator 线程的转发,因此 slave_parallel_workers=1 的性能反而比 0 还要差,测试下还有 20% 左右的性能下降。

那么如何知道事务是否在同一组中,又是一个问题,因为原版的 MySQL 并没有提供这样的信息。在 MySQL 5.7 版本中,其设计方式是将组提交的信息存放在 GTID 事件中。为了标记事务所属的组,MySQL 5.7 版本在产生 Binlog 日志时会有两个特殊的值记录在 Gtid Event 中,last_committed 和 sequence_number , 其中 last_committed 指的是该事务提交时,上一个事务提交的 sequence_number 编号,sequence_number 是事务提交的序列号,在一个 Binlog 文件内单调递增,只要换一个文件(flush binary logs),这两个值就都会从 0 开始计数。如果两个事务的 last_committed 值一致,这两个事务就是在一个组内提交的。

通过 mysqlbinlog 工具解析 binlog 文件,可以看到组提交的相关信息:

如上 binlog 文件中, sequence_number 1-6 的事务 last_committed 都是 0 ,因此属于同一个组,可以在从库上并行回放,sequence_number 8-12 的 last_committed 都是 6,也属于同一个组,因此可以并行回放。

在上面的并行执行中,last_committed = 1 的事务需要等待 last_committed = 0 的 6 个事务完成后才能执行,同理,last_committed = 6 的 5 个事务需要等待 last_committed = 1 的事务完成。但是 MySQL 5.7 还做了额外的优化,可进一步增大回放的并行度。思想是 LOCK-BASED,即如果两个事务有重叠,则两个事务的锁依然是没有冲突的,依然可以并行回放。

在上面的例子中,last_committed = 1 的事务可以和 last_committed = 0 的事务同时并行执行,因为事务有重叠,last_committed 不是上一组事务最大 sequence_number 的值。具体来说,这表示 last_committed = 0 的事务进入到 COMMIT 阶段时,last_committed 的事务进入到了 PREPARE 阶段,即事务间依然没有冲突。具体实现思想可见官方的 Worklog: WL#7165: MTS: Optimizing MTS scheduling by increasing the parallelization window on master

MySQL 5.7 中引入的基于 Logical_Lock 极大的提高了在主机并发压力比较大的情况下,从机上的回放速度。基本上做到了主机上如何提交的,在从机上如何回放。

那么如果用户没有开启 GTID 功能,即将参数 gtid_mode 设置为 OFF 呢?故 MySQL 5.7 又引入了称之为 Anonymous_Gtid(ANONYMOUS_GTID_LOG_EVENT)的二进制日志 event 类型,如:

与 GTID 相关的几个事件

PREVIOUS_GTIDS_LOG_EVENT

用于表示上一个 binlog 最后一个 gitd 的位置,每个 binlog 只有一个,当没有开启 GTID 时此事件为空。此事件的作用是 master 用来检验 slave 发送的 gtid set 是否合法。maser 会先扫描最后一个 binary log,拿到 PREVIOUS_GTIDS_LOG_EVENT 事件,然后检查 slave 需要拉取的 gtid 是否在此之后,是就结束,否则检查上一个 binary log 文件同样拿到 PREVIOUS_GTIDS_LOG_EVENT 事件,同样检查需要拉取的 gtid 是否再次之后,如此循环直到找到为止。

GTID_LOG_EVENT

当开启 GTID 时,每一个操作语句(DML/DDL)执行前就会添加一个 GTID 事件,记录当前全局事务 ID;同时在 MySQL 5.7 版本中,组提交信息也存放在 GTID 事件中,有两个关键字段 last_committed,sequence_number 就是用来标识组提交信息的。在 InnoDB 中有一个全局计数器(global counter),在每一次存储引擎提交之前,计数器值就会增加。在事务进入 prepare 阶段之前,全局计数器的当前值会被储存在事务中,这个值称为此事务的commit-parent(也就是 last_committed)。

ANONYMOUS_GTID_LOG_EVENT

在 MySQL 5.7 版本中即使不开启 GTID,每个事务开始前也是会存在一个 Anonymous_Gtid,而这个 Anonymous_Gtid 事件中就存在着组提交的信息。反之,如果开启了 GTID 后,就不会存在这个 Anonymous_Gtid 了,从而组提交信息就记录在非匿名 GTID 事件中。

总结一下,从上面的描述可以看出,不管是基于 GTID 方式复制,还是 file+postion 方式复制,在 MySQL 5.7 版本都可以使用 MTS 技术。但不建议在非 GTID 模式下使用 MTS 技术,因为无法保证从库 crash safe;而在 GTID 模式下则可以保证从库 crash safe。

关于如何将事务分组的细节,可以参考 Group Commit 相关的文章,这里描述的比较概括。

事务两阶段提交

事务的提交主要分为两个主要步骤:

1. Prepare Phase

调用 prepare 接口完成第一阶段,具体会做 Binlog Prepare,实际上什么也没做,然后做 InnoDB Prepare,此时 SQL 已经成功执行,并生成 xid 信息及 redo 和 undo 日志,并将事务状态设为 TRX_STATE_PREPARED。

2. Commit Phase

2.1 记录协调者日志,即 Binlog 日志。

如果事务涉及的所有存储引擎的 prepare 都执行成功,则调用 TC_LOG_BINLOG::log_xid 方法将 SQL 语句写到 binlog(write 将 binary log 内存日志数据写入文件系统缓存,fsync 将 binary log 文件系统缓存日志数据永久写入磁盘)。此时,事务已经铁定要提交了。否则,调用 ha_rollback_trans 方法回滚事务,而 SQL 语句实际上也不会写到 binlog。

2.2 告诉引擎做 commit。

最后,调用引擎做 commit 完成事务的提交。会清除 undo 信息,刷 redo 日志,将事务设为 TRX_NOT_STARTED 状态。

ordered_commit

关于 MySQL 是如何提交的,内部使用 ordered_commit 函数来处理的。先看它的逻辑图,如下:

MySQL 5.7:并行复制原理(MTS)

从图中可以看到,只要事务提交(调用 ordered_commit 方法),就都会先加入队列中。而提交有三个步骤,包括 FLUSH、SYNC 及 COMMIT,相应地也有三个队列。首先要加入的是 FLUSH 队列,如果某个事务加入时,队列还是空的,则这个事务就担任 leader,来代表其他事务执行提交操作。而在其他事务继续加入时,就会发现此时队列已经不为空了,那么这些事务就会等待 leader 帮它们完成提交操作。在上图中,事务 2-6 都是这种坐享其成之辈,事务 1 就是 leader 了。不过这里需要注意一点,不是说 leader 会一直等待要提交的事务不停地加入,而是有一个时限,只有在这个时限之内成功加入到队列的,才能帮它提交。这个时限就是从队长加入开始,到它去处理队列的时间,这个时间实际非常小,基本上就是程序从这行到哪行的一个过程,也没有刻意去等待,不然事务响应时间就会拉长。

只要对 leader 将这个队列中的事务取出,其他事务就可以加入这个队列了。第一个加入的还是 leader ,但此时必须要等待。因为此时有事务正在做 FLUSH,做完 FLUSH 之后,其他的 leader 才能带着队员做 FLUSH。而在同一时刻,只能有一个组在做 FLUSH。这就是上图中所示的等待事务组 2 和等待事务组 3,此时队长会按照顺序依次做 FLUSH,做 FLUSH 的过程中,有一些重要的事务需要去做,如下:

  1. 要保证顺序必须是提交加入到队列的顺序。
  2. 如果有新的事务提交,此时队列为空,则可以加入到 FLUSH 队列中。不过,因为此时 FLUSH 临界区正在被占用,所以新事务组必须要等待。
  3. 给每个事务分配 sequence_number,如果是第一个事务,则将这个组的 last_committed 设置为 sequence_number-1。否则 last_committed 是在 binlog prepare 阶段就会获取,值为上一个 COMMIT 队列最大 sequence_number。
  4. 将带着 last_committed 与 sequence_number 的 GTID 事件写入到 Binlog 文件中,这里是直接写入 binlog 文件,而不经过 binlog cache,所以 GTID 事件是这个事务的第一个事件。
  5. 将当前事务所产生的 Binlog 内容写入到 Binlog 文件中,这里就是把 binlog cache 内容刷新到 binlog 文件。

这样,一个事务的 FLUSH 就完成了。接下来,依次做完组内所有事务的 FLUSH,然后做 SYNC。如果 SYNC 的临界区是空的,则直接做 SYNC 操作,而如果已经有事务组在做,则必须要等待。同样地,做完 FLUSH 之后,FLUSH 临界区会空闲出来,哪儿此时再等待这个临界区的组就可以做 FLUSH 操作了。总而言之,每个步骤都会有事务组在做, 就像一个流水线一样。完成一件产品需要三个工序,每个工序都可以批量来做,那么每个工序车间都不会闲着,都一直重复着相同的事情,最终每个产品都是以完全相同的顺序完成。

到 COMMIT 时,实际做的是存储引擎提交,参数 binlog_order_commits 会影响提交行为。如果设置为 ON,那么此时提交就变为串行操作了,就以队列的顺序为提交顺序。而如果设置为 OFF,提交就不会在这里进行,而会在每个事务(包括队长和队员)做 finish_commit(FINISH)时各自做存储引擎的提交操作。组内每个事务做 finish_commit 是在队长完成 COMMIT 工序之后进行,到步骤 DONE 时,便会唤醒每个等待提交完成的事务,告诉他们可以继续了,那么每个事务就会去做 finish_commit。而后,自己再去做 finish_commit。这样,一个组的事务就都按部就班地提交完成了。现在也可以知道,与这个组中同时在做提交的最多还有另外两个事务,一个是在做 FLUSH,一个是在做 SYNC。

现在应该搞明白关于 order commit 的原理了,而这也是 LOGICAL_CLOCK 并行复制的基础。因为 order commit 使得所有的事务分了组,并且有了序列号,从库拿到这些信息之后,就可以根据序号放心大胆地做分发了。

但是有没有发现一个问题,每个组的事务数都没有做过特殊处理。因为从时间上说,从 leader 开始入队,到取队列中的所有事务出来,这之间的时间是非常非常小的,其实就是几行代码的事,也不会有任何费时间的操作,所以在这段时间内其实不会有多少个事务。只有在压力很大,提交的事务非常多的时候,才会提高并发度(组内事务数变大)。不过这个问题也可以解释得通,主库压力小的时候,从库何必要那么大的并发度呢?只有主库压力大的时候,从库才会延迟。

这种情况下也可以通过调整主服务器上的参数 binlog_group_commit_sync_delaybinlog_group_commit_sync_no_delay_count。前者表示事务延迟提交多少时间来加大整个组提交的事务数量,从而减少进行磁盘刷盘 sync 的次数,单位为 1/1000000 秒,最大值 1000000 也就是 1 秒;后者表示组提交的事务数量凑齐多少此值时就跳出等待,然后提交事务,而无需等待 binlog_group_commit_sync_delay 的延迟时间;但是 binlog_group_commit_sync_no_delay_count 也不会超过 binlog_group_commit_sync_delay 设置。几个参数都是为了增加主服务器组提交的事务比例,从而增大从机 MTS 的并行度。

三、重要数据结构

Relay_log_info(sql/rpl_rli.h)

对应协调线程,在 MTS 之前对应 SQL 线程,为了支持并行复制,在原来的基础上又加了一些成员。

circular_buffer_queue(sql/rpl_rli_pdb.h)

用 DYNAMIC_ARRAY arrary 实现的一个首尾相连的环形队列,是其他重要数据结构的基类。

Slave_committed_queue(sql/rpl_rli_pdb.h)

维护分发执行的 group 信息,是 circular_buffer_queue 的子类,队列里存的是 Slave_job_group。Relay_log_info->gap 类型为 Slave_committed_queue。

Slave_job_group(sql/rpl_rli_pdb.h)

维护一个正在执行的事务的信息,如对应的位点信息、事务分发到的 worker、事务有没有执行完等等。

Slave_worker(sql/rpl_rli_pdb.h)

对应一个 worker,属于 Relay_log_info 的子类。

Slave_jobs_queue(sql/rpl_rli_pdb.h)

任务队列,也是 circular_buffer_queue 的子类,队列里存的是 slave_job_item,每个 worker 有一个这样的任务队列。Slave_worker->jobs 的类型为 Slave_jobs_queue。

Slave_job_item

worker 的 jobs 队列的成员。

四、协调线程分发机制

协调线程主体和之前的 sql 线程基本是一样的,入口函数 handle_slave_sql。

可以看到,关键就是循环不停的读取 event,然后调用 exec_relay_log_event 函数。

Note

这里是 MySQL 8.0 的代码,在 8.0 之前读取可用的 relay log 函数在 exec_relay_log_event 函数中,同时之前的函数名字叫 next_event,在 8.0 中叫 read_next_event。

在 exec_relay_log_event 中,会根据一些条件选择是否更新 last_master_timestamp,比如在并行复制模式和非并行复制模式下,更新 last_master_timestamp 的逻辑是不同的。此外就是调用 apply_event_and_update_pos 函数做 event 分发。除此之外还会调用 mts_checkpoint_routine 强制做 checkpoint,后面会详细讲 checkpiont 过程。

apply_event_and_update_pos 进行分发的入口是 Log_event::apply_event。

Log_event::apply_event 会进行判断,如果没有开 MTS,就是原来的逻辑,SQL 线程直接执行 event,调用 do_apply_event 函数,每个 event 类都实现了 do_apply_event 函数;如果开了 MTS 的话,调用 Log_event::get_slave_worker,这个是分发的主逻辑。从库是以事务为单位做 apply 的,每个事务有一个 GTID 事件,从而都有 last_committed 及 sequence_number 值,主要就是根据这两个值来进行并行回放的。

我们这里主要看 MTS 下分发 event 的流程,下面分解一下 Log_event::get_slave_worker 都做了什么:

1. 如果是 GTID_LOG_EVENT 事件代表事务开始,则将本事务加入到 GAQ 队列中(GAQ 下面会详细描述)。参考 Log_event::get_slave_worker。

2. 将 GTID_LOG_EVENT 事件加入到 curr_group_da 队列中暂存。参考 Log_event::get_slave_worker。

3. 获取 GTID_LOG_EVENT 事件中的 last_committed 及 sequence_number 值,决定是否分配下一个 event 到 worker 线程。可参考函数 Mts_submode_logical_clock::schedule_next_event。

4. 获取 current_lwm 值,代码里面叫 last_lwm_timestamp,这个值代表的是所有在 GAQ 队列上还没有提交完成事务中最早的那个事务的前一个已经提交事务的 sequence_number(rli->gaq->lwm.sequence_number),但可能后面的事务已经提交完成了,听起来可能比较拗口但很重要。如果都提交完成了,那么就是取最新提交事务的 sequence_number,下面的图表达的就是这个意思,这个图是源码中的。这个值的获取可参考函数 Mts_submode_logical_clock::get_lwm_timestamp。

我们可以先不看 lwm 部分,对于检查点的 lwm 后面讨论。 sequence_number 从右向左递增,在 GAQ 中实际上有三种值:

  • X:已经做了检查点,在 GAQ 中出队的事务。
  • x:已经提交完成的事务。
  • o:没有提交完成的事务。

我们可以看到我们需要获取的 current_lwm 并不是最新一次提交事务的 sequence_number 的值,而是最早未提交事务的前一个已经提交事务的 sequence_number,这里就是 x。这一点很重要,因为理解后就会知道大事务是如何影响 MTS 的并行回放的,同时中间的 5 个 o 实际上就是所谓的 gap,后面会描述。

5. 会检查当前事务是否和正在执行的事务冲突,将 GTID_LOG_EVENT 事件中的 last_committed 和当前 current_lwm 进行比较。可以参考函数 Mts_submode_logical_clock::schedule_next_event。基于 COMMIT_ORDER 和 WRITESET 的都使用这个方法。

下面是大概的比较规则:

  • 如果 last_committed 大于 current_lwm,同时该事务前面还有其他事务执行,则表示不能进行并行回放,这个时候协调线程就需要等待了,直到确认没有冲突事务或者前面的事务已经执行完。条件成立后协调线程会被 worker 线程唤醒。等待期间状态被置为“Waiting for dependent transaction to commit”。
  • 其余的条件表示都可以并行回放,比如 last_committed 小于等于 current_lwm 时。这里 last_committed 等于 current_lwm 的时候,实际这两个值拥有事务的 Lock Interval(锁定间隔) 是没有重叠的,没有重叠是可能有冲突的。一般这种情况是前面一个事务执行结束,后面一个事务获取到 last_committed 为前面一个事务的 sequence_number 的情况时,他们的 Lock Interval 没有重叠。但由于 current_lwm 表示的是已经提交的事务,所以等于的时候,该事务也可以执行。当 last_committed 小于 current_lwm 时,要么是同一组的事务,要么是有重叠的事务,自然可以并行。

Mts_submode_logical_clock::schedule_next_event

上面循环等待的时候,会等待 logical_clock_cond 条件然后做检查。该条件的唤醒逻辑是:当回放事务结束,如果存在等待的事务,即检查 min_waited_timestamp 和当前 curr_lwm(lwm 同时会被更新),如果 min_waited_timestamp 小于等于 curr_lwm,则唤醒等待的 coordinator 线程。

6. 如果是 QUERY_EVENT 事件则初始化一个 Slave_job_item,加入到 curr_group_da 队列中暂存。

7. 如果是 MAP_EVENT 事件进行 worker 线程的分配。参考函数 Mts_submode_logical_clock::get_least_occupied_worker,分配 worker 线程如下:

  • 如果有空闲的 worker 线程则分配完成,继续。
  • 如果没有空闲的 worker 线程则等待空闲的 worker 线程。这种情况下状态会置为“Waiting for slave workers to process their queues”。

然后会回到 apply_event_and_update_pos 函数,将 GTID_LOG_EVENT 事件和 QUERY_EVENT 事件分配给 worker 线程,具体入 worker 线程队列可参考 append_item_to_jobs 函数。

函数 append_item_to_jobs 入队的时候会检查 worker 线程的任务队列是否已满,如果满了则需要等待,状态置为“Waiting for Slave Worker queue”。因为分配的单位是 event,对于一个事务而言可能包含很多 event,如果 worker 线程应用的速度赶不上协调线程入队的速度,可能导致任务队列的积压,因此任务队列被占满是可能的。任务队列的大小为 16384,当前由变量 mts_slave_worker_queue_len_max 硬编码。每等待一次,就会累加 rli->mts_wq_overfill_cnt++ 操作。

另外,分配前还会对 event 大小进行检查。如果是 big event(event size 大于 slave_pending_jobs_size_max 但小于 slave_max_allowed_packet),它将等待 worker 队列中的所有任务完成。如果是正常的事件(event size 小于 slave_pending_jobs_size_max),event size + 已经在等待的任务大小超过 slave_pending_jobs_size_max,则它将等待有足够的可用内存时将事件添加到 worker 队列中。设置此变量对未启用多线程的从库没有影响。另外,设置此变量不会立即生效,变量的状态适用于所有后续 start slave 命令。此变量的最小可能值为 1MB,默认值为 128MB,最大可能值为 18446744073709551615(16艾字节)。内存足够,或者延迟较大时,可以适当调大。

NOTE

接收器线程 (I/O 线程) 负责限制事件大小到 slave_max_allowed_packet。如果来自主库的事件大于此值 slave_max_allowed_packet,IO 线程将停止,并报 ER_NET_PACKET_TOO_LARGE 错误。

8. MAP_EVENT 事件分配给 worker 线程,同上。

9. ROWS_EVENT 事件分配给相同 worker 线程,同上。

10. XID_EVENT 事件分配给相同 worker 线程,同上。但是这里还需要额外的处理,主要处理一些和检查点相关的信息,在 get_slave_worker 函数中。

这里关注一点如下:如果检查点处于这个事务上,那么这些信息会出现在表 slave_worker_info 中,并且会出现在 show slave status 中。也就是说,show slave status 中很多信息是来自 MTS 的检查点。后面具体描述。

需要注意,Log_event::get_slave_worker 每个 event 的处理流程完成后,都会回到上层 Log_event::apply_event 函数,然后会回到 apply_event_and_update_pos 函数,接着 MTS 逻辑才进行,也就是入队到 worker 中去。

如果上面 event 的分配过程大于 120 秒,可能会出现一个日志如下:

[Note] Multi-threaded slave statistics for channel ”: seconds elapsed = 127; events assigned = 6959105; worker queues filled over overrun level = 0; waited due a Worker queue full = 0; waited due the total size = 0; waited at clock conflicts = 93948853900 waited (count) when Workers occupied = 0 waited when Workers occupied = 0

指标 描述
seconds elapsed 整个分配过程消耗的总时间,单位秒。超过 120 秒会出现这个日志。
events assigned 本 worker 线程分配的 event 数量。
worker queues filled over overrun level 本 worker 线程任务队列中 event 个数大于 90% 的次数,当前硬编码大于 14746。
Waited due to a Worker queue full 本 worker 线程任务队列已满造成的等待次数,当前硬编码 14746。
Waited due to the total size 由于 worker 队列未应用 event 达到 slave_pending_jobs_size_max 大小而造成协调线程等待的时间。
slave_pending_jobs_size_max big event 出现的次数,big event 就是 event size 大于 slave_pending_jobs_size_max,但是小于 slave_max_allowed_packet。
Waited at clock conflicts 由于不能并行回放,协调线程等待的时间,单位纳秒。
Waited (count) when used occupied 由于没有空闲的 worker 线程而导致协调线程等待的次数。
waited when Workers occupied 由于没有空闲的 worker 线程而导致协调线程等待的时间,单位纳秒。

我们可以看到这个日志还是记录很全的,基本覆盖了前面我们讨论的全部可能性。那么我们再看看案例中的日志,waited at clock conflicts = 93948853900,大于 93 秒。120 秒中大约 91 秒都因为不能并行回放而造成的等待,很明显应该考虑是否有大事务的存在。

从上面的分析中我们一共看到了三个等待点:

Waiting for dependent transaction to commit

由于协调线程判定本事务由于 last_committed 大于 current_lwm,因此并不能并行回放,协调线程处于等待,大事务会加剧这种情况。

Waiting for slave workers to process their queues

由于没有空闲的 worker 线程,协调线程会等待。这种情况说明理论上的并行度是理想的,但是可能是参数 slave_parallel_workers 设置不够。当然设置 worker 线程的个数应该和服务器的配置和负载相互结合考虑。

Waiting for Slave Worker queue

由于 worker 线程的任务队列已满,协调线程会等待。这种情况前面说过是由于一个事务包含了过多的 event,并且 worker 线程应用 event 的速度赶不上协调线程分配 event 的速度,导致了积压并且超过了 16384 个 event。

Waiting for Slave Workers to free pending events

由所谓的 big event 造成的,什么是 big event 呢?源码中描述为:event size 大于 slave_pending_jobs_size_max,但是小于 slave_max_allowed_packet。出现的可能性并不大。可以在函数 append_item_to_jobs 中找到答案。

worker 线程执行 event

前面已经讨论了协调线程分发 event 的规则,实际上协调线程只是将 event 发到了 worker 线程的执行队列中。那么 worker 线程执行 event 就需要从执行队列中拿出这些 event,然后进行执行。整个过程可以参考函数 slave_worker_exec_job_group。

这个流程也比较简单,只需要关注一下如下几点:

  • 循环从执行队列中读取 event,注意这里如果执行队列中没有 event 那么就进入空闲等待,也就是 worker 线程处于无事可做的状态,等待状态为 Waiting for an event from Coordinator。参考 slave_worker_exec_job_group 函数。
  • 如果执行到 XID_EVENT,那么说明事务已经结束了,那么需要完成内存信息更新操作。可参考 Slave_worker::slave_worker_exec_event 和 Xid_apply_log_event::do_apply_event_worker 函数。更新内存相关信息可参考函数 Slave_worker::commit_positions 函数。更新的信息基本和 slave_worker_info 表中的信息基本一致。此外,还会更新 worker 线程 的 Bitmap 信息。
  • 如果执行到 XID_EVENT,那么说明事务已经结束了,那么需要完成内存信息的持久化。即强制刷内存信息持久化到 slave_worker_info 表中(realy_log_info_repository 设置为 table)
  • 如果执行到 XID_EVENT,那么还需要进行事务的提交操作,也就是进行 InnoDB 层事务的提交。在 Xid_apply_log_event::do_apply_event_worker 函数中调用 do_commit 函数。

Slave_worker::commit_positions

从上面我们可以看到 MTS 中每次事务的提交并不会更新 slave_relay_log_info 表,而是进行 slave_worker_info 表的更新,将最新的信息写入到 slave_worker_info 表中。我们前面也说过 sql 线程已经蜕变为协调线程,那么 slave_relay_log_info 表什么时候更新呢?下面我们就能看到 slave_relay_log_info 表的更新实际上由协调线程在做完 checkpoint 之后更新。

MTS 中的 checkpoint 

总的说来 MTS 中的检查点是 MTS 进行异常恢复的起点,实际上就是代表到这个位置之前(包含自身)事务都是已经在从库执行过的,但之后的事务可能已经执行完成了,也可能没有执行完成,checkpoint 由协调线程进行。

协调线程的 GAQ 队列

前面我们已经知道了 MTS 中为每个 worker 线程维护了一个 event 的分发队列,除此之外协调线程(Relay_log_info->gaq)还维护了一个非常重要的队列 GAQ(Global Assigned Queue),其结构类型为 Slave_committed_queue,属于 circular_buffer_queue 的子类,是用 DYNAMIC_ARRAY arrary 实现的一个首尾相连的环形队列,队列长度由 slave_checkpoint_group 参数定义,默认 512。队列成员类型为 Slave_job_group,主要维护一个正在执行的事务的信息,如对应的位点信息、事务分发到的 worker、事务有没有执行完等等。由此可以看出 GAQ 用于协调线程和 worker 线程交互。

每次协调线程分发事务的时候都会将事务记录到 GAQ 队列中,因此 GAQ 中事务的顺序总是和 relay log 文件中事务的顺序一致的。检查点正是作用在 GAQ 队列上的,通过判断事务是否已经提交(判断 Slave_job_group->done 状态),把已经提交的事务移除 GAQ 队列,向前推进事务完成位置,每次推进的位置称为 LWM(Low-Water-Mark),就是把移除的 Slave_job_group 事务信息赋值给 LWM,它在 GAQ 队列中进行维护,源码变量名称就叫 lwm,类型为 Slave_job_group。

在 GAQ 队列中还维护有一个叫做 rli_checkpoint_seqno 的变量,它是最后一次检查点以来每个分配事务的序号。

在协调线程读取到 GTID_LOG_EVENT 事件后为其分配序号,可参考 get_slave_worker 函数。

当协调线程进行检查点的时候,当遇到没有完成的事务时,就是遇到一个 gap,表示对应 worker 还没执行完当前事务,checkpoint 不能再向前推进了,到此结束。此时,就会使用 rli_checkpoint_seqno 序号减去此次出队的事务数量,那么这时的 rli_checkpoint_seqno 值对应的就是 GAQ 中事务(Slave_job_group)的个数,就是尚未被 checkpoint 出队的事务(可能已经被 worker 执行完了),对 woker 线程来说,这个对应当前 worker 执行到的事务编号。

在 MTS 异常恢复的时候也会用到这个序号,每个 worker 线程会通过这个序号来确认本 worker 线程执行事务的上限。如下:

worker 线程的 Bitmap

有了 GAQ 队列和检查点就知道异常恢复开始的位置了。但是我们并不知道每一个 worker 线程都完成了哪些事务,哪些又没有执行完成,因此就不能确认哪些事务需要恢复。在 MTS 中并行回放事务的提交并不是按分发顺序进行的,某些大事务(或者其他原因)可能迟迟不能提交,而一些小事务缺会很快提交完成。这些迟迟不能提交的事务就成为了所谓的 gap,如果使用了 GTID,那么在查看已经执行的 gtid set 的时候可能出现一些“空洞”,为了防止 gap 的发生,通常需要设置参数 slave_preserve_commit_order,也就是顺序提交事务,但是如果要设置 slave_preserve_commit_order 参数,就需要开启从库记录 binary log 的功能,因此必须开启 log_slave_update 参数,这有关于从库 crash safe。

这里简单说一下 MTS 恢复会有两个关键阶段:

  • 扫描阶段:通过扫描检查点以后的 relay log,通过每个 worker 线程的 Bitmap 区分出哪些事务已经执行完成,哪些事务没有执行完成,并且汇总形成恢复 Bitmap,同时得到需要恢复的事务总量。
  • 执行阶段:通过这个汇总恢复 Bitmap,将这些没有执行完成的事务读取 relay log 再次执行。

这个 Bitmap(Slave_worker->group_executed) 位图和 GAQ 中的事务一一对应,与 GAQ 大小一致,由参数 slave_checkpoint_group 决定,默认 512。worker 线程每当执行 XID_EVENT 事件完成提交后,会在 group_executed bitmap 中将本事务位(也就是 checkpoint_seqno 位)设置为 1。

协调线程信息的持久化

这个已经在前面提到过,实际上每次进行检查点的时候都需要将检查点的位置固化到 slave_relay_log_info 表中(relay_log_info_repository 设置为 table)。因此 slave_relay_log_info 中存储的实际上不是实时的信息,而是检查点的信息。随着 MySQL 版本不同,这个表的结构也一直在发生变化。与此同时,命令 show slave status 中的某些信息也是检查点的内存信息。比如下面的信息将是来自检查点:

  • Relay_Log_File:最新一次检查点的 relay log 文件名。
  • Relay_Log_Pos:最新一次检查点的 relay log 位点。
  • Relay_Master_Log_File:最新一次检查点的主库 binary log 文件名。
  • Relay_Master_Log_Pos:最新一次检查点的主库 binary log 位点。
  • Seconds_Behind_Master:根据检查点指向事务的提交(XID_EVENT)时间计算的延迟。

需要注意的是,我们的 GTID 模块独立在这一套理论之外,GTID 模块的初始化是在从库信息初始化之前完成的。因此在做 MTS 异常恢复的时候使用 gtid auto_position 模式将会变的更加简单和安全。

worker 线程信息的持久化

worker 线程信息的持久化在 slave_worker_info 表中,前面我们描述 worker 线程执行 event 注意点的时候已经做了相应的描述。执行 XID_EVENT 完成事务提交之后会将信息写入到 slave_worker_info 表中,相关操作都是 Slave_worker::commit_positions 函数,其中包括信息:

  • Relay_log_name:工作线程最后一个提交事务的 relay log 文件名。
  • Relay_log_pos:工作线程最后一个提交事务的 relay log 位点。
  • Master_log_name:工作线程最后一个提交事务的主库 binary log 文件名。
  • Master_log_pos:工作线程最后一个提交事务的主库 binary log 位点。
  • Checkpoint_relay_log_name:工作线程最后一个提交事务对应检查点的 relay log 文件名。
  • Checkpoint_relay_log_pos:工作线程最后一个提交事务对应检查点的 relay log 位置。
  • Checkpoint_master_log_name:工作线程最后一个提交事务对应检查点的主库 binary log 文件名。
  • Checkpoint_master_log_pos:工作线程最后一个提交事务对应检查点的主库 binary log 位点。
  • Checkpoint_seqno:工作线程最后一个提交事务对应 checkpoint_seqno 序号。
  • Checkpoint_group_size:checkpoint_group_bitmap 的长度,默认 64 字节(512 位),是阅读 checkpoint_group_bitmap 所必须的。
  • Checkpoint_group_bitmap:工作线程对应的 Bitmap 位图信息。
  • Channel_name:复制通道的名称。

这其中比较重要的就是 Checkpoint_group_bitmap,记录哪些事务是执行过的,下面会介绍对 bitmap 的操作。

检查点运行的时机

  • 距离上一次 checkpoint 的时间间隔达到 slave_checkpoint_period 参数配置的时间,运行一次检查点,默认 300 毫秒。
  • GAQ 队列大小达到 slave_checkpoint_group 参数的值时强制运行检查点,默认 512。
  • 正常 stop slave。

一个例子

通常有压力的情况下,slave_worker_info 中的所有 worker 线程最大的 Checkpoint_master_log_pos 应该和 slave_relay_log_info 表中的 Master_log_pos 相等。因为这是最后一个检查点的位置信息。压力特别小的详情下,同样也会有类似现象。

可以看出,并行 8 个线程回放,由于压力不大,所以有很多线程已经很久没有执行事务了。

MTS 中的 checkpoint 流程

这一部分将详细描述一下检查点的步骤,关于检查点可以参考 mts_checkpoint_routine 函数。

假设现在有 7 个事务是可以并行执行的,worker 线程数量为 4 个。当前协调线程已经分发了 5 个,前面 4 个事务都已经执行完成,其中第 5 个事务是一个大事务。那么可能当前的状态图如下:

MySQL 5.7:并行复制原理(MTS)

前面 4 个事务每个 worker 线程都分到一个,最后一个大事务这里假设由 worker 线程 2 进行执行,图中用红色部分表示。

1. 检查点被触发,调用 mts_checkpoint_routine 函数。

2. 扫描 GAQ 队列进行出队操作,直到第一个没有提交的事务为止。图中红色部分就是一个大事务,检查点只能停留在它之前。

Slave_committed_queue::move_queue_head 函数部分代码如下:

3. 先更新内存信息为本次检查点指向的位置,也就是我们 show slave status 时看到的信息,然后强制写入 slave_relay_log_info 表(relay_log_info_repository 为 table)。

4. 更新 last_master_timestamp 信息为检查点位置事务的 XID_EVENT 的 timestamp 值。它是计算 Seconds_behind_master 的一个因素,因此 MTS 中 Seconds_behind_master 的计算和检查点息息相关。

5. 最后还会将前面 GAQ 出队的事务数量累加给每个 worker 线程,因为每个 worker 线程需要根据这个值来进行 Bitmap 位图的偏移;并且还会维护我们前面说的 GAQ 的 checkpoint_seqno 值,及更新 last_master_timestamp。

这个操作也是在函数 Relay_log_info::reset_notified_checkpoint 中完成的,实际上很简单,部分代码如下:

到这里整个检查点基本操作就完成了。我们看到实际步骤并不多,拿到 Bitmap 偏移量后每个 worker 线程就会在随后的第一个事务提交的时候进行位图的偏移,checkpoint_seqno 计数也会更新。

我们前面的假设环境中,如果触发了一次检查点,并且协调线程将后两个可以并行的事务发给了 worker1 和 worker3 进行处理,并且处理完成。那么我们的图会变成如下:

MySQL 5.7:并行复制原理(MTS)

这种图中我用不同的样色表示了不同线条,因为它们交叉比较多。GAQ 中的红色事务就是我们假设的大事务,它仍然没有执行完成,它也是我们所谓的 gap。如果这个时候 MySQL 实例异常重启后,那么这个红色 gap 就是我们启动后需要找到的事务,方式就是通过 Bitmap 位图进行比对。如果是开启了 GTID,这种 gap 很容易就能观察到,后面会说。

同时我们需要注意这个时候 worker2 并没有分发新的事务执行,因为 worker2 没有执行完大事务,因此在 slave_worker_info 表中它的信息仍然显示为上一次提交事务的信息。而 worker4 因为没有分配到新的事务,因此 slave_worker_info 表中它的信息也显示为上一次提交事务的信息。因此在 slave_worker_info 中 worker2 和 worker4 的检查点信息、Bitmap 信息、checkpoint_seqno 都是老的信息。

淘宝月报版本

如下图所示,GAQ 中第 0、2、5 号事务分发给了 worker a,第 0 个已经执行完成,所以 worker a 的 bitmap 中,第 0 位置 1;worker b 和 worker c 的 bitmap 同理,标识已经执行的事务。

MySQL 5.7:并行复制原理(MTS)

假设这个时候协调线程做了一次 checkpoint,将队列头部 2 个已经完成的事务出队,然后将 rli_checkpoint_seqno – 2,同时将 2 累加到每个 worker->bitmap_shifted 中,当协调线程将新的事务分给 worker 的时候,会将 worker->bitmap_shifted 取出,存在当前 Slave_job_group.shifted 中,当 worker 执行到这个 group(就是事务),就开始对 group_executed bitmap 进行偏移,偏移量就是 Slave_job_group.shitfed (再一次说明了 GAQ 中的 Slave_job_group,充当了协调线程和 worker 线程通信的角色)。bitmap 的变化就如下图所示,checkpoint 后,原来的 0 和 1 出队,然后新的 4、5、6 加入进来,新分发给 worker b 和 worker c 的 4 和 6 已经执行完成,所以 bitamp 和上图相比,已经向左路偏移了 2 位,而新分发 worker a 的 5 并示执行,所以 worker a 的 bitmap 还未偏移。

MySQL 5.7:并行复制原理(MTS)

MTS 中 gap 测试

前面我们主要描述了 MTS 多线程并发回放的原理。提到了一种情况,如果不设置 slave_preserve_commit_order 参数为 ON 的情况下,可能出现 gap。这种 gap 可能是由于在并行回放的事务中存在一个大事务没有执行完成,但其随后的事务已经由其他 worker 线程执行完成,意味着从库并行回放时候事务顺序发生变化,用户在从库端读取数据可能先读到后提交的事务(相对主库来说),这种场景就无法满足 Causal Consistency(因果一致性)。如果设置了 slave_preserve_commit_order 将会防止这种 gap 现象的存在,也就可以在并行回放的同时保证了 Causal Consistency。下面我们来测试这种 gap,然后解释为什么 slave_preserve_commit_order 参数设置为 ON 可以防止这种现象。

要测试 gap 需要使用 gtid auto_position 模式,通过观察 gtid set 来发现。

首先可以人为的调大参数 binlog_group_commit_sync_delay=1000000,也就是 1 秒,这样设置可能会导致简单的 DML 都需要 1 秒的时间。我们可以使用如下方式:

大事务 小事务
begin
begin
执行大事务
执行小事务
commit
commit

注意这两个 commit 发起间隔不能超过 1 秒,因此我们可以在两个窗口先打好 commit 命令,然后直接回车,同时这两个事务修改的记录是不能冲突的。

现在我使用上面的方法得到了 2 个可以并发执行的事务,如下,第一个是大事务,第二个是小事务。

从库我们可以观察到下面的现象:

这里我们可以发现 Executed_Gtid_Set 中缺少了 gno 为 187 的这个事务,因为这个事务正在执行,但是 188 这个事务已经由其他 worker 线程执行完成了,因此出现这种 gap。如果这个时候从库 MySQL 异常重启了,这个 gap 是需要填补起来的,具体怎么填补后面再说。

参数 slave_preserve_commit_order 的影响

首先我们应该知道,如果要开启参数 slave_preserve_commit_order,从库必须开启记录 event 功能,也就是 log_bin 和 log_slave_updates 参数都需要设置。

因为 slave_preserve_commit_order 参数的主要实现还是集中在 MYSQL_BIN_LOG::ordered_commit 函数中,如果不记录 event 的话根本就不会进入这个函数。还有一个参数 binlog_order_commits,这个参数主要用于保证 InnoDB 层提交顺序和 MySQL 层提交一致,并且这个参数默认是开启的。既然能保证顺序那么为什么还会出现 gap 呢?还需要参数 slave_preserve_commit_order 呢?

实际上这两个参数用处完全不一样:

  • binlog_order_commits:默认是打开的,主要用于保证 InnoDB 层提交顺序和 MySQL 层提交顺序一致,这样事务的可见顺序也就和 MySQL 层提交顺序一致了。它在 order commit 的 commit 阶段前生效,开启后按照 commit 队列顺序在 InnoDB 层提交事务,否则 commit 队列中的每个事务就各自进行 InnoDB 层提交(不按照 binary log 中事务的的顺序)。
  • slave_preserve_commit_order:虽然协调线程的分发是按照主库事务执行的顺序进行分发,但是每个 worker 线程执行完这个事务进行提交的时间却是不一定的。这里的顺序就是为了保证每个 worker 线程的事务提交顺序和主库事务执行的顺序一致。它在 order commit 的 flush 阶段前就生效。worker 线程的事务在等待获取自己提交权限期间会堵塞在状态“Waiting for preceding transaction to commit”下,如果并行执行的事务中有一个大事务,很容易出现这种情况,因为大事务迟迟不能提交,导致其他 worker 线程就会一直等待获取自己的提交权限。

要实现这个功能,我们只需要保证 worker 线程进行事务提交的顺序和协调线程的分发顺序一致就可以了,因为协调线程是顺序读取的 relay log,然后分发给 worker 线程的。那么下面我们来看看 slave_preserve_commit_order 参数具体的实现方法。

实现 slave_preserve_commit_order 的关键是添加了 Commit_order_manager 类,开启该参数会在获取 worker 时候向 Commit_order_manager 注册事务。

协调线程在完成事务的分发后将事务注册到一个队列中,元素就是 worker 线程的 ID。参考函数 Commit_order_manager::register_trx。

如前所述,worker 线程在提交事务进入 order commit 的时候,在事务进入 FLUSH_STAGE 前,会等待前面的事务都进入 FLUSH_STAGE。直到队列中的首个元素的 worker 线程 ID 和本 worker 线程 ID 相同则说明自己的提交时机到来了,事务开始进入 FLUSH_STAGE。整个过程的等待会处于状态 Waiting for preceding transaction to commit 中。参数函数 Commit_order_manager::wait_for_its_turn。

当本事务进入 FLUSH_STAGE 后,那么就可以从队列中去掉这个 worker 线程的 ID 了,唤醒下一个事务。参数函数 Commit_order_manager::unregister_trx。

在保证事务 binlog flush 的顺序后,通过 binlog_order_commit 参数即可获取同样的提交顺序,也就不存在 Causal Consistency(因果一致性),即在备库获得和主库完全一致的执行顺序。

可以看到这个实现过程其实还是比较简单的,但是其主要实现位于 MYSQL_BIN_LOG::ordered_commit 函数下,因此必须要记录 event 到 binary log 才行。这也是最大的限制,开启记录 event 到 binary log 就涉及到影响从库性能,和我们开启 MTS 想提搞从库性能的初衷相违背。

Warning

但是经过测试,这个参数在 MySQL 5.7.18 中设置之后,也无法保证从库上事务提交的顺序与 relay log 一致。 在 MySQL 5.7.19 设置后,从库上事务的提交顺序与 relay log 中一致(所以生产要想使用 MTS 特性,版本大于等于 MySQL 5.7.19 才是安全的)。

四、MySQL 5.7 并行复制测试

下图显示了开启 MTS 后,Slave 服务器的 QPS。测试的工具是 sysbench 的单表全 update 测试,测试结果显示在 16 个线程下的性能最好,从机的 QPS 可以达到 25000 以上,进一步增加并行执行的线程至 32 并没有带来更高的提升。而原单线程回放的 QPS 仅在 4000 左右,可见 MySQL 5.7 MTS 带来的性能提升,而由于测试的是单表,所以 MySQL 5.6 的 MTS 机制则完全无能为力了。

MySQL 5.7:并行复制原理(MTS)

五、mts 实践

说了这么多,要开启 enhanced multi-threaded slave 其实很简单,只需根据如下设置:

在使用了 MTS 后,复制的监控依旧可以通过 SHOW SLAVE STATUS\G,但是 MySQL 5.7 在 performance_schema 架构下多了以下这些元数据表,用户可以更细力度的进行监控:

通过 replication_applier_status_by_worker 可以看到 worker 进程的工作情况:

那么怎样知道从机 MTS 的并行程度又是一个难度不小。简单的一种方法(姜总给出的),可以使用 performance_schema 库来观察,比如下面这条 SQL 可以统计每个 Worker Thread 执行的事务数量,在此基础上再做一个聚合分析就可得出每个 MTS 的并行度:

如果线程并行度太高,不够平均,其实并行效果并不会好,可以试着优化。这种场景下,可以通过调整主服务器上的参数 binlog_group_commit_sync_delaybinlog_group_commit_sync_no_delay_count。前者表示延迟多少时间提交事务,后者表示组提交事务凑齐多少个事务再一起提交。总体来说,都是为了增加主服务器组提交的事务比例,从而增大从机MTS的并行度。

虽然 MySQL 5.7 推出的 Enhanced Multi-Threaded Slave 在一定程度上解决了困扰 MySQ L长达数十年的复制延迟问题。然而,目前 MTS 机制基于组提交实现,简单来说在主上是怎样并行执行的,从服务器上就怎么回放。这里存在一个可能,即若主服务器的并行度不够,则从机的并行机制效果就会大打折扣。MySQL 8.0 最新的基于 writeset 的 MTS 才是最终的解决之道。即两个事务,只要更新的记录没有重叠(overlap),则在从机上就可并行执行,无需在一个组,即使主服务器单线程执行,从服务器依然可以并行回放。相信这是最完美的解决之道,MTS 的最终形态。

最后,如果 MySQL 5.7 要使用 MTS 功能,必须使用最新版本,最少升级到 5.7.19 版本,修复了很多 Bug。比如这个 Bug

stop slave

类似单线程复制,stop slave 命令会终止 sql 线程和 worker 线程的运行。

sql 线程收到退出信号后,会先调用 slave_stop_workers 函数终止 worker 线程,过程如下:

  1. 依次把每个运行中的 worker 的 runnig_status 设置 Slave_worker::STOP,同时设置 worker 执行终止位置 rli->max_updated_index;
  2. sql 线程等待所有 worker 线程终止(w->running_status == Slave_worker::NOT_RUNNING);
  3. 调用 mts_checkpoint_routine 函数,做一次 checkpoint;
  4. 释放资源,如 GAQ、curr_group_da 等。

SQL 线程在 pop_jobs_item 函数中会调用 set_max_updated_index_on_stop 函数,会检查 2 个条件:

  1. worker job 队列是空的;
  2. 当前 worker 执行的事务在 GAQ 中的位置,是否已经超过 rli->max_updated_index;

任一条件满足就设置状态 running_status 为 Slave_worker::STOP_ACCEPTED,表示开始退出。

从上面的逻辑可以看出,在收到 stop 信号后,worker 线程会等正在执行的事务完成后,才会退出,是安全的。

异常退出

worker 线程被 kill 或者执行出错

  1. slave_worker_exec_job 进入错误处理逻辑,调用 Slave_worker::slave_worker_ends_group 函数,给 sql 线程发 KILL_QUERY 信号,然后做相关变量的清理,把 job 队列的任务全部清理掉,最终把 running_status 置为 Slave_worker::NOT_RUNNING,表示结束;
  2. sql 线程收到 kill 信号后,停止分发,然后进入 slave_stop_workers 逻辑,给活跃的 worker 线程发送 STOP 信号;
  3. 其它 worker 线程收到 STOP 信号后,会处理 job 队列中所有的 event;
  4. 和 stop slave 不同的是,sql 线程最后不会做 checkpoint。

sql 线程被 kill

sql 线程被 kill 的处理逻辑和 stop slave 差不多,不同之处在于等 worker 全部终止后,不会做 checkpoint。

非 GTID AUTO_POSITION 模式异常恢复

在非 GTID AUTO_POSITION 复制模式下,从库线程重启(正常关闭或者异常 kill)后,需要根据 sql 线程和每个 worker 线程的记录信息来进行恢复,推进到一个一致状态后再开始并行。

恢复的主要逻辑是 mts_recovery_groups 这个函数。

在启动从库的时候,如果 relay-log.info 中存的 Number_of_workers 不为 0,就说明之前是并行复制,然后调用 mts_recovery_groups,进入恢复逻辑。如前所述,mts_recovery_groups 的目的就是根据 slave_worker_info 和 slave_relay_log_info 中信息,把 gap 事务找出来。

首先会创建 Number_of_workers 个 worker,依次把每个 slave_worker_info 的信息读出来,然后把 worker 执行位点信息和 slave_relay_log_info 中记录的位点信息(低水位)相比,如果比后者小,说明崩溃前已经被 checkpoint 出队,不可能造成空隙,直接跳过;如果比后者大,就把 worker 存入 above_lwm_jobs 数组。above_lwm_jobs 收集完成后,初始化 bitmap rli->recovery_groups,用来汇总每个 worker 的 bitmap。对 above_lwm_jobs 中的每个 worker,设置一个计数器 recovery_group_cnt,从低水位位点开始扫 relay log,每扫完一个事务,recovery_group_cnt 加 1,直到扫到 worker.info 中记录的位点为止,之后把 worker 的 bitmap 汇总到 rli->recovery_groups 中,其间会统计一个最大的 recovery_group_cnt,记入 rli->mts_recovery_group_cnt,这个对应高水位。

bitmap 汇总逻辑如下:

之后 SQL 线程就可以从低水位往高水位扫 relay log,对于每个事务,如果 rli->recovery_groups 对应 bit 为 1,说明崩溃前已经执行过,就跳过;反之,就对事务中的每个 event 调用 do_apply_event 函数执行。扫描到高水位后整个恢复逻辑结束,后面 SQL 线程就进入正常的执行逻辑,执行(串行)或者分发(并行)event。

<摘自>

运维内参书籍

姜总的公众号文章

gh-ost 工具导致从库并行回放死锁

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

MySQL 5.7并行复制中并行的真正含义

Replication and Transaction Inconsistencies

http://mysql.taobao.org/monthly/2017/12/03/

http://mysql.taobao.org/monthly/2015/09/07/

MySQL并行复制在DROP语句下的死锁BUG源码剖析

MySQL的COMMIT_ORDER模式下组提交分组实现与BUG案例源码剖析


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

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

(1)个小伙伴在吐槽
  1. 请问下,2)若判断不可以并行执行,如该操作是DDL,亦或者是事务跨schema操作,则等待所有的worker线程执行完成之后,再执行当前的日志。 ---这个是什么场景呢?我这边测试结果是,还是会发给一个worker线程处理。
    小铁匠2018-04-20 07:10 Mac OS X | Chrome 65.0.3325.181