MySQL 外部XA及其在分布式事务中的应用分析

4月 18th, 2012
  1. XA原理

关于XA,分布式事务处理的原理,可见[3];关于MySQL XA的说明,可见[1][2]。

 

MySQL XA分为两类,内部XA与外部XA;内部XA用于同一实例下跨多个引擎的事务,由大家熟悉的Binlog作为协调者;外部XA用于跨多MySQL实例的分布式事务,需要应用层介入作为协调者(崩溃时的悬挂事务,全局提交还是回滚,需要由应用层决定,对应用层的实现要求较高)

 

本文,假设读者已经知道MySQL外部XA的使用,而将重点放在MySQL如何处理外部XA的crash recover,以及面对不同的crash recover的情形,应用程序如何处理,才能够保证分布式事务的一致性。最后,本文简单分析一下目前MySQL外部XA支持存在的问题,以及可选的解决方案。

 

源代码分析基于MySQL 5.1.49,MySQL 5.5.16。

  1. MySQL处理流程

    1. MySQL 外部XA –正常处理流程

MySQL外部XA的正常处理流程,这里不准备介绍,可以参考[1][2][3]。接下来我重点描述一下MySQL外部XA的崩溃恢复流程,毕竟此流程跟应用程序如何正确使用外部XA息息相关。

  1. MySQL外部XA –崩溃恢复流程

若一个运行外部XA事务的MySQL节点发生崩溃,那么其重启之后的崩溃恢复,涉及到外部XA处理的流程如下:

Crash recover:

 

// 1.    读取binlog文件,将文件中的xid存入commit_list hash表

//     顾名思义,所谓的commit_list,就是说此list中对应prepare状态的xid

//    在崩溃恢复过程中均可以被提交,而不在commit_list中的xid,均须回滚

//     binlog中的xid,都是属于内部xid,由MySQL产生,用于内部XA

Log.cc::TC_LOG_BINLOG::recover

// 2.    遍历底层所有的事务引擎,收集处于XA_PREPARED状态的所有xid

//    这些xid列表,既包括内部xid,也包括外部xid,存储引擎内部不做区分

Handler.cc::ha_recover(commit_list)

// 执行各引擎层面提供的recover方法,收集所有的处于prepared状态的xid

// 根据xid分类:

// 3.    若xid属于内部xid,那么在commit_list中查找此xid,

//    若存在,则提交此xid对应的事务;否则,回滚此事务

// 4.    若xid属于外部xid,那么则将xid插入xid_cache hash表

//    xid_cache中的所有xid,将会通过xa recover命令返回,等待外部程序决策

Handler.cc::xarecover_handlerton

    // 5.    收集InnoDB引擎中,处于prepare状态的所有xid,并返回

    got = hton->recover(innobase_xa_recover)

    my_xid x = info->list[i].get_my_xid();

    if (!x)

        // 若当前为外部xid,那么将xid插入xid_cache hash表

xid_cache_insert(&xid_cache, x);

    else

        if (x in commit_list)

            // 若当前为内部xid,同时此xid在binlog中存在,则提交

            hton->commit_by_xid();

        else

            // 若当前为内部xid,同时此xid在binlog中不存在,则回滚

            hton->rollback_by_xid();

 

通过以上的分析,可以总结出:

  • MySQL内部,会对xid做区分。内部xid有MySQL自己产生(MySQL内部xid格式,将在本文下面给出),用于多引擎间事务的一致性;外部xid由应用程序给出,用于跨多MySQL实例的分布式事务。但是存储引擎层不做区分(区分在MySQL上层)。

     

  • crash recover时,存储引擎负责将引擎内部,处于prepare状态的事务收集,并返回MySQL上层。

     

  • Binlog作为内部XA的协调者[5],在binlog中出现的内部xid,在crash recover时,由binlog负责提交;在binlog中未出现的xid,由binlog负责回滚。(这是因为,binlog不进行prepare,只进行commit,因此在binlog中出现的内部xid,一定能够保证其在底层各存储引擎中已经完成prepare)。

     

  • 外部XA事务的xid,在crash recover过程中仅仅是插入xid_cache中,而不做其他处理。等到用户发起xa recover命令时,将xid_cache中处于prepare状态的xid返回。

     

  • xa recover命令的流程处理如下。

 

xa recover命令处理流程:

 

sql_parse.cc::mysql_execute_command

    case SQLCOM_XA_RECOVER:

        mysql_xa_recover();

            // 遍历xid_cache,找出其中的状态处于XA_PREPARED的事务,发送客户端

            while (xs = hash_element(&xid_cache,))

                if (xs->xa_state == XA_PREPARED)

                    protocol->write();

 

