XA事务与两阶段提交

标签: MySQL是怎样运行的


什么是分布式事务

我们平常使用事务的时候,基本流程是这样的:

  • 使用BEGIN/START TRANSACTION来开启一个事务。
  • 然后可以继续向服务器发送一些增删改查语句,这些语句都属于这个事务的一部分。
  • 之后可以向服务器发送COMMIT语句来表明这个事务的所有语句都已经发送完了,服务器可以提交这个事务了。

小贴士:

如果auto_commit系统变量值为1,并且我们未显式使用BEGIN/START TRANSACTION开启事务,那MySQL也会将单条语句当作是一个事务来执行。

我们知道MySQL分为server层和存储引擎层,而事务具体是在存储引擎层实现的。有的存储引擎支持事务,有的不支持。

对于支持事务的存储引擎来说,它们提供了相应的开启事务、提交事务的接口。server层只需要调用这些接口,来让存储引擎执行事务。

除了MySQL自带的支持事务的存储引擎InnoDB外,其他一些公司也为MySQL开发了一些支持事务的存储引擎,比方说阿里的XEngine,Facebook的Rocksdb等。

在书写包含在一个事务中的语句时,不同语句可能会涉及不同存储引擎的表,这时如果我们想保持整个事务要么全部执行,要么全部不执行的话,本质上就需要保证各个存储引擎的事务全部提交,或者全部回滚。不能存在某些存储引擎事务提交了,某些存储引擎事务回滚了的情况。

稍微总结一下就是:我们有一个大的事务,我们可以称其为全局事务,这个全局事务由若干的小的事务组成。要实现这个大的事务,就必须让它对应的若干个小的事务全部完成,或者全部回滚。我们也可以把这个大的全局事务称作分布式事务

除了上述涉及多个存储引擎的全局事务之外,分布式事务还有更多的应用场景。比方说我们的数据分布在多个MySQL服务器中;甚至有的数据分布在MySQL服务器中,有的数据分布在Oracle服务器中;甚至有些服务器在中国,有些服务器在美国。我们想完成一个操作,这个操作会更新多个系统里的数据,此时如果我们想让这个操作具有原子性,就需要保证让各个系统中的小事务要么全部提交,要么全部回滚。这时的这个跨多个系统的操作也可以被称作分布式事务

跨行转账是一个典型的分布式事务的实例。各个银行都有自己的服务,如果狗哥在招商银行存了10块钱,他想把这10块钱转给猫爷在建设银行的账户,那么招商银行先得给狗哥账户扣10块,然后建设银行给猫爷账户增10块。而招商银行和建设银行根本就不是一个系统,招商银行给狗哥扣钱的业务放到了自己的一个事务里,建设银行给猫爷加钱的业务放到了自己的一个事务里,这两个事务其实在各自的系统中并没有什么关系,完全有可能招商银行的事务提交了,而建设银行的事务由于系统宕机而失败了,这就导致狗哥扣了钱,却没有转给猫爷的惨剧发生。所以我们必须引入一些机制,来实现分布式事务

XA规范

有一个名叫X/Open(这名儿听着就挺霸气)的组织提出了一个名为XA的规范。

小贴士:

为节省同志们去搜索这份规范的宝贵时间,大家在“我们都是小青蛙”公众号输入“XA”即可下载该规范。

有人说XA的含义是Extended Architecture。令人迷惑的是,我竟然没在上述规范中找到XA到底是个啥意思(很尴尬😅),大家把它理解成一个名字就好了,其实叫成王尼玛也没啥问题。

这个XA规范提出了2个角色:

  • 一个全局事务由多个小的事务组成,所以我们得在某个地方找一个总揽全局的家伙,这个家伙用于和各个小事务进行沟通,指导它们是提交还是回滚。这个家伙被称作事务协调器(Transaction Coordinator)或者资源管理器(Resource Manager)。

