当前位置:   article > 正文

一文带你深度解析MySQL 8.0事务提交原理

一文带你深度解析MySQL 8.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事务中的三个参与角色分别是:

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作为协调者:

  1. prepare:ha_prepare_low
  2. commit:write-binlog + ha_comit_low

tc_log_mmap作为协调者:

  1. prepare:ha_prepare_low
  2. commit:wrtie-xid + ha_commit_low

tc_log_dummy作为协调者:

  1. prepare:ha_prepare_low
  2. 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来执行,我们来分析该接口执行的流程:

  1. mysql_execute_command
  2. {
  3. switch (command)
  4. {
  5. case SQLCOM_COMMIT
  6. trans_commit();
  7. break;
  8. }
  9. if thd->is_error() //语句执行报错
  10. trans_rollback_stmt(thd);
  11. else
  12. trans_commit_stmt(thd);
  13. }

MySQL的Server层有两个提交函数trans_commit_stmt()和trans_commit()。前者在每个语句执行完成时调用,一般标记语句的结束。而后者是在整个事务真正提交的时候调用,一般对应显示执行COMMIT语句,或开启一个新事务BEGIN/START TRANSCATION,或执行一条非临时表的DDL语句等场景。

3.3 多语句事务提交

多语句事务提交一般指BEGIN/COMMIT显示事务,主要逻辑在trans_commit()中,以下是具体实现:

  1. // mysql层进行的事务提交
  2. int ha_commit_trans(THD *thd, bool all, bool ignore_global_read_lock) {
  3. Transaction_ctx *trn_ctx = thd->get_transaction();
  4. // alltrue,意味着当前是事务级提交范围,否则是语句级提交范围
  5. Transaction_ctx::enum_trx_scope trx_scope = all ? Transaction_ctx::SESSION : Transaction_ctx::STMT ;
  6. // 获得注册在当前事务的引擎列表,在trans_register_ha()中初始化
  7. Ha_trx_info *ha_info = trn_ctx->ha_trx_info(trx_scope);
  8. // 当前注册的可读可写存储引擎的数量,只有事务引擎支持读写
  9. uint rw_ha_count = 0;
  10. // 检查是否可以跳过两阶段提交机制
  11. rw_ha_count = ha_check_and_coalesce_trx_read_only(thd, ha_info, all);
  12. trn_ctx->set_rw_ha_count(trx_scope, rw_ha_count);
  13. // Prepare 阶段
  14. if (!trn_ctx->no_2pc(trx_scope) && (trn_ctx->rw_ha_count(trx_scope) > 1))
  15. error = tc_log->prepare(thd, all);
  16. }
  17. // Commit 阶段
  18. if (error || (error = tc_log->commit(thd, all))) {
  19. ha_rollback_trans(thd, all);
  20. goto end;
  21. }
  22. }

协调者如何确认是否走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日志。

  1. // innodb prepare,innodb层事务准备阶段
  2. static void trx_prepare(trx_t *trx) /*!< in/out: transaction */
  3. {
  4. lsn_t lsn = 0;
  5. // 对于系统和undo表空间回滚段,如果有更新需要持久化到redo中
  6. if (trx->rsegs.m_redo.rseg != nullptr && trx_is_redo_rseg_updated(trx)) {
  7. // lsn = mtr.commit_lsn(); 开启第一个mtr,并返回写入redo log buffer后的最新位点,提交时刻对应的lsn
  8. lsn = trx_prepare_low(trx, &trx->rsegs.m_redo, false);
  9. }
  10. // 对于临时表空间回滚段,如果有更新不需要持久化到redo中
  11. if (trx->rsegs.m_noredo.rseg != nullptr && trx_is_temp_rseg_updated(trx)) {
  12. trx_prepare_low(trx, &trx->rsegs.m_noredo, true);
  13. }
  14. // 更新事务和事务系统状态信息
  15. trx->state = TRX_STATE_PREPARED;
  16. trx_sys->n_prepared_trx++;
  17. // 释放RC及以下隔离级别的GAP lock
  18. if (trx->isolation_level <= TRX_ISO_READ_COMMITTED) {
  19. trx->skip_lock_inheritance = true;
  20. lock_trx_release_read_locks(trx, true);
  21. }
  22. switch (thd_requested_durability(trx->mysql_thd)) {
  23. // thd初始化时默认设置为HA_REGULAR_DURABILITY
  24. case HA_REGULAR_DURABILITY:
  25. trx->ddl_must_flush = false;
  26. // redolog刷新
  27. trx_flush_log_if_needed(lsn, trx);
  28. }
  29. }

