分布式事务 Seata AT模式原理与实战

Seata 是阿里开源的基于Java的分布式事务解决方案

AT,XA,TCC,Saga

Seata 提供四种模式解决分布式事务场景,AT,XA,TCC,Saga。简单叨咕叨咕我对这几种模式的理解

AT

image.png

这是Seata的一大特色,AT对业务代码完全无侵入性,使用非常简单,改造成本低。我们只需要关注自己的业务SQL,Seata会通过分析我们业务SQL,反向生成回滚数据

AT 包含两个阶段

  • 一阶段,所有参与事务的分支,本地事务Commit 业务数据和回滚日志(undoLog)
  • 二阶段,事务协调者根据所有分支的情况,决定本次全局事务是Commit 还是 Rollback(二阶段是完全异步)
XA

也是我们常说的二阶段提交,XA要求数据库本身提供对规范和协议的支持。XA用起来的话,也是对业务代码无侵入性的。

上述其他三种模式,都是属于补偿型,无法保证全局一致性。啥意思呢,例如刚刚说的AT模式,我们是可能读到这一次分布式事务的中间状态,而XA模式不会。

补偿型 事务处理机制构建在 事务资源(数据库) 之上(要么在中间件层面,要么在应用层面),事务资源 本身对分布式事务是无感知的,这也就导致了补偿型事务无法做到真正的 全局一致性 。
比如,一条库存记录,处在 补偿型 事务处理过程中,由 100 扣减为 50。此时,仓库管理员连接数据库,查询统计库存,就看到当前的 50。之后,事务因为意外回滚,库存会被补偿回滚为 100。显然,仓库管理员查询统计到的 50 就是 脏 数据。
如果是XA的话,中间态数据库存 50 由数据库本身保证,不会被仓库管理员读到(当然隔离级别需要 读已提交 以上)

但是全局一致性带来的结果就是数据的锁定(AT模式也是存在全局锁的,但是隔离级别无法保证,后边我们会详细说),例如全局事务中有一条update语句,其他事务想要更新同一条数据的话,只能等待全局事务结束

传统XA模式是存在一些问题的,Seata也是做了相关的优化,更多关于Seata XA的内容,传送门👉http://seata.io/zh-cn/blog/seata-xa-introduce.html

TCC

image.png

TCC 模式同样包含两个阶段

  • Try 阶段 :所有参与分布式事务的分支,对业务资源进行检查和预留
  • 二阶段 Confirm:所有分支的Try全部成功后,执行业务提交
  • 二阶段 Cancel:取消Try阶段预留的业务资源

对比AT或者XA模式来说,TCC模式需要我们自己抽象并实现Try,Confirm,Cancel三个接口,编码量会大一些,但是由于事务的每一个阶段都由开发人员自行实现。而且相较于AT模式来说,减少了SQL解析的过程,也没有全局锁的限制,所以TCC模式的性能是优于AT 、XA模式。
PS:果然简单和高效难以两全的

Saga

image.png

Saga 是长事务解决方案,每个参与者需要实现事务的正向操作和补偿操作。当参与者正向操作执行失败时,回滚本地事务的同时,会调用上一阶段的补偿操作,在业务失败时最终会使事务回到初始状态

Saga与TCC类似,同样没有全局锁。由于相比缺少锁定资源这一步,在某些适合的场景,Saga要比TCC实现起来更简单。
由于Saga和TCC都需要我们手动编码实现,所以在开发时我们需要参考一些设计上的规范,由于不是本文重点,这里就不多说了,可以参考分布式事务 Seata 及其三种模式详解

在我们了解完四种分布式事务的原理之后,我们回到本文重点AT模式

AT 如何使用

模拟需求:以下订单为例,在分布式的电商场景中,订单服务和库存服务可能是两个数据库

我们先来看看AT模式下的代码是什么样的,这里忽略了Seata的相关配置,只看业务部分

image.png

在需要开启分布式事务的方法上标记@GlobalTransactional,然后执行分别执行扣减库存和创建订单操作,事务的参与者可以是本地的数据源,或者RPC的远程调用(远程调用的话需要携带全局事务ID,也就是上图的xid)