不论是事务协调器,还是资源管理器这样的名字念起来都比较拗口,有催眠功效,我们后续就把事务协调器或者资源管理器称作大哥了哈。

  • 管理一个小事务的家伙被称作事务管理器(Transaction Manager)。

事务管理器念起来也比较拗口,我们就把它称作小弟了哈。

要提交一个全局事务,那么属于该全局事务的若干个小事务就应该全部提交,只要有任何一个小事务无法提交,那么整个全局事务就应该全部回滚。所以此时大哥不能让各个小弟逐个提交,因为不能保证后面提交的小弟是否可能发生错误。此时XA规范中指出,要提交一个全局事务,必须分为2步:

  • Prepare阶段:当大哥准备提交一个全局事务时,会依次通知各个小弟说:“现在事务中的语句都已经执行完了,我们准备提交了,你这里有没有什么问题?”。如果小弟觉得自己没有问题,就把在事务执行过程中所产生的redo日志都刷新到硬盘,然后对大哥说:“没有问题”。如果小弟遇到了啥突发情况不能提交(比方说磁盘满了,不能写redo了),就对大哥说:“不行,提交不了了”。

  • Commit阶段:如果在Prepare阶段各个小弟大哥的答复都是:“OK,木有问题”,那大哥就要真正通知各个小弟去提交事务了。如果在Prepare阶段某个小弟大哥的回复是:“NO,做不了”,那大哥就得通知所有小弟:“遇到突发情况,所有人立即回滚”。小弟收到通知便都回滚了。不过在大哥通知各个小弟是要提交之前,都需在某个地方记录一下这个全局事务已经提交,以及各个小弟都是什么的信息。

XA规范把上述全局事务提交时所经历的两个阶段称作两阶段提交

小贴士:

如果一个全局事务仅包含一个小弟的话,那两阶段提交可以退化成1阶段提交。

大家可以看到,XA规范引入了一个在事务提交时的Prepare阶段,这个阶段就是让各个事务做好提交前的准备,具体就是把语句执行过程中产生的redo日志都刷盘。如果语句执行过程中的redo日志都刷盘了,那么即使之后系统崩溃,那么在重启的时候还是可以恢复到该事务各个语句都执行完的样子。

这样的话,在Prepare阶段结束后,即使某个小弟因为某些原因而崩溃,在之后重启恢复时,也可以把自己再次恢复成Prepare状态。在崩溃恢复结束后,大哥可以继续让小弟提交或者回滚。

以上就是XA规范的核心内容,下边可以来唠叨一下MySQL对上述XA规范的实现了。

MySQL中的XA事务

MySQL中的XA事务分为外部XA内部XA,我们分别来看一下。

外部XA

在MySQL的外部XA实现中,MySQL服务器充当小弟,而连接服务器的客户端程序充当大哥

与使用BEGIN语句开启,使用COMMIT提交的常规事务不同,如果我们想在MySQL中使用XA事务,需要一些特殊的语句:

  • XA {START|BEGIN} xid:该语句用于开启一个XA事务,此时该XA事务处于ACTIVE状态。

在一台MySQL服务器上,每个XA事务都必须有一个唯一的id,被称作xid。这个xid是由发起XA事务的应用程序(客户端)自己指定的,只要我们自己保证它唯一就好了。

这个xid其实是由gtridbqualformatID三个部分组成的:

  1. xid: gtrid [, bqual [, formatID ]]

其中gtrid(global transaction id)是指全局事务id,是一个字符串bqual是指分支限定符,formatID是指gtrid和bqual所使用的格式。

