Appearance
分布式事务 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 成功完成后,提交本地事务
- 将 2.4 中记录的所有的
如果 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 endxa preparexa commit
两者对比,相关的开启/提交事务是两个模式都需要的,性能差异不大。但是从执行的 DML 操作来看,AT 下的 SQL 数量为:3 writes,2 read,比 XA 下仅一个 update 多出许多,因此在性能上会有较大的差距。
从上述理论分析,XA 事务性能会大幅高于 AT,应当可以在 PostgreSQL 数据库上验证出来;而 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、PostgreSQL、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 的源代码而写。文中如果不准确之处,希望各位读者帮忙指正。
原文链接