MySQL 组提交

2017-10-01 database mysql

组提交 (group commit) 是为了优化写日志时的刷磁盘问题,从最初只支持 InnoDB redo log 组提交,到 5.6 官方版本同时支持 redo log 和 binlog 组提交,大大提高了 MySQL 的事务处理性能。

下面将以 InnoDB 存储引擎为例,详细介绍组提交在各个阶段的实现原理。

简介

自 5.1 之后,binlog 和 innodb 采用类似两阶段提交的方式,不过不支持 group commit;在 5.6 中,将 binlog 的 commit 阶段分为三个阶段:flush stagesync stage 以及 commit stage

这三个阶段中,每个阶段都会去维护一个队列,各个列表的定义如下。

Mutex_queue m_queue[STAGE_COUNTER];

如上,每个阶段都在维护一个队列,第一个进入该队列的作为 leader 线程,否则作为 follower 线程;leader 线程会收集 follower 的事务,并负责做 sync,follower 线程等待 leader 通知操作完成。

尽管维护了三个队列,但队列中所有的 THD 实际上都是通过 next_to_commit 连接起来。binlog 在事务提交阶段,也就是在 MYSQL_BIN_LOG::ordered_commit() 函数中,开始 3 个阶段的流程。

接下来,看看 MySQL 中事务是如何提交的。

事务提交

接下来,看看 InnoDB 和 binlog 提交的流程。

二阶段提交

详细介绍下二阶段提交的过程。

未开启binlog时

InnoDB 通过 redo 和 undo 日志来恢复数据库 (safe crash recovery),当数据恢复时,通过 redo 日志将所有已经在存储引擎内部提交的事务应用 redo log 恢复,所有已经 prepared 但是没有 commit 的事务则会通过 undo log 做回滚。

然后客户端连接时就能看到已经提交的数据存在数据库内,未提交被回滚地数据需要重新执行。

开启binlog时

为了保证存储引擎和 MySQL 的 binlog 保持一致,引入二阶段提交 (two phase commit, 2pc) 。

因为备库通过 binlog 重放主库提交的事务,假设主库存储引擎已经提交而 binlog 没有保持一致,则会使备库数据丢失造成主备数据不一致。

二阶段提交

如下是二阶段提交流程。

group commit 2pc

详细执行流程为:

  1. InnoDB 的事务 Prepare 阶段,即 SQL 已经成功执行并生成 redo 和 undo 的内存日志;
  2. binlog 提交,通过 write() 将 binlog 内存日志数据写入文件系统缓存;
  3. fsync() 将 binlog 文件系统缓存日志数据永久写入磁盘;
  4. InnoDB 内部提交,commit 阶段在存储引擎内提交,通过 innodb_flush_log_at_trx_commit 参数控制,使 undo 和 redo 永久写入磁盘。

开启 binlog 的 MySQL 在崩溃恢复 (crash recovery) 时:

  • 在 prepare 阶段崩溃,恢复时该事务未写入 binlog 且 InnoDB 未提交,该事务直接回滚;
  • 在 binlog 已经 fsync() 永久写入 binlog,但 InnoDB 未来得及 commit 时崩溃;恢复时,将会从 binlog 中获取提交的信息,重做该事务并提交,使 InnoDB 和 binlog 始终保持一致。

以上提到单个事务的二阶段提交过程,能够保证 InnoDB 和 binlog 保持一致,但是在并发的情况下怎么保证存储引擎和 binlog 提交的顺序一致?当并发提交的时,如果两者不一致会造成什么影响?

组提交异常

首先看看,对于上述的问题,当并发提交的时,如果两者不一致会造成什么影响?

group commit 2pc

如上所示,事务按照 T1T2T3 顺序开始执行,并依相同次序按照写入 binlog 日志文件系统缓存,调用 fsync() 进行一次组提交,将日志文件永久写入磁盘。

但是存储引擎提交的顺序为 T2T3T1,当 T2T3 提交事务之后做了一个 On-line 的备份程序新建一个 slave 来做复制;而搭建备库时,CHANGE MASTER TO 的日志偏移量在 T3 事务之后。

那么事务 T1 在备机恢复 MySQL 数据库时,发现 T1 未在存储引擎内提交,那么在恢复时,T1 事务就会被回滚,此时就会导致主备数据不一致。

结论:需要保证 binlog 的写入顺序和 InnoDB 事务提交顺序一致,用于 xtrabackup 备份恢复。

早期解决方案

早期,使用 prepare_commit_mutex 保证顺序,只有当上一个事务 commit 后释放锁,下个事务才可以进行 prepara 操作,并且在每个事务过程中 binlog 没有 fsync() 的调用。

group commit 2pc concurrency mutex

由于内存数据写入磁盘的开销很大,如果频繁 fsync() 把日志数据永久写入磁盘,数据库的性能将会急剧下降。为此提供 sync_binlog 参数来设置多少个 binlog 日志产生的时候调用一次 fsync() 把二进制日志刷入磁盘来提高整体性能,该参数的设置作用为:

  • sync_binlog=0 二进制日志 fsync() 的操作基于系统自动执行。
  • sync_binlog=1 每次事务提交都会调用 fsync(),最大限度保证数据安全,但影响性能。
  • sync_binlog=N 当数据库崩溃时,可能会丢失 N-1 个事务。