不过我们这里并不打算详纠啥是个分支,还限定符,以及啥格式之类的,我们可以在指定xid的时候省略bqualformatID的值,MySQL会使用默认的值(bqual默认是空字符串’’,formatID默认是1)。也就是说我们文章后续内容指定xid时仅指定gtrid就好了,也就是指定一个字符串即可。

  • XA END xid:在使用XA START xid开启了一个XA事务后,客户端就可以接着发送属于这个XA事务的各条语句,等所有语句都发送完毕后,就可以接着发送XA END xid来告知服务器由xid标识的XA事务的所有语句都输入完了。此时该XA事务处于IDLE状态。

  • XA PREPARE xid:对于处于IDLE状态的XA事务,应用程序就可以询问MySQL服务器是否准备好提交这个XA事务了,此时就可以给服务器发送XA PREPARE xid语句。当MySQL服务器收到此语句后,就需要做准备提交前的工作了,比如把该事务执行过程中所产生的redo日志刷新到磁盘等。此时XA事务处于PREPARE状态。

  • XA COMMIT xid [ONE PHASE]:对于处于PREPARE状态的XA事务,应用程序可以发送XA COMMIT xid来让MySQL服务器提交XA事务。如果此XA事务尚处于IDEL状态,那应用程序可以不发送XA PREPARE xid,而直接发送XA COMMIT xid ONE PHASE来让MySQL服务器直接提交事务即可。此XA事务处于COMMITE状态。

  • XA ROLLBACK xid:应用程序通过发送此语句来让MySQL服务器回滚xid所标识的事务。此时XA事务处于ABORT状态。

  • XA RECOVER:应用程序想看一下当前MySQL服务器上已经处于Prepare状态的XA事务有哪些,就可以发送该语句。

介绍了在MySQL中使用外部XA的一些语句,接下来可以画一个XA事务的状态转换图了:

48、专题式讲解 —— XA事务与两阶段提交 - 图1

哔哔了很多,赶紧来做个实验:

  1. mysql> XA START 'a'; //XA事务进入ACTIVE状态
  2. Query OK, 0 rows affected (0.00 sec)
  3. mysql> DELETE FROM x WHERE id = 1; //XA事务中包含的语句
  4. Query OK, 1 row affected (0.00 sec)
  5. mysql> XA END 'a'; //XA事务进入IDEL状态
  6. Query OK, 0 rows affected (0.00 sec)
  7. mysql> XA PREPARE 'a'; //XA事务进入PREPARE状态
  8. Query OK, 0 rows affected (0.01 sec)
  9. mysql> XA COMMIT 'a'; //XA事务进入COMMIT状态
  10. Query OK, 0 rows affected (0.01 sec)

MySQL的外部XA除了被用于跨行转账这种经典的分布式事务应用场景,还被广泛应用于所谓的数据库中间件

现在各个公司由于表中数据太多,这些数据会被分散在不通服务器中存储。由应用程序员分别和不同的MySQL服务器打交道实在费劲,所以有一种称作数据库中间件的东西开始问世。即应用程序只将SQL语句发送给数据库中间件,中间件分析一下该SQL访问的数据都在哪些不同的服务器中存储着,并且计算出不通服务器应该执行哪些SQL语句。然后就可以对不同的服务器分别开启XA事务,并且让把不同服务器需要执行的语句分别发送到不同的服务器中。等应用程序员告知中间件准备提交事务时,中间件先给各个服务器发送XA PREPARE语句,如果各个服务器都返回OK的话,接着就给各个服务器发送XA COMMIT语句来提交XA事务,等各个服务器把提交成功的消息返回给中间件,中间件就可以通知应用程序事务提交成功了。

内部XA

对于一台服务器来说,即使客户端使用BEGIN/START TRANSACTION语句开启的普通事务,该事务所包含的语句也有可能涉及多个存储引擎。此时MySQL内部采用XA规范来保证所有支持事务的存储引擎要么全部提交,要么全部回滚,这也被称作MySQL的内部XA

另外有一点值得注意的是,内部XA除了解决这种设计多个存储引擎的事务之外,还解决保证binlog和存储引擎所做的修改是一致的问题。我们稍后重点展开一下这个问题。

