摘要:当多个引擎/节点同时访问和修改数据时,如何保证数据在各个引擎/节点之间的一致性成为了一项挑战。本文将深入探讨MySQL集群在保持数据一致性的解决方案。
本文分享自华为云社区《【华为云MySQL技术专栏】MySQL 8.0事务提交原理解析!》,作者:GaussDB数据库。
1. 概述
MySQL是一个插件式、支持多存储引擎架构的数据库。一方面,MySQL支持一个事务跨多个引擎进行读写,使得数据库系统具备良好的可扩展性和灵活性;另一方面,MySQL也支持一个事务跨多节点进行读写,通过分布式节点架构使MySQL消除了单点故障,提高数据库系统的可靠性和可用性。
然而,当多个引擎/节点同时访问和修改数据时,如何保证数据在各个引擎/节点之间的一致性成为了一项挑战。
本文将深入探讨MYSQL集群在保持数据一致性的解决方案。MySQL集群通过XA事务(X/Open Distributed Transaction Processing Model,简称X/Open DTP Model)解决了此问题。XA事务分为内部XA和外部XA事务,本文将聚焦内部XA的源码实现。
2. XA事务
XA事务定义了三个参与角色(APP、TM、RM),并通过两个阶段实现分布式事务。
图2.1 XA事务模型
- XA事务中的三个参与角色分别是:
APP(Application Program,简称APP):应用程序,定义事务的开始和结束。
TM(Transaction Manager,简称TM): 事务管理器,充当事务的协调者,监控事务的执行进度,负责事务的提交、回滚等。
RM(Resource Manager,简称RM): 资源管理器,充当事务的参与者,如数据库、文件系统,提供访问资源的方式。
- 实现分布式事务的两个阶段:
阶段一: TM向所有的RM发出PREPARE指令,RM进行完成提交前的准备工作,并刷新相关操作日志,此时不会进行事务提交。如果在PREPARE指令下发过程中某一RM节点失败,则回滚事务,TM向所有RM节点下发ROLLBACK指令,防止数据不一致的情况发生。
阶段二: 如果TM收到所有RM的成功消息,则TM向RM发出COMMIT指令,RM向TM返回提交成功的消息后,TM确认整个事务完成。如果任意一个RM节点COMMIT失败,则TM尝试重新下发COMMIT指令,尝试失败到上限次数后将返回报错,整个事务失败。
在单实例节点中,当Server层作为TM,多个存储引擎作为RM,就会产生内部XA事务,MySQL利用内部事务保证了多个存储引擎的一致性。外部XA事务一般是针对跨多MySQL实例的分布式事务,因此,外部XA的协调者是用户的应用,参与者是MySQL节点。
外部XA事务与内部XA事务核心逻辑类似,同时给用户提供了一套XA事务的操作命令,包括XA start,XA end,XA prepare和XA commit等。
3. 内部XA事务
在单个MYSQL实例中,使用内部XA事务来解决Server层Binlog日志和Storage层事务日志的一致性等问题。其中,Server层作为事务协调器,而多个存储引擎作为事务参与者。
3.1 协调者对象tc_log
MySQL启动时,包含了事务协调者的选择。如果开启了Binlog,并且存在事务引擎,则XA协调器为mysql_bin_log对象,使用Binlog物理文件记录事务状态;如果关闭了Binlog,且存在不少于2个事务引擎,则XA协调器为tc_log_mmap对象,使用内存结构来记录事务状态;其他情况(没有事务引擎),则不需要XA,tc_log设置为tc_log_dummy 对象。
无论tc_log_dummy还是mysql_bin_log或tc_log_mmap都基于TC_LOG这个基类来实现的。TC_LOG是一个全局指针,作为事务提交的协调器,实现了事务的prepare,commit,rollback等接口。
图3.1 TC_LOG类关系图
mysql_bin_log,tc_log_mmap和tc_log_dummy作为协调者的基本逻辑如下:
mysql_bin_log作为协调者: prepare:ha_prepare_low commit:write-binlog + ha_comit_low tc_log_mmap作为协调者: prepare:ha_prepare_low commit:wrtie-xid + ha_commit_low tc_log_dummy作为协调者: prepare:ha_prepare_low commit:ha_commit_low
其中tc_log_dummy不会记录事务日志,只是做简单的转发,将Server层的调用路由到Storage层调用。tc_log_mmap是一个标准的事务协调者实现,它会创建一个名为tc.log的日志并使用操作系统的内存映射(memory-map,mmap)机制将内容映射到内存中,tc.log文件中分为一个一个PAGE,每个PAGE上有多个XID(X/Open transaction IDentifier,全局事务唯一ID)。Binlog同样基于TC_LOG来实现事务协调者功能,会递增生成mysql-binlog.xxxx的文件,每个文件中包含多个事务产生的Binlog event,并在Binlog event中包含XID。tc_log_mmap和Binlog都基于XID来确定事务是否已提交。
本文主要关注于如何通过内部XA 保证Binlog和Redo log的一致性,即以Binlog作为协调器的场景,这里的Binlog既是协调者也是参与者。
3.2 事务提交过程
如图3.2为一个事务的执行过程,当客户端发出COMMIT指令时,MYSQL内部将通过Prepare和Commit两个阶段完成事务的提交。
图3.2 事务提交过程
Prepare阶段,事务的Undo log设置为prepare状态,写Prepare Log(Prepare阶段产生的Redo Log),将事务状态设为TRX_PREPARED,写XID(事务ID号)到Redo Log,同时把Redo Log刷新到磁盘中。
Commit阶段,Binlog写入文件并刷盘,同时也会把XID写入到Binlog。调用引擎的Commit完成事务的提交,同时会对事务的Undo log从prepare状态设置为提交状态(可清理状态),写Commit Log(Commit阶段产生的Redo log),释放锁、read view等,最后将事务状态设置为TRX_NOT_STARTED状态。
两阶段提交保证了事务在多个引擎之间的原子性,以Binlog写入成功作为事务提交的标志。
在崩溃恢复中,是以Binlog中的XID和Redo log中的XID进行比较,XID在Binlog 里存在则提交,不存在则回滚。我们来看崩溃恢复时具体的情况:
情况一:写入Redo log后,处于Prepare状态的时候崩溃了,此时:
由于Binlog还没写,Redo log处于Prepare状态还没提交,所以崩溃恢复的时候,这个事务会回滚,此时Binlog还没写,所以也不会传到备库。
情况二:假设写完Binlog之后崩溃了,此时:
Redo log中的日志是不完整的,处于Prepare状态,还没有提交,那么恢复的时候,首先检查Binlog中的事务是否完整(事务XID在Binlog里中存在,标志该事务已经完成),如果事务完整,则直接提交事务,否则回滚事务。
情况三:假设Redo log处于Commit状态的时候崩溃了,如果Binlog中的事务完整,那么会重新写入Commit标志,并完成提交,否则回滚事务。由此可见,两阶段提交能够确保数据的一致性。
一般常用的SQL语句都是通过公共接口mysql_execute_command来执行,我们来分析该接口执行的流程:
mysql_execute_command { switch (command) { case SQLCOM_COMMIT trans_commit(); break; } if thd->is_error() //语句执行报错 trans_rollback_stmt(thd); else trans_commit_stmt(thd); }
MySQL的Server层有两个提交函数trans_commit_stmt()和trans_commit()。前者在每个语句执行完成时调用,一般标记语句的结束。而后者是在整个事务真正提交的时候调用,一般对应显示执行COMMIT语句,或开启一个新事务BEGIN/START TRANSCATION,或执行一条非临时表的DDL语句等场景。
3.3 多语句事务提交
多语句事务提交一般指BEGIN/COMMIT显示事务,主要逻辑在trans_commit()中,以下是具体实现:
// mysql层进行的事务提交 int ha_commit_trans(THD *thd, bool all, bool ignore_global_read_lock) { Transaction_ctx *trn_ctx = thd->get_transaction(); // all为true,意味着当前是事务级提交范围,否则是语句级提交范围 Transaction_ctx::enum_trx_scope trx_scope = all ? Transaction_ctx::SESSION : Transaction_ctx::STMT ; // 获得注册在当前事务的引擎列表,在trans_register_ha()中初始化 Ha_trx_info *ha_info = trn_ctx->ha_trx_info(trx_scope); // 当前注册的可读可写存储引擎的数量,只有事务引擎支持读写 uint rw_ha_count = 0; // 检查是否可以跳过两阶段提交机制 rw_ha_count = ha_check_and_coalesce_trx_read_only(thd, ha_info, all); trn_ctx->set_rw_ha_count(trx_scope, rw_ha_count); // 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); goto end; } }
协调者如何确认是否走2PC(两阶段提交)逻辑?
这里主要根据事务修改是否涉及多个引擎来决定,即函数ha_check_and_coalesce_trx_read_only()。特殊的是,如果打开Binlog,Binlog也会作为参与者而被考虑在内,最终协调者会统计事务中涉及修改的参与者数量。如果数量超过1个,则进行2PC提交流程。
当满足以上条件,进入Prepare阶段,调用Binlog协调器的prepare接口。Prepare阶段,Binlog Prepare接口没什么可做,而InnoDB Prepare接口主要做的事情就是修改事务和Undo段的状态,以及记录XID。
InnoDB Prepare接口会把内存中事务对象的状态修改为TRX_STATE_PREPARED,并将事务对应Undo段在内存中的对象状态修改为TRX_UNDO_PREPARED。然后,把XID信息写入当前事务对应日志组的Undo Log Header中的XID区域。修改TRX_UNDO_STATE字段值和写入XID,这两个操作都要修改Undo页。修改Undo页之前,会先记录相应的Redo日志。最后,刷事务更新产生的Redo日志。
// innodb prepare,innodb层事务准备阶段 static void trx_prepare(trx_t *trx) /*!< in/out: transaction */ { lsn_t lsn = 0; // 对于系统和undo表空间回滚段,如果有更新需要持久化到redo中 if (trx->rsegs.m_redo.rseg != nullptr && trx_is_redo_rseg_updated(trx)) { // lsn = mtr.commit_lsn(); 开启第一个mtr,并返回写入redo log buffer后的最新位点,提交时刻对应的lsn lsn = trx_prepare_low(trx, &trx->rsegs.m_redo, false); } // 对于临时表空间回滚段,如果有更新不需要持久化到redo中 if (trx->rsegs.m_noredo.rseg != nullptr && trx_is_temp_rseg_updated(trx)) { trx_prepare_low(trx, &trx->rsegs.m_noredo, true); } // 更新事务和事务系统状态信息 trx->state = TRX_STATE_PREPARED; trx_sys->n_prepared_trx++; // 释放RC及以下隔离级别的GAP lock if (trx->isolation_level <= TRX_ISO_READ_COMMITTED) { trx->skip_lock_inheritance = true; lock_trx_release_read_locks(trx, true); } switch (thd_requested_durability(trx->mysql_thd)) { // thd初始化时默认设置为HA_REGULAR_DURABILITY case HA_REGULAR_DURABILITY: trx->ddl_must_flush = false; // redolog刷新 trx_flush_log_if_needed(lsn, trx); } }
紧接着进入2PC的Commit阶段,trans_commit()调用binlog协调器的MYSQL_BIN_LOG::Commit()接口,功能集中在MYSQL_BIN_LOG::ordered_commit()函数中。到了Commit阶段,一个事务就已经接近尾声了。写操作(包括增、删、改)已经完成,内存中的事务状态已经修改,Undo状态也已经修改,XID信息也已经写入Undo Log Header,Prepare阶段产生的Redo日志已经写入到Redo日志文件。剩余的收尾工作,包括Redo日志刷盘、事务的Binlog日志从临时存放点拷贝到Binlog日志文件、Binlog日志文件刷盘以及InnoDB事务提交。
// tc_log->commit ==> MYSQL_BIN_LOG::commit() MYSQL_BIN_LOG::commit() // 这个函数很重要,它包含了binlog组提交三步曲, int MYSQL_BIN_LOG::ordered_commit(THD *thd, bool all, bool skip_commit) { //1:Flush Stag:按照事务提交的顺序,先刷Redo log到磁盘,然后把每个事务产生的 binlog 日志从临时存放点拷贝到 binlog 日志文件缓存中 flush_error = process_flush_stage_queue(&total_bytes, &do_rotate, &wait_queue); //2: Sync Stage: binlog 日志刷盘之前会进入等待过程,目的是为了攒到更多的binlog日志后,合并IO单次刷盘 sync_binlog_file(false);//binlog fsync to disk //3: Commit Stage: 各线程按序提交事务 process_commit_stage_queue(thd, commit_queue); }
Redo Binlog日志刷盘都涉及到磁盘IO。如果每提交一个事务,都把该事务中的 Redo日志、Binlog日志刷盘,那么就会涉及到很多小数据量的IO操作,但是频繁的小数量IO操作非常消耗磁盘的读写性能。
为了提高磁盘IO效率并进一步提升事务的提交效率,MySQL从5.6开始引入了Binlog日志组提交功能。该功能将事务的Commit阶段细分为3个子阶段。对于每个子阶段,都可以有多个事务同时处于该子阶段,写日志和刷盘操作可以合并。
- Flush子阶段,先将Redo日志刷盘,接着将所有的binlog caches写入到binlog文件缓存中。
- Sync子阶段,对binlog文件缓存做fsync操作,多个线程的 binlog 合并为一次刷盘。
- Commit子阶段,依次将redolog中已经prepare的事务在引擎层提交,commit阶段不用刷盘,因为flush阶段中的redolog刷盘已经足够保证数据库崩溃时的数据安全了。当前Commit子阶段主要包含了InnoDB层的事务提交,真正执行事务提交入口函数为trx_commit_low()。trx_commit_low()主要分成两个部分trx_write_serialisation_history()和trx_commit_in_memory()。trx_write_serialisation_history()处理整个事务执行过程中所使用insert/update的回滚段的收尾工作。trx_commit_in_memory()在内存中设置事务提交的标志trx->state = TRX_STATE_COMMITTED_IN_MEMORY,本事务的数据可以即刻被其他事务可见;在设置事务提交已经完成的标志后,才会释放当前事务的Read View和事务过程中所持有的table lock和record lock,清除trx_sys系统中的当前事务等。
3.4 单语句事务提交
从SQL的执行过程分析可以看到,无论执行何种语句,最后都会执行trans_commit_stmt(),即单语句提交函数。如果当前是单语句事务,一般指AUTOCOMMIT为ON的场景,那么会走事务提交逻辑,即ha_commit_trans()函数。额外考虑到COMMIT和DDL语句等已经在调用trans_commit_stmt()之前将事务提交,所以在这里只需要标记语句结束即可。
// 执行单语句事务提 bool trans_commit_stmt(THD *thd, bool ignore_global_read_lock) { int res = false; // 单语句事务,需要走2PC提交逻辑 if (thd->get_transaction()->is_active(Transaction_ctx::STMT)) { res = ha_commit_trans(thd, false, ignore_global_read_lock); } else if (tc_log) // COMMIT/DDL等,只需要走引擎层提交逻辑,置为false,只标识语句结束,跳过真正提交阶段 res = tc_log->commit(thd, false); thd->get_transaction()->reset(Transaction_ctx::STMT); return res; }
ha_commit_trans()最后会走到innobase_commit()中,innobase_commit()中的参数commit_trx控制是否真的进行存储引擎层的提交处理,trans_commit_stmt()里会设置 commit_trx为0,允许跳过事务提交。
这里的判断逻辑是,只有当commit_trx= 1或者设置autocommit=1的情况下,才会真正进入事务提交逻辑。而多语句事务对应的trans_commit()函数里会设置commit_trx=1,进入innobase_commit_low()执行真正的事务提交逻辑。
/** 在innodb层提交一个事务 thd:需要提交事务的会话 commit_trx:true,需要提交事务。false,跳过事务提交。 */ static int innobase_commit(handlerton *hton, THD *thd, bool commit_trx) { trx_t *trx = check_trx_exists(thd); // innobase_commi仅在“真正的”commit时被调用,而且在每个语句之后(走trans_commit_stmt()函数)也被调用,因此这里需要will_commit判断是否要真正去提交事务。 bool will_commit = commit_trx || (!thd_test_options(thd, OPTION_NOT_AUTOCOMMIT | OPTION_BEGIN)); // autocommit=1且不在显示事务块中 if (will_commit) { /* 在显示提交commit,或者autocommit=1、且不在显示事务块内*/ innobase_commit_low(trx); } else { /* 其他情况,我们只是标记SQL语句结束,不做事务提交 */ trx_mark_sql_stat_end(trx); } return 0; }
4. 总结
本文从多语句/单语句事务提交原理角度出发,介绍了MySQL的两阶段提交协议。在prepare阶段,InnoDB把数据更新到内存后记录Redo log,此时Redo log的状态为prepare状态;在Commit阶段,Server生成Binlog后落盘,InnoDB把刚写入的Redo log状态更新为commit状态。两阶段提交保证了事务在多个引擎和Binlog之间的原子性,同样保证了通过备份和Binlog恢复出的数据库和原数据库的数据一致性。