AT 一阶段

之前说过AT模式分为两个阶段,第一阶段包括提交业务数据和回滚日志(undoLog),第一阶段具体流程如下图

image.png

GlobalTransactional 切面

标记@GlobalTransactional的方法通过AOP实现了,开启全局事务和提交全局事务两个操作,与Spring 事务机制类似,当 GlobalTransactionalInterceptor 在事务执行过程中捕获到Throwable时,会发起全局事务回滚

0.1 步骤中会生成一个全局事务ID

0.2 所有事务参与者执行结束后,一阶段事务提交

undoLog

我们先来看看 Seata undoLog 的结构

1
2
3
4
5
6
7
8
9
10
11
// 省略了相关方法
public class SQLUndoLog {
// insert, update ...
private SQLType sqlType;

private String tableName;

private TableRecords beforeImage;

private TableRecords afterImage;
}

Seata 在执行业务SQL前后,会生成beforeImage和afterImage,在需要回滚时,根据SQLType,决定具体的回滚策略,例如SQLType=update时,将数据回滚到beforeImage的状态,如果SQLType=insert,则根据afterImage删除数据

如2.4所示,每条业务SQL,执行成功后,会为这条SQL生成LockKey,格式为tableName:PrimaryKey

注册分支事务

在3.1步骤注册分支事务时,client会把所有的LockKey 拼到一起作为全局锁发送给Seata-server。如果注册成功,写入undoLog,并提交本地事务,一阶段结束,等待二阶段反馈

如果当前有其他分支事务已经持有了相同的锁(即其他事务也在处理相同表的同一行),则client 注册事务分支失败。client会根据客户端定义的重发时间和重发次数进行不断的尝试,如果重试结束仍然没有获得锁,则一阶段失败,本地事务回滚。如果该全局事务存在已经注册成功分支事务,Seata-server 进行二阶段回滚

全局锁会在分支事务二阶段结束后释放

Seata 全局锁的设计是为了什么?
以扣减库存场景为例,TX1 完成库存扣减的一阶段,库存从100扣减为99,正在等待二阶段的通知。TX2也要扣减同一商品的库存,如果没有全局锁的限制,TX2库存从99扣减为98,这时如果TX1接收到回滚通知,进行回滚把库存从98回滚到100。因为没有全局锁,造成了脏写

AT 二阶段

二阶段是完全异步化的并且完全由Seata控制,Seata根据所有事务参与者的提交情况决定二阶段如何处理

  • 如果所有事务提交成功,则二阶段的任务就是删除一阶段生成 的undoLog,并释放全局锁
  • 如果部分事务参与者提交失败,则需要根据undoLog对已经注册的事务分支进行回滚,并释放全局锁

对Seata提出的疑问

至此我们已经初步了解了Seata的AT模式是如何实现的了

如果你也和我一样,仔细思考了上述过程,可能会提出一些问题,这边我列举一下我在学习Seata时,遇到的问题,以及我得出的结论

问题1. Seata如何做到无侵入的分析业务SQL生成undoLog,注册事务分支等操作?

Seata 代理了DataSource,我们可以通过在代码注入一个DataSource来验证我的说法,目前的DataSource 是 io.seata.rm.datasource.DataSourceProxy

image.png

所有的Java持久化框架,最终在操作数据库时都会通过DataSource接口获取Connection,通过Connection 实现对数据库的增删改查,事务控制。

image.png

Seata 通过代理Connection的方式,做到了无侵入的生成undoLog,注册事务分支,具体源码可以查看io.seata.rm.datasource.ConnectionProxy

问题2. ConnectionProxy 如何判断当前事务是全局事务,还是本地事务?

通过当前线程是否绑定了全局事务id,在进行全局事务之前,需要调用RootContext.bind(xid);

问题3. 全局事务并发更新

还是以下订单扣减库存的场景为例,如果TX1和TX2同时扣减product_id为1的库存,这时Seata会不会生成相同的beforeImage?

举个例子,TX1读库存为100,TX1扣减库存1,此时BeforeImage为100
紧接着 如果TX2读库存也为100,那么就有问题了,不管TX2扣减多少库存,如果TX1回滚那么相当于覆盖了TX2扣减的库存,出现了脏写

