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

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

AT,XA,TCC,Saga

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

AT

image.png

这是Seata的一大特点,AT对业务代码彻底无侵入性,使用很是简单,改形成本低。咱们只须要关注本身的业务SQL,Seata会经过分析咱们业务SQL,反向生成回滚数据java

AT 包含两个阶段git

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

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

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

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

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

传统XA模式是存在一些问题的,Seata也是作了相关的优化,更多关于Seata XA的内容,传送门? http://seata.io/zh-cn/blog/se...
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 是长事务解决方案,每一个参与者须要实现事务的正向操做和补偿操做。当参与者正向操做执行失败时,回滚本地事务的同时,会调用上一阶段的补偿操做,在业务失败时最终会使事务回到初始状态springboot

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 的结构

// 省略了相关方法
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/ov...

问题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/o...

DEMO

https://github.com/TavenYin/t...

参考

相关文章
相关标签/搜索