根据xa recover命令收集到的各MySQL实例返回的xid列表,然后再对比应用程序端日志,决定这些xid,哪些全局commit,哪些rollback。

 

由于测试中只有一个MySQL实例,因此此时可以直接选择commit处于prepare状态的xid。

 

  1. MySQL内部xid格式

上面提到,MySQL有外部XA与内部XA,内部XA对应的xid由MySQL内部产生,有特定的格式:

  • MySQL内部xid格式    MYSQL_XID_PREFIX + server_id + my_xid

    MYSQL_XID_PREFIX    MySQLXid(源码写死)        8 bytes

    server_id                MySQL实例的idulong        4 bytes

    my_xid                内部自增序列,ulonglong    8 bytes

    MySQL内部xid由以上3部分组成,总长度为20

    判断是否为内部xid的代码如下:

    gtrid_length == MYSQL_XID_GTRID_LEN

    &&bqual_length == 0

    &&!memcmp(data, MYSQL_XID_PREFIX, MYSQL_XID_PREFIX_LEN)

    其中:MYSQL_XID_GTRID_LEN = 20;MYSQL_XID_PREFIX_LEN = 8;

    例如:MySQLXid 0004

    server_id = ”my_xid = 4

    因此,使用时应该注意,不要在外部构造这种形式的xid,否则MySQL就会将内部xid与外部xid混淆。

    在测试中,我构造了一个外部xid = ‘MySQLXidxxxx00100000’,长度为20 bytes,前八个字符为‘MySQLXid’。在事务完成xa prepare之后,关闭MySQL数据库。MySQLcrash recover时,直接将此xid认为是内部xid,并在内部由Binlog直接rollback此事务,导致使用xa recover命令无法看到任何prepare状态的xa事务。

    但是,反过来考虑,若是应用程序本身不想处理悬挂事务,那么将外部xid构造成内部的形式不失为一种较好的策略,由binlog来负责处理悬挂事务的提交与回滚。付出的代价则是:崩溃时,未提交事务在各个MySQL实例上的状态可能不一致(部分节点提交;部分节点回滚)

 

  1. MySQL 崩溃恢复& Binlog

前面提到了MySQL外部XA的崩溃恢复流程。在本小节我们简单分析一下崩溃恢复过程中的Binlog文件的读取问题。

 

通过跟踪TC_LOG_BINLOG::open函数,发现在crash recover过程中,MySQL全量读取最后一个Binlog文件,这与MariaDB WorkLog#164Extend crash recovery to recover non-prepared transactions from binlog[6]中的说法一致:...The existing scan always scans the full last binlog file, and we should keep this...

 

但是这样就带来一个疑问:

为什么仅仅全量读取最后一个Binlog文件就可以呢?如果最后一个binlog文件很短,如何保证底层引擎处于prepare状态的事务不会出现在前一个Binlog文件之中?

 

回答这个疑问,需要从目前MySQL写Binlog与底层存储引擎(InnoDB)写redo log的方式分析:

  1. 同一事务只能写到同一个Binlog文件中,不能跨文件。
  2. 为了保证底层引擎Commit顺序与Binlog顺序一致,目前MySQL+InnoDB不支持group commit(新版的PreconaMariaDB除外),同一时间只有一个事务可以进行提交(内部的XA事务,二阶段提交)InnoDB prepare + Binlog flush + InnoDB commit这一系列操作。因此下一个事务开始进行InnoDB prepare时,前一个事务的系列动作一定结束,事务已经提交。意味着crash recovery时,最多只有一个InnoDB事务处于prepare状态。
  3. 结合12可得,最后一个prepare事务一定位于最后的Binlog文件中。

 

上面说到,由于MySQL+InnoDB不支持group commit,因此只读最后一个Binlog是可行的,那么如果是最新版的Percona/MariaDB,已经支持group commit (关于group commit的具体实现,可以参考我的另外一篇短文:MariaDB&PerconaXtraDB Group Commit实现简要分析[7]),那么仍旧读取最后一个Binlog文件是否一样可行呢?

 

答案是肯定的,因为目前Percona/MariaDB的最新版本实现中,仍旧采用的是全量读取最后一个Binlog文件的策略,那么此时又是如何保证前一个Binlog文件中所有的日志对应的事务,其在底层InnoDB引擎中已经完成提交动作了呢?

