MySQL 组提交原理
MySQL 中事务的两阶段提交保证了 redo log 与 binlog 两种日志文件的数据一致性,但是并发事务场景下还需要保证事务顺序的一致性,因此通过组提交机制在保证顺序一致性的前提下提高写入效率。因此组提交是两阶段提交的一部分。
两阶段提交
组提交是两阶段提交的一部分,因此首先回顾下两阶段提交(2PC)。其中:
-
prepare 阶段,其中负责 redo log 刷盘;
-
commit 阶段,其中负责 binlog 刷盘与存储引擎层面的事务提交。
事务提交的顺序
MySQL 的内部 XA 机制也就是两阶段提交保证了单个事务在 binlog 和 InnoDB 之间的原子性,但是在多个事务并发执行的情况下,怎么保证在 binlog 和 redo log 中事务的顺序一致?
在回答这个问题之前需要明确为什么需要保证 binlog 与 redo log 中事务顺序的一致性?
原因是这种一致性对于保持数据的完整性和一致性、支持高效的数据恢复和复制机制至关重要。其中:
数据恢复,比如基于 binlog 进行按时间点恢复(PITR)时,如果两者顺序不一致,将导致事务以不同的顺序被应用,违法 ACID 原则;
复制一致性,如果两者顺序不一致,将导致主从事务以不同的顺序被应用,从而导致主从数据不一致;
事务原子性,如果两者顺序不一致,将导致事务无法被正确的回滚或提交,可能导致只有部分事务更改被持久化,违反事务的原子性;
避免死锁,如果两者顺序不一致,可能导致恢复或复制过程中发生死锁,尤其是当涉及到多个事务并发修改同一数据行时。
早期解决方法
那么,如何保证 binlog 与 redo log 中事务的顺序一致呢?
MySQL 5.6 版本之前,使用 prepare_commit_mutex 对整个 2PC 过程进行加锁,只有当上一个事务 commit 后释放锁,下个事务才可以进行 prepare 操作,通过串行化的执行方式保证了顺序一致,类似于事务隔离级别中的串行化(Serializable)。
显然 prepare_commit_mutex 的锁机制会严重影响高并发时的性能,原因是多个小 IO 是非常低效的方式,串行化导致响应变慢。
在双 1 条件下,在每个事务执行过程中, 都会至少调用 3 次刷盘操作,包括写 redo log,写 binlog,写 commit。而多个小 IO 是非常低效的方式,因此可能导致写入出现性能瓶颈。为提高并发性能,细化锁粒度,引入组提交(group commit),可以理解为批量提交。
具体又可以分为 binlog 组提交与 redo log 组提交。注意 redo log 与 binlog 都是顺序写,而磁盘的顺序写比随机写速度要快。
组提交
binlog
MySQL 5.6 中针对 binlog 引入组提交功能,prepare 阶段不变,只针对 commit 阶段,将 commit 阶段拆分为三个阶段:
-
flush:多个线程按进入的顺序将 binlog 从 cache 写入日志文件(binlog write);
-
sync:将多个线程的 binlog 合并一次刷盘(binlog sync);
-
commit:各个线程按顺序做 InnoDB commit 操作。
其中:
-
对于每个子阶段,都可以有多个事务处于该子阶段;
-
每个子阶段有单独的 lock 进行保护,因此保证了事务写入的顺序。
组提交实现的基本思想是通过批量写入将日志文件的小数据量多次 IO 变为大数据量更少次数 IO,从而提升磁盘 IO 效率。但是为了合并日志的写入、刷盘参数,就需要一个协调者的角色。如果引入一个单独的协调线程,会增加额外开销。MySQL 的解决方案是把处于同一个子阶段的事务线程分为两种角色:
leader 线程,第 1 个进入某个子阶段的事务线程,就是该子阶段当前分组的 leader 线程;
follower 线程,第 2 个及以后进入某个子阶段的事务线程,都是该子阶段当前分组的 follower 线程。事务线程指的是事务所在的那个线程,我们可以把事务线程看成事务的容器,一个线程执行一个事务。leader 线程管事的方式,并不是指挥 follower 线程干活,而是自己帮 follower 线程把活都干了。
commit 细分为 3 个子阶段之后,每个子阶段会有一个
队列用于记录哪些事务线程处于该子阶段。为了保证先进入 flush 子阶段的事务线程一定先进入 sync 子阶段,先进入 sync 子阶段的事务线程一定先进入 commit 子阶段,每个子阶段都会持有一把互斥锁。
这里可以提出一个问题,如何判断是否可以加入队列?
同时进入 prepare 阶段的多个事务必然没有锁冲突,因此不需要判断是否可以加入 flush 阶段,其他阶段入队顺序相同,因此同样没有冲突。
redo log
MySQL 5.7 针对 redo log 引入组提交功能,同时修改 prepare 阶段与 commit 阶段。
-
5.6 中 redo log 的刷盘操作在 prepare 阶段完成,因此每个事务的 redo log fsync 操作成为性能瓶颈;
-
5.7 中将 prepare 阶段中每个线程各自执行 redo log fsync 操作推迟到组提交的 flush 阶段之中,binlog fsync 阶段之前。
显然,通过推迟 redo log fsync 的时间,一次组提交中的成员更多,节省 IOPS 的效果更好。
总结
不同版本中两阶段提交过程中 redo log 与 binlog 的写入与刷盘时间对比见下图。

