MySQL 分布式事务的“路”与“坑”

685 阅读19分钟

1 数据库事务

1.1 普通本地事务

分布式事务也是事务,事务的 ACID 基本特性依旧必须符合:

A:Atomic,原子性,事务内所有 SQL 作为原子工作单元执行,要么全部成功,要么全部失败;

C:Consistent,一致性,事务完成后,所有数据的状态都是一致的。如事务内A给B转100,只要A减去了100,B账户则必定加上了100;

I:Isolation,隔离性,如果有多个事务并发执行,每个事务作出的修改必须与其他事务隔离;

D:Duration,持久性,即事务完成后,对数据库数据的修改被持久化存储。

普通的非分布式事务,在一个进程内部,基于锁依赖于快照读和当前读,比较好实现 ACID 来保证事务的可靠性。但分布式事务参与方通常在不同机器的不同实例上,原来的局部事务的锁不能保证分布式事务的ACID特性,需要引入新的事务框架,MySQL的分布式事务是基于2PC(二阶段提交)实现,下面详细介绍下2pc分布式事务。

1.2 基于2pc的分布式事务

分布式事务有多种实现方式,如2PC(二阶段提交)、3PC(三阶段提交)、TCC(补偿事务)等,MySQL是基于 2PC 实现的分布式事务,下面介绍 2PC 分布式事务实现方式。

两阶段提交:Two-Phase Commit , 简称2PC,为了使基于分布式系统架构下的所有节点在进行事务提交时保持一致性而设计的一种算法。 

2PC的算法思路可以概括为,参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报,决定各参与者是否要提交操作还是中止操作。这里的参与者可以理解为 Resource Manager (RM),协调者可以理解为 Transaction Manager(TM)。

下图说明了RM和TM在分布式事务中的运作过程:

第一阶段提交:TM 会发送 Prepare 到所有RM询问是否可以提交操作,RM 接收到请求,实现自身事务提交前的准备工作并返回结果。 

第二阶段提交:根据RM返回的结果,所有RM都返回可以提交,则 TM 给 RM 发送 commit 的命令,每个 RM 实现自己的提交,同时释放锁和资源,然后 RM 反馈提交成功,TM 完成整个分布式事务;如果任何一个 RM 返回不能提交,则涉及分布式事务的所有 RM 都需要回滚。

2 MySQL 分布式事务XA

MySQL分布式事务XA是基于上面的2pc框架实现,下面详细介绍MySQL XA相关内容。

2.1 XA事务标准

X/Open 这个组织定义的一套分布式XA事务的标准,定义了规范和API接口,然后由厂商进行具体的实现。

XA规范中分布式事务由AP,RM,TM组成:

如上图,应用程序AP定义事务边界(定义事务开始和结束),并访问事务边界内的资源。资源管理器RM管理共享的资源,也就是数据库实例。事务管理器TM负责管理全局事务,分配事务唯一标识,监控事务的执行进度,并负责事务的提交、回滚、失败恢复等。MySQL实现了XA标准语法,提供了上面的RMs能力,可以让上层应用基于它快速支持分布式事务。

2.2 MySQL XA语法

XA START xid:开启一个分布式事务xid。

XA END xid: 将分布式事务xid置于 IDLE 状态,表示事务内的SQL操作完成。

XA PREPARE xid: 事务xid本地提交,成功状态置于 PREPARED 失败则回滚。

XA COMMIT xid:  事务最终提交,完成持久化。

XA ROLLBACK xid: 事务回滚终止。

XA RECOVER: 查看 MySQL 中存在的 PREPARED 状态的 XA 事务。

(1)语法要点

参与分布式事务的实例之间,在数据库内核视角没有直接关联,互相不感知状态,且一个分布式事务中各个节点上的子事务均可单独执行无依赖,他们之间的关联是通过全局事务号在应用层建立的。

与普通事务比,XA事务开启时多了一个全局事务号,结束时多了一个end动作 和 prepare动作。

XA START, 开启一个分布式事务,需要指定分布式事务号。

XA END ,在内部仅是一个状态变化,声明当前XA事务结束,不允许追加新的sql语句,无其它作用,业界有人提出XA事务框架去掉这一步,减少一次网络交互,提高性能。