在MySQL内部执行一个事务时,存储引擎会修改相应的数据,server层会记录语句对应的binlog。这是两个要么都完成,要么都步完成的事情。否则的话:

  • 如果存储引擎修改了相应数据并且提交了事务,而server层却未写入binlog。在有主从复制的场景中,意味着这个事务不会在从库中得已执行,从而造成主从之间的数据不一致。

  • 如果server层写入了binlog,但存储引擎却回滚了事务。在有主从复制的场景中,意味着这个事务会在从库中得已执行,从而造成主从之间的数据不一致。

那我们需要保证:如果存储引擎提交了事务,server层的binlog日志必须也被写入到硬盘上;如果存储引擎回滚了事务,server层的binlog日志必须不能被写入到硬盘上

MySQL采用内部XA来实现上述内容,下边以Innodb存储引擎为例,具体讨论一下Innodb事务的提交和binlog日志写入的过程。

有binlog参与的内部XA事务

小贴士:

后续会用到很多undo日志结构的内容,这些内容都在《MySQL是怎样运行的:从根儿上理解MySQL》书籍中有详细叙述,如果不了解的话,看起来可能会有点儿困难,建议先将undo日志章节内容看熟。

当客户端执行COMMIT语句或者在自动提交的情况下,MySQL内部开启一个XA事务,分两阶段来完成XA事务的提交:

  • Prepare阶段:存储引擎将该事务执行过程中产生的redo日志刷盘,并且将本事务的状态设置为PREPARE。binlog啥也不干。下边看一下具体的代码。

binlog_prepare是在PREPARE阶段对binlog所做的一些操作:

48、专题式讲解 —— XA事务与两阶段提交 - 图2

binlog_prepare函数基本啥也没干,我们就不展开说了。

innobase_xa_prepare是InnoDB存储引擎实现的XA规范的prepare接口:

48、专题式讲解 —— XA事务与两阶段提交 - 图3

这个函数做了很多事情,我们得好好唠叨一下。

首先我们知道事务执行过程中需要写undo日志,这些undo日志被写到若干个页面中,这些页面也被称作Undo页面,这些页面会串成一个链表,称作Undo页面链表。在一个事务对应的Undo页面链表的首个页面中,记录了一些关于这个事务的一些属性,我们贴个图看一下:

48、专题式讲解 —— XA事务与两阶段提交 - 图4

我们先看一下其中的Undo Log Segment Header部分:

48、专题式讲解 —— XA事务与两阶段提交 - 图5

其中的TRX_UNDO_STATE字段就表明该事务目前处于什么状态。当处于Prepare阶段时,调用innobase_xa_prepare函数会将TRX_UNDO_STATE字段的值设置为TRX_UNDO_PREPARED(整数5),表明当前事务处在Prepare阶段。

我们再看一下Undo Log Header部分:

48、专题式讲解 —— XA事务与两阶段提交 - 图6

这个部分体现着这个Undo页面链表所属的事务的各种信息,包括事务id。其中两个属性和我们今天主题特别搭:

  • TRX_UNDO_XID_EXISTS:表示有没有xid信息。
  • XID信息:表示具体的xid是什么。

当处于Prepare阶段时,调用innobase_xa_prepare函数会将TRX_UNDO_XID_EXISTS设置为TRUE,并将本次内部XA事务的xid(这个xid是MySQL自己生成的)写入XID信息处。

小贴士:

再一次强调,修改Undo页面也是在修改页面,事务凡是修改页面就需要先记录相应的redo日志。

记录了关于该事务的各种属性之后,接下来该将到现在为止所产生的所有redo日志进行刷盘,刷盘的函数如下所示:

48、专题式讲解 —— XA事务与两阶段提交 - 图7

在将redo日志刷盘之后,即使之后系统崩溃,在重启恢复的时候也可以将处于Prepare状态的事务完全恢复。

小贴士:

