AT vs XA
AT 这种事务模式是阿里开源的seata主推的事务模式,本文先给出了XA 和 AT之间的特性比较,然后详解AT的原理,并对其中的问题进行深入探讨
XA | AT | |
---|---|---|
脏回滚 | 无 | 存在 |
SQL支持度 | 全部支持 | 部分支持 |
脏读 | 无 | 有 |
应用侵入性 | 无侵入 | 无侵入 |
性能 | 较低 | 较低 |
数据库支持 | 主流数据库都支持 | 理论上可扩展至NoSQL |
原理
AT 从原理上面看,与 XA 的设计有很多相近之处。XA 是数据库层面实现的二阶段提交, AT 则是应用/驱动层实现的二阶段提交。建议您了解了XA相关的知识后,来阅读这篇文章,这样能够更快更好的掌握 AT 的原理与设计。
AT的角色和XA一样分为3个,但是起了不一样的名称,大家注意分辨:
- RM 资源管理器,是业务服务,负责本地数据库的管理,与XA中的RM一致
- TC 事务协调器,是Seata服务器,负责全局事务的状态管理,负责协调各个事务分支的执行,相当于XA中的TM
- TM 事务管理器,是业务服务,负责全局事务的发起,相当于XA中的APP
AT 的第一阶段为prepare,它在这一阶段会完成以下事情:
- RM 侧,用户开启本地事务
- RM 侧,用户每进行一次业务数据修改,假设是一个update语句,那么 AT 会做以下内容:
- 根据update的条件,查询出修改前的数据,该数据称为BeforeImage
- 执行update语句,根据BeforeImage中的主键,查询出修改后的数据,该数据称为AfterImage
- 将BeforeImage和AfterImage保存到一张undolog表
- 将BeforeImage中的主键以及表名,该数据称为lockKey,记录下来,留待后续使用
- RM 侧,用户提交本地事务时,AT 会做以下内容:
- 将2.4中记录的所有的lockKey,注册到 TC(即事务管理器seata)上
- 3.1中的注册处理会检查 TC 中,是否已存在冲突的主键+表名,如果有冲突,那么AT会睡眠等待后重试,没有冲突则保存
- 3.1成功完成后,提交本地事务
如果 AT 的第一阶段所有分支都没有错误,那么会进行第二阶段的commit,AT 会做以下内容:
- TC 会将当前这个全局事务所有相关的lockKey删除
- TC 通知与当前这个全局事务相关的所有业务服务,告知全局事务已成功,可以删除undolog中保存的数据
- RM 收到通知后,删除undolog中的数据
如果 AT 的第一阶段有分支出错,那么会进行第二阶段的rollback,AT 会做以下内容:
- TC 通知与当前这个全局事务相关的所有业务服务,告知全局事务失败,执行回滚
- RM 收到通知后,对本地数据的修改进行回滚,回滚原理如下:
- 从undolog中取出修改前后的BeforeImage和AfterImage
- 如果AfterImage与数据库中的当前记录校验一致,那么使用BeforeImage中的数据覆盖当前记录
- 如果AfterImage与数据库中的当前记录不一致,那么这个时候发生了脏回滚,此时需要人工介入解决
- TC 待全局事务所有的分支,都完成了回滚,TC 将此全局事务所有的lockKey删除
脏回滚
AT 模式的一个突出问题是rollback中2.3的脏回滚难以避免。以下步骤能够触发该脏回滚:
- 全局事务g1对数据行A1进行修改 v1 -> v2
- 另一个服务将对数据行A1进行修改 v2 -> v3
- 全局事务g1回滚,发现数据行A1的当前数据为v3,不等于AfterImage中的v2,回滚失败
这个脏回滚一旦发生,那么分布式事务框架没有办法保证数据的一致性了,必须要人工介入处理。想要避免脏回滚,需要把所有对这个表的写访问,都加上特殊处理(在Seata的Java客户端中,需要加上GlobalLock注解)。这种约束对于一个上了一定规模的复杂系统,是非常难以保证的。
XA 在数据库系统层面实现了行锁,原理与普通事务相同,因此一旦出现两个事务访问同一行数据,那么后一个事务会阻塞,完全不会有脏回滚的问题
SQL支持度
AT 模式并未支持所有的SQL,它的原理是在应用层解析SQL,然后根据不同的SQL生成BeforeImage和AfterImage,一方面不同的SQL可能需要采用不同的逻辑来生成这些Image,另一方面不同的数据库语法不同,因此不常见的SQL,AT可能不支持。
XA 是数据库层面支持的,因此对所有的DML SQL都支持,不会出现问题
脏读
AT 模式会发生脏读,在 AT 模式下发生如下的执行序列:
- 全局事务g1对数据行A1进行修改 v1 -> v2
- 另一个服务将读取数据行A1,获得数据 v2
- 全局事务g1回滚,将数据行A1改回 v2 -> v1
这里面步骤2读取的数据是v2,是一个中间态数据。在Seata的手册中,虽然也有一些方法能够避免AT模式下,但是涉及到注解和sql改写,并不优雅。
XA模式下,由于还没有进行xa commit,那么步骤2根据MVCC
读取到的数据依然是v1,没有AT模式中的脏读的困扰。
应用侵入性
AT 在最简单的情况下,通过在代码中添加注解,就能够把分布式事务引入到应用中,因此很多人认为是无侵入的。但是前面给出的脏读,脏回滚,SQL支持等,是开发人员必须考虑的,并进行设计,否则引入了相关注解变成全局事务之后,发生这些问题,导致线上应用故障,产生的后果会更严重。因此AT的无侵入不是真正的无侵入,仅仅是表面上代码的“无侵入”,但是设计上“侵入”了(不可以用AT未支持的SQL),行为上“侵入”了(需要容忍脏回滚),还会“侵入”其他项目(如果其他项目也写了同一张表,也需要加GlobalLock注解)。
XA 没有前面的问题,它的侵入性很低,在Java语言中,也同样做到通过加注解,而不用修改Java代码就完成分布式事务的引入。
注解是Java中很有特色的语法,是面向切面编程的一个典范。注解也有一定的理解成本,在Go和其他语言领域,并未引入注解,一个重要的理由是,通过显式的代码调用,更容易让读者理解中间发生了什么,可读性更好。DTM 在各语言的SDK中保持了统一的接口,让多语言的分布式事务更加简单,因此各语言的SDK大多未采用注解的这种接口方式。
性能分析
从原理的详细步骤看,XA事务的性能应高于AT,分析如下:
AT 模式下,RM侧,上述原理过程中,执行的SQL如下:
- 开启事务
- 查询BeforeImage数据
- 执行update
- 查询AfterImage数据
- 将BeforeImage,AfterImage插入到undolog中
- 提交事务
- 事务完成后,删除BeforeImage和AfterImage
而 XA 模式下,RM侧,执行的SQL如下:
- xa begin
- 执行update
- xa end
- xa prepare
- xa commit
两者对比,相关的开启/提交事务是两个模式都需要的,性能差异不大。但是从执行的DML操作来看,AT 下的 SQL 数量为:3 writes,2 read,比 XA 下仅一个update多出许多,因此在性能上会有较大的差距
从上述理论分析,XA 事务性能会大幅高于AT,应当可以在postgres数据库上验证出来;而mysql数据库,在当前的5.8版本上,由于xa prepare后,需要将当前连接断开才能够在其他连接上xa commit,所以会有一个重新创建连接的开销。
我同时也做了性能实测,详细的测试过程和结果数据,参考 xa-at bench
dtm实现的XA事务,为了在极端情况下,也能保证XA事务能够正确的被清理,会在业务事务中对子事务屏障表进行插入,因此会比上述理论分析中,多一个sql写入。
我们可以看到,最终的结果XA性能优于AT。如果未来Mysql完善了XA的实现,可以不用关闭当前连接也能够允许其他连接提交xa事务,那么XA的性能还能够提升一大截。
但AT和XA两种模式,由于数据锁在整个分布式事务期间的存在,降低了并发度,因此性能都低于其他模式。当您的并发度较高时,建议使用其他无全局锁的事务模式
数据库支持
AT 目前支持了多个主流数据库,而且从理论上看,也能够扩展到非SQL数据库,但目前暂未看到支持非SQL数据库的扩展。
- AT与Redis:虽然Redis也支持事务,但Redis的事务支持主要是通过lua脚本来做的,与传统数据库的Begin Transaction/Commit不一样,因此上述生成前后镜像的原理并不适用Redis,因此AT想要支持Redis会非常困难,目前未看到有这样的尝试
- AT与Mongo:Mongo的事务支持与Mysql类似,但Mongo的操作类型很多,而且在主键规范上面,与SQL数据库有很大不同,想要正确生成前后镜像的工作量庞大,目前未看到有这样的尝试
XA 模式则需要底层数据库支持,目前主流的数据库,Mysql,Postgres,Oracle等都已支持。如果分布式事务涉及mongo呢?这个时候需要考虑其他事务模式,DTM中有关于Redis,Mongo的事务例子
小结
mysql在版本5.6中,xa相关API存在bug。如果当前连接在xa prepare之后,连接断开,那么这个连接未完成的事务会被自动回滚。这样的bug导致mysql的XA模式是无法保证正确性的,在各种应用crash中,可能导致数据不一致。因此AT在mysql的5.6版本及更低版本使用中,是具有很高应用价值的。
另外部分大厂的数据库是禁止使用XA事务的,这种特定场景下,选型AT模式,也是合理的。
对于其他场景,建议优先考虑 XA 事务。
作者对AT模式的完整实现源码,并未完整阅读。上述的相关原理是根据自己阅读相关资料,并参考了seata-golang的源代码而写。文中如果不准确之处,希望各位读者帮忙指正