XA PREPARE,写 binlog 和 redo log,预提交事务,并将分布式事务信息保存到全局内存结构,让其它连接可以查询、回滚、提交,如果 prepare 失败则回滚。

XA COMMIT,真正提交事务,修改事务状态,释放锁资源。如果实例上 XA PREPARE 已经成功,那么它的 XA COMMIT 一定能成功。

XA事务示例:201用户给202用户转账1000元,简化如下:

第1步,开启一个分布式事务,xa_ts:10001是应用层定义的全局事务号,实例1和实例2通过它来构建分布式事务。

第2、3步是普通事务语句。

第4步,声名xa事务结束,在此之后不能再追加更新插入查询等语句,不属于这个分布式事务也不允许,其它语句放在xa commit或xa rollback之后。

第5步,prepare 成功后,上层应用可以发起第6步提交事务。注意,必须是所有参与这个分布式事务的全部节点均 prepare 成功,即实例1和实例2都完成prepare,应用端才能发起提交,两阶段提交的框架核心点就在此。

如果有节点在前5步不能成功,所有参与分布式事务的节点都必须回滚。如实例2是账户加1000元,基本上什么情况都能成功,肯定能成功执行第5步,但实例1就未必了,账户要扣1000元,可能资金不够,会出错回滚,若实例1不能执行到prepare,所有分布式事务参与者也必须回滚,所以实例2也要回滚。如果第5步全部成功,有一个节点执行了第6步提交了事务,那么所有节点必须要均提交,否则就会导致数据不一致。处于xa prepare不提交会占用资源,残留xa事务等价于存在长事务,对刷脏和purge等都有影响,业务层最好要立即提交。

(2)残留XA事务如何处理

上面说到xa事务不提交等价于长事务,一旦prepare成功要立即提交,否则会带来很多问题。但是数据库crash或应用系统出错crash等原因都可能导致xa事务未能全部提交,这些残存XA事务如何处理?这就要用到上面的 XA RECOVER语法了,执行xa recover 查看未提交XA事务,选择对应的进行rollback或commit。如果仅 gtrid_length字段有值一般可以直接 xa rollback/commit  xid方式回滚或提交,xid就是xa recover中data。

如果gtrid_length和bqual_length 都有值,回滚或提交则相对复杂一些,需要以下面方式提交或回滚:

gtrid 和 bqual被拼接在 data字段中,需要按他们长度切分,以下面未提交xa事务里第一个为例,gtrid_length 为34,表示data中前34个字符为gtrid, bqual_length 为22,表示data中后22个字符为bqual,那么对对其回滚或提交方式可表示如下:

如果data中有其它特殊字符,也可以转成16进制整数方式处理,执行语句如下:

因为是16进制数,字符做了转换,data中字符数会翻倍,回滚或提交内容要同步调整,将data中字符也要翻倍再拆分,如上grtrid长度34,则data中前34*2个16进制数字是gtrid,bqual长度22,则后44个16进制数字是bqual,回滚或提交语法如下:

注意:上面的提交或回滚都可能报xid不存在,这不一定是xid写错了,也可能是开启这个XA事务的连接并未断开,其它连接不能处理这个XA事务,这里是MySQL报错不准确。

(3)提交还是回滚的依据

上面给出如何进行提交或回滚的方法,但是提交or回滚应该选择哪个?

残留XA事务是提交还是回滚,必须要由业务决定,谁开启XA事务,构建了分布事务管理器TM,谁就必须为这个事务负责到底。

单个数据库视角无法判断出这个XA事务是应该提交还是应该回滚,不管选哪种都可能会导致全局数据出错,运维同学在处理时一定要与业务方确定好该事务是提交还是回滚,获得授权后再操作。以上面转账为例,201用户给202转1000元,都prepare成功,发起commit,此时202用户实例发生故障重启,未完成commit,重启之后有残留XA事务,此时若201提交成功,那么202必须提交,如果201未成功,202可以先201一起提交或一起回滚,由应用层事务管理器TM来决定。假如201提交成功,202回滚则201扣了1000,202未收到,对账则钱少了。如201回滚了,202提交,则202加了1000,201未扣,对账则钱多了。