prepare_commit_mutex 的锁机制会严重影响高并发时的性能,而且 binlog 也无法执行组提交。

改进方案

接下来,看看如何保证 binlog 写入顺序和存储引擎提交顺序是一致的,并且能够进行 binlog 的组提交?5.6 引入了组提交,并将提交过程分成 Flush stage、Sync stage、Commit stage 三个阶段。

这样,事务提交时分为了如下的阶段:

InnoDB, Prepare
    SQL已经成功执行并生成了相应的redo和undo内存日志;
Binlog, Flush Stage
    所有已经注册线程都将写入binlog缓存;
Binlog, Sync Stage
    binlog缓存将sync到磁盘,sync_binlog=1时该队列中所有事务的binlog将永久写入磁盘;
InnoDB, Commit stage
    leader根据顺序调用存储引擎提交事务;

每个 Stage 阶段都有各自的队列,从而使每个会话的事务进行排队,提高并发性能。

如果当一个线程注册到一个空队列时,该线程就做为该队列的 leader,后注册到该队列的线程均为 follower,后续的操作,都由 leader 控制队列中 follower 行为。

leader 同时会带领当前队列的所有 follower 到下一个 stage 去执行,当遇到下一个 stage 为非空队列时,leader 会变成 follower 注册到此队列中;注意:follower 线程绝不可能变成 leader 。

配置参数

与 binlog 组提交相关的参数主要包括了如下两个参数。

binlog_max_flush_queue_time

单位为微妙,用于从 flush 队列中取事务的超时时间,这主要是防止并发事务过高,导致某些事务的 RT 上升,详细的内容可以查看函数 MYSQL_BIN_LOG::process_flush_stage_queue()

注意:该参数在 5.7 之后已经取消了。

binlog_order_commits

当设置为 0 时,事务可能以和 binlog 不同的顺序提交,其性能会有稍微提升,但并不是特别明显.

源码解析

binlog 的组提交是通过 Stage_manager 管理,其中比较核心内容如下。

class Stage_manager {
  public:
    enum StageID {         // binlog的组提交包括了三个阶段
      FLUSH_STAGE,
      SYNC_STAGE,
      COMMIT_STAGE,
      STAGE_COUNTER
    };
  private:
    Mutex_queue m_queue[STAGE_COUNTER];
};

组提交 (Group Commit) 三阶段流程,详细实现如下。

MYSQL_BIN_LOG::ordered_commit()            执行事务顺序提交,binlog group commit的主流程
 |
 |-#########>>>>>>>>>                     ← 进入Stage_manager::FLUSH_STAGE阶段
 |-change_stage(..., &LOCK_log)
 | |-stage_manager.enroll_for()            将当前线程加入到m_queue[FLUSH_STAGE]
 | |
 | |                                       (follower)返回true
 | |-mysql_mutex_lock()                    (leader)LOCK_log加锁,并返回false
 |
 |-finish_commit()                         (follower)对于follower则直接返回
 | |-ha_commit_low()
 |
 |-process_flush_stage_queue()             (leader)对于follower则直接返回
 | |-fetch_queue_for()                     通过stage_manager获取队列中的成员
 | | |-fetch_and_empty()                   获取元素并清空队列
 | |-ha_flush_log()
 | |-flush_thread_caches()                 对于每个线程做该操作
 | |-my_b_tell()                           判断是否超过了max_bin_log_size,如果是则切换binlog文件
 |
 |-flush_cache_to_file()                   (follower)I/O Cache中的内容写到文件中
 |-RUN_HOOK()                              调用HOOK函数,也就是binlog_storage->after_flush()
 |
 |-#########>>>>>>>>>                     ← 进入Stage_manager::SYNC_STAGE阶段
 |-change_stage()
 |-sync_binlog_file()
 | |-mysql_file_sync()
 |   |-my_sync()
 |     |-fdatasync()                       调用系统API写入磁盘,也可以是fsync()
 |
 |-#########>>>>>>>>>                     ← 进入Stage_manager::COMMIT_STAGE阶段
 |-change_stage()                          该阶段会受到binlog_order_commits参数限制
 |-process_commit_stage_queue()            会遍厉所有线程,然后调用如下存储引擎接口
 | |-ha_commit_low()
 |   |-ht->commit()                        调用存储引擎handlerton->commit()
 |   |                                     ### 注意,实际调用如下的两个函数
 |   |-binlog_commit()
 |   |-innobase_commit()
 |-process_after_commit_stage_queue()      提交之后的后续处理,例如semisync
 | |-RUN_HOOK()                            调用transaction->after_commit
 |
 |-stage_manager.signal_done()             通知其它线程事务已经提交
 |
 |-finish_commit()

enroll_for() 函数中,刚添加的线程如果是队列的第一个线程,就将其设置为 leader 线程;否则就是 follower 线程,此时线程会睡眠,直到被 leader 唤醒 (m_cond_done) 。

注意,binlog_max_flush_queue_time 参数已经取消。

commit stage

如上所述,commit 阶段会受到参数 binlog_order_commits 的影响,当该参数关闭时,会直接释放 LOCK_sync,各个 session 自行进入 InnoDB commit 阶段,这样不会保证 binlog 和事务 commit 的顺序一致。

当然,如果你不关注两者的一致性,那么可以关闭这个选项来稍微提高点性能;当打开了上述的参数,才会进入 commit stage 。