在MySQL 5.7中,有一个称之为组提交(group commit)的优化。即设计InnoDB的大叔觉得各个事务分别刷自己的redo日志和binlog效率太低,他们把并发执行的多个事务所产生的redo日志和binlog在后续的Commit阶段集中起来统一刷新,这样可能提升效率,所以在MySQL 5.7以及之后的版本中,上述在Prepare阶段刷新redo日志的操作会被推迟到Commit阶段才真正执行。关于组提交的优化措施我们并不想过多展开,大家忽略这个优化就好了,这里就认为在Prepare阶段事务就已经将执行过程中产生的redo日志刷盘就OK。

  • Commit阶段:先将事务执行过程中产生的binlog刷新到硬盘,再执行存储引擎的提交工作。

将binlog刷盘的函数如下:

48、专题式讲解 —— XA事务与两阶段提交 - 图8

InnoDB存储引擎提交事务使用innobase_commit函数完成存储引擎层面的事务提交:

48、专题式讲解 —— XA事务与两阶段提交 - 图9

innobase_commit函数做了很多事情,我们挑一些重要的来说。

首先是更新Undo页面链表的状态,将我们上边说的Undo Log Segment Header部分的STATE字段更新一下。更新规则如下:

48、专题式讲解 —— XA事务与两阶段提交 - 图10

也就是说如果当前事务产生的undo日志比较少,那么就继续让别的事务复用该Undo页面链表,将STATE设置为TRX_UNDO_CACHED;如果Undo页面链表用于存储INSERT操作产生的undo日志,那么就将STATE设置为TRX_UNDO_TO_FREE,稍后会释放Undo页面链表占用的页面;如果Undo页面链表用于存储其他操作产生的undo日志,那么就将STATE设置为TRX_UNDO_TO_PURGE,等待purge线程后台回收该Undo页面链表

小贴士:

UPDATE、DELETE操作产生的undo日志可能会用于其他事务的MVCC操作,所以不能立即删除。

对于存储UPDATE、DELETE操作产生的undo日志的Undo页面链表,还要将其加入所谓的History链表,关于这个History链表是啥,我们这里就不展开了。

每个Undo页面链表的首个页面的页号会被存储在表空间的某个地方,以便崩溃恢复的时候可以根据该页来进行恢复。如果此时在事务提交时,Undo页面链表的状态被设置为TRX_UNDO_CACHED,那存储Undo页面链表的首个页面的页号的地方也就不需要做改动;如果此时在事务提交时,Undo页面链表的状态被设置为TRX_UNDO_CACHED,那存储Undo页面链表的首个页面的页号的地方就得被设置为空,这样这个地方就可以被其他事务使用了。

至此,这个事务就算是提交完了。

崩溃恢复

每当系统重启时,都会先进入恢复过程。

此时首先按照已经刷新到磁盘的redo日志修改页面,把系统恢复到崩溃前的状态。

然后在表空间中找一下各个Undo页面链表的首个页面的页号,然后就可以读取该页面的各种信息。我们再把这个页面的内容给大家看一下:

48、专题式讲解 —— XA事务与两阶段提交 - 图11

通过这个页面,我们可以知道该Undo页面链表对应的事务状态是什么:

  • 如果是TRX_UNDO_ACTIVE状态,也就是活跃状态,直接按照undo日志里记载的内容将其回滚就好了。

  • 如果是TRX_UNDO_PREPARE状态,那么是提交还是回滚就取决于binlog的状态了,我们稍后再说。

  • 如果是其他状态,就将该事务提交即可。

对于处于PREPARE状态的事务,存储引擎既可以提交,也可以回滚,这取决于目前该事务对应的binlog是否已经写入硬盘。这时就会读取最后一个binlog日志文件,从日志文件中找一下有没有该PREPARE事务对应的xid记录,如果有的话,就将该事务提交,否则就回滚好了。

最后

这一篇文章有点儿长,不点赞/在看/分享,真的好么~