2.3 MySQL XA事务设计上的“坑”

(1)设计上的缺陷

基于binlog的主从复制是MySQL高可用的基石,这也是MySQL能广泛流行使用的最重要因素。在MySQL内部,对于普通事务(非XA事务),innodb等引擎和binlog为了保持数据的一致性,就是用的 2PC ,为了区分于XA事务的2PC ,称之为内部两阶段提交。内部2pc使用binlog是作为协调者(TM),内部prepare时先写redo再写binlog,都持久化(受刷盘参数策略影响)后再提交。当发生Crash重启时,会先恢复出所有prepare成功的事务,把里面的xid事务号取出来,再到协调者Binlog中去找,如果binlog中有这个xid则说明innodb和binlog都执行成功,等价于外部xa 事务两个参与节点都prepare成功,则继续提交,如果binlog中找不到,刚说明只在引擎层完成,需要回滚,如果某个进行的事务xid在prepare中未找到,则说明prepare未完成,直接回滚,这个顺序一定是先写Redo log,最后写Binlog。

那么处于XA prepare 状态的分布式事务到底是一个什么样的状态?分布式XA事务也是基于普通事务实现,实际上就是一个支持挂起,支持让其它会话继续提交或回滚,支持crash或重启之后还能恢复这种挂起状态的普通事务。

普通事务的prepare动作是发生在显式commit之后,先写redo后再写binlog。XA事务的prepare发生在显式XA commit之前,它需要生成binlog,然后再写redo,这与普通事务是相反的,这就导致这个外部2pc事务的内部2pc提交缺少了一个协调者,某些情况下会导致数据库不一致。

一个XA事务的binlog由两部分组成,从xa start到xa prepare是一个不可分原子语句块,xa commit又是一个原子语句块,且分别有各自的gtid,如下图binlog:

 事务号为  X'7831',X'',1 的分布式事务prepare之后,中间插入了很多普通事务,然后再执行的xa commit。