其中:
-
redo log 与 binlog 中都分开执行 write 与 fsync;
-
redo log fsync 推迟到 binlog write 之后。
然后,整理下 5.7 中两阶段提交中每个阶段中主要做的事情:
-
prepare
-
将 redo log 写入 redo log buffer;
-
获取上一个事务最大的 sequence number 时间戳;
-
将事务状态设置为 prepare,具体是将 trx_state_t 从
TRX_STATE_ACTIVE修改为TRX_STATE_PREPARED; -
将 undo log segment 的状态 TRX_UNDO_STATE 从
TRX_UNDO_ACTIVE修改为TRX_UNDO_PREPARED; -
将 XID 写入 undo log,然后生成并将 XID_EVENT 写入 binlog cache;
-
针对 RC 事务隔离级别,释放 gap lock。
-
-
commit
-
redo log write + fsync
-
binlog write + fsync
-
存储引擎层事务提交
-
其中 commit 阶段基于组提交拆分为三个阶段:
-
flush
-
redo log write + fsync,针对 prepare 状态的 redo log;
-
binlog write;
-
生成 GTID、sequence_number、last_committed,然后生成并将 GTID_EVENT 写入 binlog 文件;
-
如果 sync_binlog != 1,更新 binlog 位点,唤醒 DUMP 线程给从库发送 EVENT,与主从复制有关。
-
-
sync
-
binlog fsync;
-
如果 sync_binlog == 1,更新 binlog 位点,唤醒 DUMP 线程给从库发送 EVENT,与主从复制有关;
-
调用 after_sync,与半同步复制有关。
-
-
commit
-
关闭 MVCC Read view
-
持久化 GTID
-
释放 insert undo log
-
释放锁
-
生成 gtid event;
-
将事务状态更新为 TRX_STATE_COMMITTED_IN_MEMORY,表示事务已经完成了二阶段提交的 2 个阶段,还剩一些收尾工作没做,这种状态的事务修改的数据已经可以被其它事务看见了;
-
存储引擎层事务提交;
-
调用 after_commit,与半同步复制有关。
-
其中除了日志写入外,还主要包括主从复制与并行复制。
其中可以明确看到整个过程主要分为三个阶段,包括 flush、sync、commit。

最后,是组提交的具体实现。
前面提到,commit 细分为 3 个子阶段之后,每个子阶段会有一个队列用于记录哪些事务线程处于该子阶段,如下图所示。

其中:
-
组提交的原理是将 commit 阶段细分为多个子阶段,其中每个子阶段对应一个队列 + 锁;
-
队列的作用是通过将多个事务分批,从而实现日志的批量写入与刷盘;
-
锁的作用是通过给每个队列加锁,因此同一时间每种队列最多只有一个,从而保证多批事务的顺序执行;
-
每个队列中事务的数量可能不一样,但是事务的顺序一样,保持事务加入到队列的顺序。
下面结合源码分析组提交的实现,其中主要讲解流程,具体源码实现详见参考教程。
实现
trans_commit
事务提交的入口函数是 trans_commit,其中调用 ha_commit_trans 函数。
ha_commit_trans 函数中主要判断是否需要写入 GTID 信息,并开始两阶段提交。
int ha_commit_trans(THD *thd, bool all, bool ignore_global_read_lock)
{
/*
Save transaction owned gtid into table before transaction prepare
if binlog is disabled, or binlog is enabled and log_slave_updates
is disabled with slave SQL thread or slave worker thread.
*/
// 判断是否需要写入 GTID 信息
error= commit_owned_gtids(thd, all, &need_clear_owned_gtid);
...
// prepare 阶段
if (!trn_ctx->no_2pc(trx_scope) && (trn_ctx->rw_ha_count(trx_scope) > 1))
error= tc_log->prepare(thd, all);
// commit 阶段
if (error || (error= tc_log->commit(thd, all)))
{
ha_rollback_trans(thd, all);
error= 1;
goto end;
}
...
}
其中:
-
注释显示如果关闭 binlog,或者开启 binlog,但是关闭 log_slave_updates 将 GTID 写入 mysql.gtid_executed 表和 @@GLOBAL.GTID_EXECUTED 变量中,就像开启 binlog 一样;
-
MYSQL_BIN_LOG::commit 函数中调用 MYSQL_BIN_LOG::ordered_commit 函数实现组提交。其中:
-
分为三个阶段(stage),分别对应组提交中 flush、sync、commit 阶段;
-
每个阶段的进入都要调用 MYSQL_BIN_LOG::change_stage 函数进行阶段转换。
-
prepare
MYSQL_BIN_LOG::prepare 函数中调用 MYSQL_BIN_LOG::ha_prepare_low 函数,其中调用 ht->prepare 函数。
prepare 接口由存储引擎层实现,InnoDB 存储引擎初始化注册的函数原型显示 prepare 接口对应 innobase_xa_prepare 函数。
static int

最低0.47元/天 解锁文章
1305

被折叠的 条评论
为什么被折叠?