紧接着进入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事务提交。

  1. // tc_log->commit ==> MYSQL_BIN_LOG::commit()
  2. MYSQL_BIN_LOG::commit()
  3. // 这个函数很重要,它包含了binlog组提交三步曲,
  4. int MYSQL_BIN_LOG::ordered_commit(THD *thd, bool all, bool skip_commit) {
  5. //1:Flush Stag:按照事务提交的顺序,先刷Redo log到磁盘,然后把每个事务产生的 binlog 日志从临时存放点拷贝到 binlog 日志文件缓存中
  6. flush_error = process_flush_stage_queue(&total_bytes, &do_rotate, &wait_queue);
  7. //2: Sync Stage: binlog 日志刷盘之前会进入等待过程,目的是为了攒到更多的binlog日志后,合并IO单次刷盘
  8. sync_binlog_file(false);//binlog fsync to disk
  9. //3: Commit Stage: 各线程按序提交事务 process_commit_stage_queue(thd, commit_queue);
  10. }

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()之前将事务提交,所以在这里只需要标记语句结束即可。

  1. // 执行单语句事务提
  2. bool trans_commit_stmt(THD *thd, bool ignore_global_read_lock) {
  3. int res = false;
  4. // 单语句事务,需要走2PC提交逻辑
  5. if (thd->get_transaction()->is_active(Transaction_ctx::STMT)) {
  6. res = ha_commit_trans(thd, false, ignore_global_read_lock);
  7. } else if (tc_log)
  8. // COMMIT/DDL等,只需要走引擎层提交逻辑,置为false,只标识语句结束,跳过真正提交阶段
  9. res = tc_log->commit(thd, false);
  10. thd->get_transaction()->reset(Transaction_ctx::STMT);
  11. return res;
  12. }

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()执行真正的事务提交逻辑。

  1. /** 在innodb层提交一个事务
  2. thd:需要提交事务的会话
  3. commit_trx:true,需要提交事务。false,跳过事务提交。
  4. */
  5. static int innobase_commit(handlerton *hton, THD *thd, bool commit_trx)
  6. {
  7. trx_t *trx = check_trx_exists(thd);
  8. // innobase_commi仅在“真正的”commit时被调用,而且在每个语句之后(走trans_commit_stmt()函数)也被调用,因此这里需要will_commit判断是否要真正去提交事务。
  9. bool will_commit =
  10. commit_trx ||
  11. (!thd_test_options(thd, OPTION_NOT_AUTOCOMMIT | OPTION_BEGIN)); // autocommit=1且不在显示事务块中
  12. if (will_commit) {
  13. /* 在显示提交commit,或者autocommit=1、且不在显示事务块内*/
  14. innobase_commit_low(trx);
  15. } else {
  16. /* 其他情况,我们只是标记SQL语句结束,不做事务提交 */
  17. trx_mark_sql_stat_end(trx);
  18. }
  19. return 0;
  20. }

4. 总结

本文从多语句/单语句事务提交原理角度出发,介绍了MySQL的两阶段提交协议。在prepare阶段,InnoDB把数据更新到内存后记录Redo log,此时Redo log的状态为prepare状态;在Commit阶段,Server生成Binlog后落盘,InnoDB把刚写入的Redo log状态更新为commit状态。两阶段提交保证了事务在多个引擎和Binlog之间的原子性,同样保证了通过备份和Binlog恢复出的数据库和原数据库的数据一致性。

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

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/人工智能uu/article/detail/1014101
推荐阅读
相关标签
  

闽ICP备14008679号

        
cppcmd=keepalive&