Seata是如何解决这个问题的?

源码位置:io.seata.rm.datasource.exec.AbstractDMLBaseExecutor::executeAutoCommitFalse
image.png

可以看到这里的逻辑和我上面画的图一致,证明我没有瞎说 😄

我们来看一下beforeImage(),这是一个抽象方法,看一下他的子类UpdateExecutor是如何实现的

image.png

通过Debug,可以看出Seata这边也是确实考虑了这个问题,直接简单而有效的解决了这个问题

回到我们的例子,由于SELECT FOR UPDATE的存在,TX2如果也想读同一条数据的话,只能等到TX1 提交事务后,才能读到。所以问题解决

问题4. 全局事务外的更新

我们现在可以确认在Seata的保证下,全局事务,不会造成数据的脏写,但是全局事务外会!

什么意思呢?

还以库存为例

  • 用户正在抢购,用户A完成了1阶段的库存扣减,这个时候库存为99。
  • 此时库存管理员上线了,他查了一下库存为99。嗯…太少了,我加100个,库存管理员把库存更新为200。
  • 而此时seata给用户A生成beforeImage为100,如果此时用户A的全局事务失败了,发生了回滚,再次将库存更新为100… 再次出现脏写

Seata 针对这个问题,提供了@GlobalLock注解,标记该注解时,会像全局事务一样进行SQL分析,竞争全局锁,就不会出现上述问题了

关于这个问题可以参考Seata的FAQ文档 http://seata.io/zh-cn/docs/overview/faq.html

问题5. @GlobalTransactional 和 @Transactional 同时使用会怎么样

我们上文中已经说过了 @GlobalTransactional 的作用了,他是负责开启全局事务/提交事务1阶段,说白了@GlobalTransactional 只和Seata-server 交互,而 @Transactional 管理的是本地数据库的事务,所以二者不发生冲突。

但是需要注意 @GlobalTransactional AOP 覆盖范围一定要大于 @Transactional

问题6. 如果其中某一个事务分支超时未提交,会发生什么

这个我并没有看源码,而是通过跑demo,验证的

例如现在有A,B两个事务分支

  • A 正常提交,并向Seata注册分支成功
  • B 2分钟后提交事务,并向Seata发起注册

Seata的全局事务超时时间,默认是1分钟,Seata-server 在检测到有超时的全局事务时,会向所有已提交的分支,发起回滚。而超时提交的事务,向Seata-server发起分支注册时,响应结果为事务已超时,或者事务不存在,也会回滚本地事务

问题7. Seata-client 如何接收Seata-server发起的通知

Seata-client 包含了Netty服务,在启动时Netty会监听端口,并向Seata-server 发起注册。server中存储了client 的调用地址。

总结

我们学习了Seata的AT模式是如何工作的,可以看出Seata模式在开发上是非常简单的,但是Seata的背后为了维持分布式事务的数据一致性,做了大量的工作,AT模式非常适合现有的业务模型直接迁移。

但是他的缺点也很明显,性能并不是那么的优秀。例如我们刚刚看到的全局锁的问题,为了数据不会发生脏写,Seata牺牲了业务的并发能力。在非常要求性能的场景,可能还是需要考虑TCC,SAGA,可靠消息等方案

在使用Seata开发前,建议大家先去阅读一下FAQ文档,避免踩坑 https://seata.io/zh-cn/docs/overview/faq.html

DEMO

https://github.com/TavenYin/taven-springboot-learning/tree/master/springboot-seata

参考

Seata是什么 - http://seata.io/zh-cn/docs/overview/what-is-seata.html
Seata常见问题 - http://seata.io/zh-cn/docs/overview/faq.html
分布式事务中间件 Seata 的设计原理 - http://seata.io/zh-cn/blog/seata-at-mode-design.html
分布式事务 Seata 及其三种模式详解 - http://seata.io/zh-cn/blog/seata-at-tcc-saga.html
分布式事务如何实现?深入解读 Seata 的 XA 模式 - http://seata.io/zh-cn/blog/seata-xa-introduce.html