一文带你深度解析MySQL 8.0事务提交原理
  YqbaJkf98QJO 2024年08月13日 31 0

摘要:当多个引擎/节点同时访问和修改数据时,如何保证数据在各个引擎/节点之间的一致性成为了一项挑战。本文将深入探讨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事务中的三个参与角色分别是:

APPApplication Program,简称APP):应用程序,定义事务的开始和结束。

TMTransaction Manager,简称TM事务管理器,充当事务的协调者,监控事务的执行进度,负责事务的提交、回滚等。

RMResource 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中的XIDRedo log中的XID进行比较,XIDBinlog 里存在则提交,不存在则回滚。我们来看崩溃恢复时具体的情况:

情况一:写入Redo log后,处于Prepare状态的时候崩溃了,此时:

由于Binlog还没写,Redo log处于Prepare状态还没提交,所以崩溃恢复的时候,这个事务会回滚,此时Binlog还没写,所以也不会传到备库。

情况二:假设写完Binlog之后崩溃了,此时:

Redo log中的日志是不完整的,处于Prepare状态,还没有提交,那么恢复的时候,首先检查Binlog中的事务是否完整(事务XIDBinlog里中存在,标志该事务已经完成),如果事务完整,则直接提交事务,否则回滚事务。

情况三:假设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恢复出的数据库和原数据库的数据一致性。

点击关注,第一时间了解华为云新鲜技术~

【版权声明】本文内容来自摩杜云社区用户原创、第三方投稿、转载,内容版权归原作者所有。本网站的目的在于传递更多信息,不拥有版权,亦不承担相应法律责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@moduyun.com

  1. 分享:
最后一次编辑于 2024年08月13日 0

暂无评论

推荐阅读
  KHjRGEjcFzrV   6天前   19   0   0 MySQL
YqbaJkf98QJO