经过阅读MariaDB 5.3.4的代码,我找到了答案:

  1. 同一事务只能写在同一Binlog文件中,不能跨文件,这个要求仍旧保留。
  2. Binlog在进行group commit时,需要统计参与本次group commit的所有内部XA事务的数量(prepared_xids,何用?)。
  3. 若当前Binlog文件已经超出指定的大小,需要切换,那么在切换之前,必须等待当前Binlog文件对应的prepared_xids归零(换句话说,也就是要保证当前Binlog文件中的所有内部XA事务,在存储引擎中全部提交,完成commit & fsync)。如此一来,就能够保证切换到新的Binlog文件之后,老的Binlog文件对应的所以事务,都已经确定提交。
  4. prepared_xids归零前提?要让prepared_xids归零,首先必须将新的Binlog group commit暂停,通过对LOCK_log mutex加锁即可实现(LOCK_log mutex功能可见[7],新的binlog group commit开始前,必须获得此mutex)。
  5. prepared_xids归零操作?Binlog模块(TC_LOG_BINLOG)提供一个unlog方法,该方法每调用一次,对prepared_xids –,直到prepared_xids归零,即可进行binlog文件的切换操作。每个事务,在完成所有的commit步骤(包括底层的存储引擎commit),返回用户之前,调用此方法;若binlog group commit中有事务失败,同样调用此方法。因此,只要binlog中的事务对应的底层引擎全部完成commit,prepared_xids一定为0,也意味着可以切换Binlog文件。
  6. 总结:group commit下的crash recovery,同样只需要遍历最后一个Binlog文件即可。MariaDB在实现group commit的过程中,已经改动binlog的实现,用于支持此方法。

 

同样还是在MariaDB WL#164[6]中,提到了遍历binlog的一种优化,目前,InnoDB redo log在commit日志中已经记录了对应的Binlog日志的(文件名,位置)信息。只要将此信息返回,就可以从指定位置开始遍历Binlog,如此一来,使用更大的Binlog文件,也不会影响crash recovery时,读取Binlog文件的性能。

  1. MySQL 外部XA分析

    1. 作用分析

MySQL外部XA可以用在分布式数据库代理层,例如开源的代理工具:ameoba[4],网易的DDB,淘宝的TDDL,B2B的Cobar等等。

 

通过MySQL外部XA,这些工具可以提供跨库的分布式事务。当然,这些工具也就成了外部XA事务的协调者角色。在crash recover时控制悬挂事务是全局commit,或者rollback。

 