一个XA事务的binlog被切分成了两个独立的部分,如果在主节点在生成XA prepare binlog之后发生crash, 还没有在引擎层做prepare,重启之后引擎层中因没有完成prepare动作而回滚。但在主从架构中,只要binlog正常产生就可能会同步到Slave机,这种情况下会导致slave机上多了这个xa prepare的中间状事务,最终复制出现问题。这个问题已经被发现多年,官方确认了bug,一直未修复(bugs.mysql.com/bug.php?id=…

(2)遇到该问题处理思路

虽然我们要尽量避免出现故障,但也做好面对任何故障的准备,谋而后动,有招不乱!\

在常规连接中,MySQL的XA事务执行prepare之后,通常不能执行其它非xa语句,会报错提醒当前正在xa事务中。但在复制的sql 回放线程中,执行完xa prepare之后,可以直接执行其它非此xa事务的sql,因为在master端生成的XA事务Binlog可能就是分开的,如上图例子就是。所以slave机sql线程执行完xa prepare的binlog后,是被允许接着正常执行其它事务的binlog的。如果xa preapre过程master上发生crash,刚好生成了binlog,但没有做完后续的prepare动作,备机收到了这个xa preare动作的binlog,master重启后会回滚掉这个事务,不会再生成这个xa事务后续binlog,这会导致备机执行完xa prepare后一直挂起,占用的锁等资源不会释放,直到新同步过来的binlog与之冲突报错,才会暴露问题。

要修复分两种情况处理:

情况1:基于gtid的复制,应该直接会报gtid重复错误(推测,本地没能复现)。master上重启应该会回滚掉了前半个XA事务,后面事务会重新生成这个相同gtid的事务,导致复制出错,此时停止复制,将备机上这半个XA事务回滚,并reset gtid到之前的gtid,重建复制即可。注意这里可能有多个XA事务在Binlog中处于prepare状态,需要解析binlog仔细确定要回滚的事务是哪个。

情况2:未开gtid的复制,此时比上面情况要麻烦,没有gtid来确定binlog事务是否重复,只要后面事务不涉及到这半个xa事务锁定的资源,备机就可以正常维持复制体系,一直同步数据,等到有冲突数据出现错误,回放线程重试超过一定次数后(slave_transaction_retries重试参数控制),sql线程报出相应错误,复制中断后才能被感知。恢复数据和上面差不多,回滚这个XA事务,重建主从,但是这个事务的binlog不一定能找到,因为没有gtid不会立即报错,可能几分钟后报错,也可能几个月后报错,取决于业务什么时候产生冲突数据。并且在这个事务之后,从机又同步了很多数据,这些数据是否可靠需要评估。线上强烈建议开启Gtid复制模式,非gtid的复制官方已经在淘汰!

3 分布式事务的一致性

使用到分布式事务,就必须要保证分布式事务的一致性。

分布式事务的一致性又分写一致性和读一致性,写一致性XA框架XA prepare 和XA commit已经解决,只要保证有提交全提交,有回滚全回滚就能保证写一致性。

读一致性则要复杂的多,先看看MySQL官方对XA事务在读一致性上的“只言片语”:

\

上面内容是从官方说明文档里截取,里面对XA读一致性略有介绍:如果应用程序对读敏感,首选SERIALIZABLE隔离级别,RR级别不足以用于分布式事务,官方没有对这里的不足做具体说明,但我们可以构建一个例子来分析这个“may not be sufficien”来描述读一致性是否恰当。

如下图,有A、B两个账户在两个实例上,假设每个账户初始都100块,A给B转账20,时间线左边为A账户实例上的操作,右边为B账户实例上的操作,中间T1到T6为不同时间点。

T1时刻:初始均100。

T2时刻:AB账户均完成xa prepare操作,一个减20,一个加20。

T3时刻:A帐户节点XA commit成功。

T5时刻:B帐户XA commit成功。

当处在RR或RC隔离级别时,发起一个对账操作,统计AB帐户资金总额,当只有他们相互转账时,总金额应该恒为200。T6 时刻时,查询A为80,B为120,总账为200,无问题。T4时刻查询A账户为80,查询B账户时由于MVCC机制,会读到上个快照中的值100,加一起为180,总账不对。因为是操作不同实例,当开始做xa commit之后,可能由于网络等原因,并不能保证所有节点的XA commit同时到达所有节点,在一个高并发场景,导致上面的问题几乎是必然的。因此,当使用MySQL 原生XA分布式事务时,若无其它手段来保障读一致性,而应用又有跨节点读的应用场景,应当使用序列化(SERIALIZABLE)隔离级别,“may not be sufficien”显然是不恰当的,没有任何一个业务能接受这种数据统计不对的。

如果是序列化隔离级别,T4时刻读到A为80,读B时会等待,直到T5时刻XA commit成功之后,  才能读到B为120,总账200,无问题。序列化隔离级别只有读-读不阻塞,读-写,写-读,写-写均会阻塞,而RC、RR仅写-写阻塞,因此只有序列化隔离级才能充分保障MySQL XA事务的读一致性。但它阻塞太多,性能也是各种隔离级别中最差的,所以如无必要,通常不会使用这一隔离级别。业界有很多方案来解决分布式事务RR、RC下的读一致性问题,以提高数据库性能,但原生的MySQL不具备这种能力,因此使用MySQL原生XA事务的业务需要谨慎选择隔离级别。

4 小结****

只要我们小心面对残留XA事务,谨慎处理Crash之后的可能存在的多余binlog数据,认真评估使用RR、RC隔离级别是否有读一致性读问题等问题之后,MySQL 的XA事务基本没有其它问题,可以作为RM完备提供跨节点分布式事务能力,MySQL已经实现了X/Open 组织定义的分布式事务处理规范中的语法功能,完全可以放心放业务在这条路上奔跑!

作者简介

Flyfox  高级后端工程师

从事数据库内核工作十多年,深度参与多个基于PostgreSQL、MySQL自研数据库项目,目前负责RDS产品研发团队工作。

推荐阅读

|Elastic-Job的执行原理及优化实践

|图数据库平台建设及业务落地

|数据库查询性能优化指南

\

本文分享自微信公众号 - OPPO数智技术(OPPO_tech)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。