在crash recover之后,外部应用程序可能会遇到以下几种情况:

 

  • 情况一:分布式事务对应的MySQL实例,部分完成prepare,部分未完成prepare。此时直接回滚完成prepare的实例即可。n_prepared <Total Nodes (处于prepare状态的节点数量要小于参与分布式事务的所有节点总数)

     

  • 情况二:分布式事务对应的MySQL实例,全部完成prepare,未开始进行commit。此时即可提交此事务,也可回滚此事务(根据分布式事务原理,所有节点都完成prepare,应该提交)。n_prepared = Total Nodes

     

  • 情况三:分布式事务对应的MySQL实例,全部完成prepare,并且部分节点已经完成commit。此时应该提交该事务处于prepare状态的节点。n_prepared < Total Nodes。对比情况三与情况一,仅仅通过prepare节点的数量无法区分,因此应用程序需要在prepare完成之后记录日志(此时,应用程序起着事务协调者(Transcaction Coordinator)的角色,而根据MariaDB WorkLog#132[5]的说法,TC角色是可以进行”middle engine”优化的,不需要prepare过程,所有MySQL节点xa prepare返回之后,应用程序直接写commit标识即可,然后再对每个MySQL节点进行xa commit操作。),从而用于区分情况一与情况三。

     

  • 情况四:分布式事务对应的MySQL实例,全部完成commit。此时事务已经提交成功,xid不会出现在执行xa recover的任一个节点。不需要特殊处理。

  • 情况五:未记录任何prepare日志。那么所有的事务,在各个存储引擎的crash recover时,都会被回滚,不需要外部特殊处理。
  1. MySQL外部XA不足

通过前面的分析,可知应用程序配合MySQL的XA事务功能,能够较好的支持分布式环境下的事务。但是,这个支持并不完美,根据我的分析,有可能会出现以下几个问题:

 

  • 问题一:主备库数据不一致。

    MySQL的主备库的同步,通过Binlog的复制完成。而Binlog是MySQL内部XA事务的协调者,并且MySQL为binlog做了优化——binlog不写prepare日志,只写commit日志。

     

    考虑前面提到的情况二,所有的参与节点prepare完成,在进行xa commit前crash。crash recover如果选择commit此事务。由于binlog在prepare阶段未写,因此主库中看来,此分布式事务最终提交了,但是此事务的操作并未写到binlog中,因此也就未能成功复制到备库,从而导致主备库数据不一致的情况出现。

     

    在MySQL 5.5.16版本中做过测试,这个问题实际存在。crash recover之后,对xa recover返回的事务运行xa commit,对应事务提交,但是操作并未写入binlog,因此无法复制到备库。

     

    那么是否回滚所有prepare的事务,就可以避免此问题呢?结论是仍旧不行,不仅不能解决问题一,甚至可能引起问题二。

 

  • 问题二:同一事务,在各参与节点,最终状态不一致(部分提交,部分回滚)。

    若回滚所有prepare状态的分布式事务,会产生问题二。考虑情况三(所有节点完成prepare,部分节点完成commit),该分布式事务对应的节点,部分已经提交,无法回滚,而部分节点回滚。最终导致同一分布式事务,在各参与节点,最终状态不一致。

 

  • 问题三:源码级别问题。MySQL 5.1.49源码对于外部XA事务处理存在bug,在MySQL 5.5.16版本中,此bug已经被fix。经过验证发现,在我已下载的MySQL 5.1.61与之后的所有版本,此bug均已经被fix

    MySQL 5.1.49中,所有xa recover返回的外部xid,都不能被提交。原因如下:

    当运行xa commit ‘xid_name’命令时,MySQL会判断当前xid_name的错误信息,若存在错误信息,那么就在内部将xa commit命令强制转换为xa rollbackxid_name的状态存于xid_cache中,在crash recover阶段,由函数Handler.cc::xarecover_handlerton调用xid_cache_insert(&xid_cache, x)函数完成插入。MySQL 5.1.49在实现xid_cache_insert函数有bug

                …

    xs->xa_state=xa_state;

            xs->xid.set(xid);

            xs->in_thd=0;

            xs->rm_error=0;

                res=my_hash_insert(&xid_cache, (uchar*)xs);

                    …

    MySQL 5.1.49中,缺少了xs->rm_error =0这一行,未初始化rm_error,导致xa commit时判断出错,无法commitMySQL 5.5.16已经fixbug,加上了黑色这一行的初始化,应用程序可以xa commit

  1. 不足的解决方案

从MySQL外部XA不足的分析可以看出,除了实现bug之外,产生其余两个问题的最大原因,还是在于MySQL针对binlog做的”middle engine”优化,binlog的prepare不写日志。在MySQL内部XA事务中,这个优化是可行的,因为Binlog本身的角色就是事务协调者(Transaction Coordinator),事务协调者可以不进行prepare [5]。

但是对于MySQL外部XA事务,Binlog已经不是事务协调者的角色,其也是一个参与者,或者说是Resource Manager。因此Binlog的prepare日志是不可省略的。

为了解决MySQL外部XA事务crash recover过程中出现的问题,我觉得只能修改binlog模块。使binlog模块在正常运行过程中也区分内部XA事务与外部XA事务。内部XA事务可以仍旧沿用现在的方案;而外部XA事务,需要增加写prepare日志的功能,已经crash recover时处理prepare日志的功能。

  1. 参考资料

[1] Sergei Golubchik.Distributed Transaction Processing with MySQL XA

[2] http://dev.mysql.com/doc/refman/5.1/en/xa.html

[3] X/Open.Distributed TP: The XA Specification

[4] 陈思儒. Amoeba

[5] MariaDB WorkLog#132: Transaction coordinator plugin

[6] MariaDB WorkLog#164: Extend crash recovery to recover non-prepared transactions from binlog

[7] 何登成. MariaDB&PerconaXtraDB Group Commit实现简要分析

  1. 阿道
    11月 22nd, 201520:17

    嗨,登博

    有一个问题,没太明白,mysql 外部xa事务不足,引用如下:

    “考虑前面提到的情况二,所有的参与节点prepare完成,在进行xa commit前crash。crash recover如果选择commit此事务。由于binlog在prepare阶段未写,因此主库中看来,此分布式事务最终提交了,但是此事务的操作并未写到binlog中,因此也就未能成功复制到备库,从而导致主备库数据不一致的情况出现。”

    主库看来此分布式事务最终commit,commit的过程不会再写binlog吗(前面有说p
    repred阶段不会写binlog,但是commit阶段会写,如果主库上此事务最终选择commit,应该还会写binlog吧)?
    疑问:为什么此种情况主库最终选择提交的时候,不会写binlog