Seata设计方案

引言

在深入介绍Seata的实现之前,我们先在一个较高的层面一览Seata的整体设计思想。

一、设计方案

整体架构

首先,很自然的,我们可以把一个分布式事务理解成一个包含了若干分支事务的全局事务。全局事务的职责是协调其下管辖的分支事务达成一致,要么一起成功提交,要么一起失败回滚。此外,通常分支事务本身就是一个满足ACID的本地事务。

基于两阶段提交模式,从设计上我们可以将整体分成三个大模块,即TMRMTC,具体解释如下:

  • TM(Transaction Manager):全局事务管理器,控制全局事务边界,负责全局事务开启、全局提交、全局回滚。
  • RM(Resource Manager):资源管理器,控制分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支(本地)事务的提交和回滚。
  • TC(Transaction Coordinator):事务协调器,维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。

一个典型的分布式事务过程:

  1. TMTC申请开启一个全局事务,全局事务创建成功并生成一个全局唯一的XID
  2. XID在微服务调用链路的上下文中传播。
  3. RMTC注册分支事务,将其纳入XID对应全局事务的管辖。
  4. TMTC发起针对XID的全局提交或回滚决议。
  5. TC调度XID下管辖的全部分支事务完成提交或回滚请求。

看到这,大家基本上就明白分布式事务处理的全貌了,实际上数据库层面的XA协议,也是这样做的。我们将整个这一部分从数据库层抽离出来后,在进行分布式事务时,就不需要下层数据库实现XA协议了,只需要支持本地事务的ACID即可,分支的提交和回滚机制,都依赖于本地事务的保障。这点对于微服务化的架构来说是非常重要的:应用层不需要为本地事务和分布式事务两类不同场景来适配两套不同的数据库驱动。

那么,Seata就是将XA协议的实现理论从数据库层面抽离出来这么简单么?不仅仅如此,还记得前面提到的数据库XA协议遇到性能问题无法优化的窘境么,Seata不仅解决了分布式事务的一致性问题,还针对实际的应用场景,改善了XA方案的锁机制,从而增加了并发能力。此外,Seata不仅仅支持2PC模式,还支持TCC等其他分布式事务处理模式,使用者可以根据实际的应用场景自行选择。

与XA的区别

我们先来看看XA协议的2PC过程:

无论Phase2的决议是commit还是rollback,事务性资源的锁都要保持到Phase2完成才释放。

设想一个正常运行的业务,大概率是90%以上的事务最终应该是成功提交的,我们是否可以在Phase1就将本地事务提交呢?这样90%以上的情况下,可以省去Phase2持锁的时间,整体提高效率。

  • 分支事务中数据的本地锁由本地事务管理,在分支事务Phase1结束时释放,这时候其他本地事务就能读取到最新的数据。
  • 同时,随着本地事务结束,连接也得以释放。
  • 分支事务中数据的全局锁在事务协调器管理,在决议Phase2全局提交时,全局锁马上可以释放,注意这里是先释放锁,再进行分支事务的提交过程。只有在决议全局回滚的情况下,全局锁才被持有至分支的Phase2结束,即所有分支事务回滚结束。

这个设计,极大地减少了分支事务对资源(数据和连接)的锁定时间,给整体并发和吞吐的提升提供了基础。但是本地事务的锁这么早释放,会不会有什么问题呢?问题也是有的,就是分布式事务的隔离级别变化了,这个话题比较复杂,我们后面再详细介绍。

二、分支事务

Seata不仅支持像XA协议那种对业务无侵入的事务处理方式,还支持TCC等类型的处理方式,它们在不同的业务场景各显神通,下面我将分别介绍它们。

2.1 AT模式

AT模式是一种无侵入的分布式事务解决方案。在AT模式下,用户只需关注自己的“业务SQL”,用户的“业务SQL”就是全局事务一阶段,Seata框架会自动生成事务的二阶段提交和回滚操作。

那么AT模式是如何做到对业务无侵入的呢?

首先,应用要使用SeataJDBC数据源代理,也就是前面提到的RM概念,所有对DB的操作都是通过Seata RM代理完成。在这层代理中,Seata会自动控制SQL的执行,提交,回滚。下图中绿色部分是JDBC数据源的原生实现内容,黄色部分就是Seata的数据源代理。

一阶段

SeataJDBC数据源代理通过对业务SQL的解析,把业务数据在更新前后的数据镜像(beforeImage & afterImage)组织成回滚日志,利用本地事务ACID特性,将业务数据的更新和回滚日志的写入在同一个本地事务中提交。这样,可以保证:任何提交的业务数据的更新一定有相应的回滚日志存在。

然后,本地事务在提交之前,还需要通过RMTC注册本地分支,这个注册过程中会根据刚才执行的SQL拿到所有涉及到的数据主键,以resourceId + tableName + rowPK 作为锁的key,向TC申请所有涉及数据的写锁,当获得所有相关数据的写锁后,再执行本地事务的Commit过程。如果有任何一行数据的写锁没有拿到的话,TC会以fastfail的方式回复该RMRM会以重试 + 超时机制重复该过程,直到超时。

完成本地事务后,RM会向TC汇报本地事务的执行情况,并完成业务RPC的调用过程。

这里大家可能会有疑问,TM一般是以RPC的方式调用RM的,那么TM直接可以通过RPC的结果知道该RM的本地事务是否提交成功,那么为什么RM还需要向TC汇报本地事务的执行结果呢?实际上,TM可以在某个RM执行失败时,强制进行全局事务的提交,这时候如果TC发现某个RM的一阶段过程都没执行成功,就不会向其发送二阶段的Commit指令了。

二阶段

  • 如果TM决议是全局提交,此时分支事务实际上已经完成提交,TC立刻释放该全局事务的所有锁,然后异步调用RM清理回滚日志,Phase2可以非常快速地完成。

  • 如果决议是全局回滚,RM收到协调器发来的回滚请求,通过XIDBranch ID找到相应的回滚日志记录,通过回滚记录生成反向的更新SQL并执行,以完成分支的回滚。当分支回滚顺利结束时,通知TC回滚完成,这时候TC才释放该分支事务相关的所有锁。

这里有一个需要注意的点,RM在进行回滚时,会先跟afterImage进行比较:

  • 如果一致:则执行逆向SQL
  • 如果不一致:再跟beforeImage进行比较
  1. 如果一致:说明没必要执行回滚SQL了,数据已经恢复了
  2. 如果不一致:说明出现了脏数据,这时候就抛出异常,需要人工处理

回滚失败的思考

上述的回滚失败情况之所以会出现,一般都是有人直接绕过系统直接操作DB数据导致或者没有正确的配置RM导致的,因为即便是该RM执行单独的本地事务,在进行适当的配置后(添加GlobalLock注解),也会在本地提交前试图获取TC中的资源锁。

我认为Seata需要显式地给现有函数加GlobalLock注解的方案有一个问题,如果一个表既会被本地事务更新,也会在分布式事务中更新,那么这个表的所有本地事务都需要加上该注解,才能完全杜绝回滚失败的问题。当然,这里并不包括人直接绕过系统操作DB的场景,我认为这种绕过系统操作DB的场景需要单独开发一个基于SeataSQL执行系统,该系统需要保证本地事务提交前都需要获取TC中的资源锁,不过这种场景应该很少发生,我这里不做过多的评论。

回到GlobalLock的问题中来,如果某一个子系统对外开放了一个分布式事务接口,那么该接口更新过的任何一个表,如果在该系统的本地独立事务中也会被修改,就会导致前面所说的事务无法回滚的问题。这时候,如果人工介入进来,一般需要先锁住脏数据,然后根据数据库执行记录,人工修正数据,最后将TC中出错的分支事务手动置为完成回滚的状态。听起来好像也不是很难,但是如果对应的数据是热点数据,在回滚前更新了很多次,就需要人工确认冗长的修改历史线,那简直是一场灾难。更有甚者,如果分布式事务中增加了某个人的存款余额,比如0 -> 1000,回滚前被其他独立事务消费掉了500,那最后修完数据该用户的实际余额应该是-500,这就得联系该用户,追回这笔钱。这个例子,虽然有点极端,但是它就是一个例子。

那么解决这个问题有什么办法呢,我简单想了几个:

  1. 在文档和Sample中强调GlobalLock注解的重要性,防止踩坑
  • 评价 :软限制,出了问题影响依旧大,但是我觉得这个措施很有必要。
  1. 全函数默认都有GlobalLock注解
  • 评价 :性能影响大,有误伤。
  1. 全函数默认都有GlobalLock注解,增加IgnoreGlobalLock注解,其效果和GlobalLock成反效果
  • 评价 :同样有误伤,如果某个服务,大多数表只会被单独本地事务修改,那么加IgnoreGlobalLock注解的工作量也很大。
  1. TC中,给每个RM维护一个分布式事务相关资源表ResourceTableSet,内容为tableName,当进行分支事务注册时,将对应资源表添加到该ResourceTableSet,当该ResourceTableSet发生变化或者有新的RM连接到TC时,TC主动将最新的ResourceTableSet推送给RMRM本地事务执行JDBC代理时,如果函数不在全局事务或者全局锁中,就先解析SQLAST,如果它涉及修改过程,并且修改的表在ResourceTableSet中,就自动升级该函数,使其达到与标有GlobalLock的函数一样的效果,并且通知开发者该升级事件,这样开发者就能找到漏加GlobalLock注解的函数,如果修改的表不在ResourceTableSet,我们可以将其所在的函数加入缓存中,下次执行该函数时直接跳过分析,恢复损耗的性能,当ResourceTableSet更新时,我们刷新该缓存,保证可靠性。同时,如果开发者明确的做了业务逻辑上的划分,保证完全不会发生回滚失败的情况的话,我们可以提供一个IgnoreAutoGlobalLock的注解,跳过上述过程,直接使用原生JDBC连接,从而减少性能的损耗。
  • 评价 :比较自动,性能影响不是很大,具有一定程度的硬限制,但是TC中维护的ResourceTableSet存在空窗期,仍有潜在风险,可是风险比之前低了很多,并且系统在测试阶段或者上线初期就能基本检查出遗漏的函数,能减少由于开发者疏忽而引入的潜在风险。
  1. 编译期检查?
  • 评价 :没法在编译期确定哪些函数会成为分布式事务中的分支事务(暴露出来,但是没人用),实现复杂,如果SQL中的表名是通过参数传入,则无法检查到。

上述的反思,我也给官方提了一个issue,感兴趣的同学可以去看看。

2.2 TCC模式

TCC模式需要用户根据自己的业务场景实现Try、Confirm和Cancel三个操作;事务发起方先在TC中注册全局事务,然后在一阶段执行Try方法,在二阶段提交的话TC会去执行各个RM的Confirm方法,二阶段回滚则TC会去执行各个RM的Cancel方法。

在Seata框架中,每个TCC接口对应了一个Resource,TCC接口可以是RPC,也可以是服务内JVM调用。在业务启动时,Seata框架会自动扫描识别到TCC接口的调用方和发布方。如果是RPC的话,就是sofa:reference、sofa:service、dubbo:reference、dubbo:service等,Seata会检查这些RPC接口是否有TCC相关的注解,有的话说明这个RPC是一个TCC接口,否则则是正常RPC过程,不划入分布式事务中。

扫描到TCC接口的调用方和发布方之后。如果是发布方,会在业务启动时向TC注册TCC Resource,与DataSource Resource一样,每个资源也会带有一个资源ID。

与AT模式一样,Seata会给实际方法的执行加切面,该切面会拦截所有对TCC接口的调用。在调用Try接口时,如果发现处在全局事务中,切面会先向TC注册一个分支事务,和AT不同的是TCC注册分支事务是不加锁的,注册完成后去执行原来的RPC调用。当请求链路调用完成后,TC通过分支事务的资源ID回调到正确的参与者去执行对应TCC资源的Confirm或Cancel方法。

TCC模式的整体框架相对于AT来说更加简单,主要是扫描TCC接口,注册资源,拦截接口调用,注册分支事务,最后回调二阶段接口。最核心的实际上是TCC接口的实现逻辑。下面我将结合实际的例子,来介绍一下TCC模式相较于AT模式有什么优势和劣势。

使用原则

从TCC模型的框架可以发现,TCC模型的核心在于TCC接口的设计。用户在接入TCC时,大部分工作都集中在如何实现TCC服务上。这就是TCC模式最主要的问题,对业务侵入比较大,要花很大的功夫来实现TCC服务。

设计一套TCC接口最重要的是什么?主要有两点,第一点,需要将操作分成两阶段完成。TCC(Try-Confirm-Cancel)分布式事务模型相对于XA等传统模型,其特征在于它不依赖RM对分布式事务的支持,而是通过对业务逻辑的分解来实现分布式事务。

TCC模型认为对于业务系统中一个特定的业务逻辑,其对外提供服务时,必须接受一些不确定性,即对业务逻辑初步操作的调用仅是一个临时性操作,调用它的主业务服务保留了后续的取消权。如果主业务服务认为全局事务应该回滚,它会要求取消之前的临时性操作,这就对应从业务服务的取消操作。而当主业务服务认为全局事务应该提交时,它会放弃之前临时性操作的取消权,这对应从业务服务的确认操作。每一个初步操作,最终都会被确认或取消。因此,针对一个具体的业务服务,TCC分布式事务模型需要业务系统提供三段业务逻辑:

  1. 初步操作Try:完成所有业务检查,预留必须的业务资源。
  2. 确认操作Confirm:真正执行的业务逻辑,不做任何业务检查,只使用Try阶段预留的业务资源。因此,只要Try操作成功,Confirm必须能成功。另外,Confirm操作需满足幂等性,保证一笔分布式事务能且只能成功一次。
  3. 取消操作Cancel:释放Try阶段预留的业务资源。同样的,Cancel操作也需要满足幂等性。

第二点,就是要根据自身的业务模型控制并发,这个对应ACID中的隔离性。

业务模型

下面我们以金融核心链路里的账务服务来分析一下。首先一个最简化的账务模型就是图中所列,每个用户或商户有一个账户及其可用余额。然后,分析下账务服务的所有业务逻辑操作,无论是交易、充值、转账、退款等,都可以认为是对账户的加钱与扣钱。

因此,我们可以把账务系统拆分成两套TCC接口,即两个TCC Resource,一个是加钱TCC接口,一个是扣钱TCC接口。

那这两套接口分别需要做什么事情呢?如何将其分成两个阶段完成?下面将会举例说明TCC业务模式的设计过程,并逐渐优化。

我们先来看扣钱的TCC资源怎么实现。场景为A转账30元给B。账户A的余额中有100元,需要扣除其中30元。这里的余额就是所谓的业务资源,按照前面提到的原则,在第一阶段需要检查并预留业务资源,因此,我们在扣钱TCC资源的Try接口里先检查A账户余额是否足够,然后预留余额里的业务资源,即扣除30元。

在Confirm接口,由于业务资源已经在Try接口里扣除掉了,那么在第二阶段的Confirm接口里,可以什么都不用做。而在Cancel接口里,则需要把Try接口里扣除掉的30元还给账户。这是一个比较简单的扣钱TCC资源的实现,后面会继续优化它。

而在加钱的TCC资源里。在第一阶段Try接口里不能直接给账户加钱,如果这个时候给账户增加了可用余额,那么在一阶段执行完后,账户里的钱就可以被使用了。但是一阶段执行完以后,有可能是要回滚的。因此,真正加钱的动作需要放在Confirm接口里。对于加钱这个动作,第一阶段Try接口里不需要预留任何资源,可以设计为空操作。那相应的,Cancel接口没有资源需要释放,也是一个空操作。只有真正需要提交时,再在Confirm接口里给账户增加可用余额。

这就是一个最简单的扣钱和加钱的TCC资源的设计。在扣钱TCC资源里,Try接口预留资源扣除余额,Confirm接口空操作,Cancel接口释放资源,增加余额。在加钱TCC资源里,Try接口无需预留资源,空操作;Confirm接口直接增加余额;Cancel接口无需释放资源,空操作。

业务并发模型

之前提到,设计一套TCC接口需要有两点,一点是需要拆分业务逻辑成两阶段完成。这个我们已经介绍了。另外一点是要根据自身的业务模型控制并发。

Seata框架本身仅提供两阶段原子提交协议,保证分布式事务原子性。事务的隔离需要交给业务逻辑来实现。隔离的本质就是控制并发,防止并发事务操作相同资源而引起的结果错乱。

举个例子,比如金融行业里管理用户资金,当用户发起交易时,一般会先检查用户资金,如果资金充足,则扣除相应交易金额,增加卖家资金,完成交易。如果没有事务隔离,用户同时发起两笔交易,两笔交易的检查都认为资金充足,实际上却只够支付一笔交易,结果两笔交易都支付成功,导致资损。

可以发现,并发控制是业务逻辑执行正确的保证,但是像两阶段锁这样的并发访问控制技术要求一直持有数据库资源锁直到整个事务执行结束,特别是在分布式事务架构下,要求持有锁到分布式事务第二阶段执行结束,也就是说,分布式事务会加长资源锁的持有时间,导致并发性能进一步下降。

因此,TCC模型的隔离性思想就是通过业务的改造,在第一阶段结束之后,从底层数据库资源层面的加锁过渡为上层业务层面的加锁,从而释放底层数据库锁资源,放宽分布式事务锁协议,将锁的粒度降到最低,以最大限度提高业务并发性能。

还是以上面的例子举例,“账户A上有100元,事务T1要扣除其中的30元,事务T2也要扣除30元,出现并发”。在第一阶段Try操作中,需要先利用数据库资源层面的加锁,检查账户可用余额,如果余额充足,则预留业务资源,扣除本次交易金额,一阶段结束后,虽然数据库层面资源锁被释放了,但这笔资金被业务隔离,不允许除本事务之外的其它并发事务动用。

并发的事务T2在事务T1一阶段接口结束释放了数据库层面的资源锁以后,就可以继续操作,跟事务T1一样,加锁,检查余额,扣除交易金额。

事务T1和T2分别扣除的那一部分资金,相互之间无干扰。这样在分布式事务的二阶段,无论T1是提交还是回滚,都不会对T2产生影响,这样T1和T2可以在同一个账户上并发执行。

大家可以感受下,一阶段结束以后,实际上采用业务加锁的方式,隔离账户资金,在第一阶段结束后直接释放底层资源锁,该用户和卖家的其他交易都可以立刻并发执行,而不用等到整个分布式事务结束,可以获得更高的并发交易能力。

在这里,TCC模式和之前说过的AT模式区别是:

  • AT模式会持有锁到全局事务提交,或在回滚时持有锁直到回滚成功
  • TCC模式一阶段结束就释放锁

想象一下一个业务要调用A,B,C三个子服务,如果是采用AT模式,那么至少要等C结束后,才会释放A,B,C的相关资源锁,而如果采用TCC模式,A结束就会释放A的锁,B结束就释放B的锁...。并发能力一下子就提高了N倍,这就是TCC相较于AT模式的优点————并发能力。

下面我们将会针对业务模型进行优化,大家可以更直观的感受业务加锁的思想。

业务模型优化

前面的模型大家肯定会想,为啥一阶段就把钱扣除了?是的。之前只是为了简单说明TCC模型的设计思想。在实际中,为了更好的用户体验,在第一阶段,一般不会直接把账户的余额扣除,而是冻结,这样给用户展示的时候,就可以很清晰的知道,哪些是可用余额,哪些是冻结金额。

那业务模型变成什么样了呢?如图所示,需要在业务模型中增加冻结金额字段,用来表示账户有多少金额处以冻结状态。

既然业务模型发生了变化,那扣钱和加钱的TCC接口也应该相应的调整。还是以前面的例子来说明。

在扣钱的TCC资源里。Try接口不再是直接扣除账户的可用余额,而是真正的预留资源,冻结部分可用余额,即减少可用余额,增加冻结金额。Confirm接口也不再是空操作,而是使用Try接口预留的业务资源,即将该部分冻结金额扣除;最后在Cancel接口里,就是释放预留资源,把Try接口的冻结金额扣除,增加账户可用余额。加钱的TCC资源由于不涉及冻结金额的使用,所以无需更改。

通过这样的优化,可以更直观的感受到TCC接口的预留资源、使用资源、释放资源的过程。

那并发控制又变成什么样了呢?跟前面大部分类似,在事务T1的第一阶段Try操作中,先锁定账户,检查账户可用余额,如果余额充足,则预留业务资源,减少可用余额,增加冻结金额。并发的事务T2类似,加锁,检查余额,减少可用余额金额,增加冻结金额。

这里可以发现,事务T1和T2在一阶段执行完成后,都释放了数据库层面的资源锁,但是在各自二阶段的时候,相互之间并无干扰,各自使用本事务内第一阶段Try接口内冻结金额即可。这里大家就可以直观感受到,在每个事务的第一阶段,先通过数据库层面的资源锁,预留业务资源,即冻结金额。虽然在一阶段结束以后,数据库层面的资源锁被释放了,但是第二阶段的执行并不会被干扰,这是因为数据库层面资源锁释放以后通过业务隔离的方式为这部分资源加锁,不允许除本事务之外的其它并发事务动用,从而保证该事务的第二阶段能够正确顺利的执行。

通过这两个例子,为大家讲解了怎么去设计一套完备的TCC接口。最主要的有两点,一点是将业务逻辑拆分成两个阶段完成,即Try、Confirm、Cancel接口。其中Try接口检查资源、预留资源、Confirm使用资源、Cancel接口释放预留资源。另外一点就是并发控制,采用数据库锁与业务加锁的方式结合。由于业务加锁的特性不影响性能,因此,尽可能降低数据库锁粒度,过渡为业务加锁,从而提高业务并发能力。

异常控制

在有了一套完备的TCC接口之后,是不是就真的高枕无忧了呢?答案是否定的。在微服务架构下,很有可能出现网络超时、重发,机器宕机等一系列的异常Case。一旦遇到这些Case,就会导致我们的分布式事务执行过程出现异常。最常见的主要是这三种异常,分别是空回滚、幂等、悬挂。

因此,TCC接口里还需要解决这三类异常。实际上,这三类问题可以在Seata框架里完成,只不过现在的Seata框架还不具备,之后这些异常Case的处理会被移植到Seata框架里,业务就无需关注这些异常情况,专注于业务逻辑即可。

虽然业务之后无需关心,但是了解一下其内部实现机制,也能更好的排查问题。下面我将为大家一一讲解这三类异常出现的原因以及对应的解决方案。

空回滚

空回滚就是对于一个分布式事务,在没有调用TCC资源Try方法的情况下,调用了二阶段的Cancel方法,Cancel方法需要识别出这是一个空回滚,然后直接返回成功

什么样的情形会造成空回滚呢?可以看图中的第2步,前面讲过,注册分支事务是在调用RPC时,Seata框架的切面会拦截到该次调用请求,先向TC注册一个分支事务,然后才去执行RPC调用逻辑。如果RPC调用逻辑有问题,比如调用方机器宕机、网络异常,都会造成RPC调用失败,即未执行Try方法。但是分布式事务已经开启了,需要推进到终态,因此,TC会回调参与者二阶段Cancel接口,从而形成空回滚。

那会不会有空提交呢?理论上来说不会的,如果调用方宕机,那分布式事务默认是回滚的。如果是网络异常,那RPC调用失败,发起方应该通知TC回滚分布式事务,这里可以看出为什么是理论上的,就是说发起方可以在RPC调用失败的情况下依然通知TC提交,这时就会发生空提交,这种情况要么是编码问题,要么开发同学明确知道需要这样做。

那怎么解决空回滚呢?前面提到,Cancel要识别出空回滚,直接返回成功。那关键就是要识别出这个空回滚。思路很简单就是需要知道一阶段是否执行,如果执行了,那就是正常回滚;如果没执行,那就是空回滚。因此,需要一张额外的事务控制表,其中有分布式事务ID和分支事务ID,第一阶段Try方法里会插入一条记录,表示一阶段执行了。Cancel接口里读取该记录,如果该记录存在,则正常回滚;如果该记录不存在,则是空回滚。

幂等

幂等就是对于同一个分布式事务的同一个分支事务,重复去调用该分支事务的第二阶段接口,因此,要求TCC的二阶段Confirm和Cancel接口保证幂等,不会重复使用或者释放资源。如果幂等控制没有做好,很有可能导致资损等严重问题。

什么样的情形会造成重复提交或回滚?从图中可以看到,提交或回滚是一次TC到参与者的网络调用。因此,网络故障、参与者宕机等都有可能造成参与者TCC资源实际执行了二阶段防范,但是TC没有收到返回结果的情况,这时,TC就会重复调用,直至调用成功,整个分布式事务结束。

怎么解决重复执行的幂等问题呢?一个简单的思路就是记录每个分支事务的执行状态。在执行前状态,如果已执行,那就不再执行;否则,正常执行。前面在讲空回滚的时候,已经有一张事务控制表了,事务控制表的每条记录关联一个分支事务,那我们完全可以在这张事务控制表上加一个状态字段,用来记录每个分支事务的执行状态。

这个过程有点类似于AT模式中的Undo Log,我们不妨看下Undo Log的表结构:

CREATE TABLE `undo_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `branch_id` bigint(20) NOT NULL,
  `xid` varchar(100) NOT NULL,
  `context` varchar(128) NOT NULL,
  `rollback_info` longblob NOT NULL,
  `log_status` int(11) NOT NULL,
  `log_created` datetime NOT NULL,
  `log_modified` datetime NOT NULL,
  `ext` varchar(100) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

我们可以看到,通过该表中branch_idxid我们可以确认该条记录对应那个分支事务,然后log_status可以用来判断该分支事务的执行情况。我们让log_status有三个值,分别是初始化、已提交、已回滚。Try方法插入时,是初始化状态。二阶段Confirm和Cancel方法执行后修改为已提交或已回滚状态。当重复调用二阶段接口时,先获取该事务控制表对应记录,检查状态,如果已执行,则直接返回成功;否则正常执行。

悬挂

悬挂就是对于一个分布式事务,其二阶段Cancel接口比Try接口先执行

因为允许空回滚的原因,Cancel接口认为Try接口没执行,空回滚直接返回成功,对于Seata框架来说,认为分布式事务的二阶段接口已经执行成功,整个分布式事务就结束了。但是这之后Try方法才真正开始执行,预留业务资源,回想一下前面提到事务并发控制的业务加锁,对于一个Try方法预留的业务资源,只有该分布式事务才能使用,然而Seata框架认为该分布式事务已经结束,也就是说,当出现这种情况时,该分布式事务第一阶段预留的业务资源就再也没有人能够处理了,对于这种情况,我们就称为悬挂,即业务资源预留后没有被继续处理。

什么样的情况会造成悬挂呢?按照前面所讲,在RPC调用时,先注册分支事务,再执行RPC调用,如果此时RPC调用的网络发生拥堵,通常RPC调用是有超时时间的,RPC超时以后,发起方就会通知TC回滚该分布式事务,可能回滚完成后,RPC请求才到达参与者,真正执行,从而造成悬挂。

怎么实现才能做到防悬挂呢?根据悬挂出现的条件先来分析下,悬挂是指二阶段Cancel执行完后,一阶段才执行。也就是说,为了避免悬挂,如果二阶段执行完成,那一阶段就不能再继续执行。因此,当一阶段执行时,需要先检查二阶段是否已经执行完成,如果已经执行,则一阶段不再执行;否则可以正常执行。那怎么检查二阶段是否已经执行呢?大家是否想到了刚才解决空回滚和幂等时用到的事务控制表,可以在二阶段执行时插入一条事务控制记录,状态为已回滚,这样当一阶段执行时,先读取该记录,如果记录存在,就认为二阶段已经执行;否则二阶段没执行。

异常控制实现

在分析完空回滚、幂等、悬挂等异常Case的成因以及解决方案以后,下面我们就综合起来考虑,一个TCC接口如何完整的解决这三个问题。

首先是Try方法。结合前面讲到空回滚和悬挂异常,Try方法主要需要考虑两个问题,一个是Try方法需要能够告诉二阶段接口,已经预留业务资源成功。第二个是需要检查第二阶段是否已经执行完成,如果已完成,则不再执行。因此,Try方法的逻辑可以如图所示:

先插入事务控制表记录,如果插入成功,说明第二阶段还没有执行,可以继续执行第一阶段。如果插入失败,则说明第二阶段已经执行或正在执行,则抛出异常,终止即可。

接下来是Confirm方法。因为Confirm方法不允许空回滚,也就是说,Confirm方法一定要在Try方法之后执行。因此,Confirm方法只需要关注重复提交的问题。可以先锁定事务记录,如果事务记录为空,则说明是一个空提交,不允许,终止执行。如果事务记录不为空,则继续检查状态是否为初始化,如果是,则说明一阶段正确执行,那二阶段正常执行即可。如果状态是已提交,则认为是重复提交,直接返回成功即可;如果状态是已回滚,也是一个异常,一个已回滚的事务,不能重新提交,需要能够拦截到这种异常情况,并报警。

最后是Cancel方法。因为Cancel方法允许空回滚,并且要在先执行的情况下,让Try方法感知到Cancel已经执行,所以和Confirm方法略有不同。首先依然是锁定事务记录。如果事务记录为空,则认为Try方法还没执行,即是空回滚。空回滚的情况下,应该先插入一条事务记录,确保后续的Try方法不会再执行。如果插入成功,则说明Try方法还没有执行,空回滚继续执行。如果插入失败,则认为Try方法正在执行,等待TC的重试即可。如果一开始读取事务记录不为空,则说明Try方法已经执行完毕,再检查状态是否为初始化,如果是,则还没有执行过其他二阶段方法,正常执行Cancel逻辑。如果状态为已回滚,则说明这是重复调用,允许幂等,直接返回成功即可。如果状态为已提交,则同样是一个异常,一个已提交的事务,不能再次回滚。

通过这一部分的讲解,大家应该对TCC模型下最常见的三类异常Case,空回滚、幂等、悬挂的成因有所了解,也从实际例子中知道了怎么解决这三类异常,在解决了这三类异常的情况下,我们的TCC接口设计就是比较完备的了。

目前Seata最新版本1.5.1已解决,具体可以参考Seata新版本终于解决了TCC模式的幂等、悬挂和空回滚问题

性能再优化

虽然TCC模型已经完备,但是随着业务的增长,对于TCC模型的挑战也越来越大,可能还需要一些特殊的优化,才能满足业务需求。下面我将会给大家讲讲,在TCC模型上还可以做哪些优化。

同库模式

第一个优化方案是改为同库模式。同库模式简单来说,就是分支事务记录与业务数据在相同的库中。什么意思呢?之前提到,在注册分支事务记录的时候,框架的调用方切面会先向TC注册一个分支事务记录,注册成功后,才会继续往下执行RPC调用。TC在收到分支事务记录注册请求后,会往自己的数据库里插入一条分支事务记录,从而保证事务数据的持久化存储。那同库模式就是调用方切面不再向TC注册了,而是直接往业务的数据库里插入一条事务记录。

在讲解同库模式的性能优化点之前,先给大家简单讲讲同库模式的恢复逻辑。一个分布式事务的提交或回滚还是由发起方通知TC,但是由于分支事务记录保存在业务数据库,而不是TC端。因此,TC不知道有哪些分支事务记录,在收到提交或回滚的通知后,仅仅是记录一下该分布式事务的状态。那分支事务记录怎么真正执行第二阶段呢?需要在各个参与者内部启动一个异步任务,定期捞取业务数据库中未结束的分支事务记录,然后向TC检查整个分布式事务的状态,即图中的StateCheckRequest请求。TC在收到这个请求后,会根据之前保存的分布式事务的状态,告诉参与者是提交还是回滚,从而完成分支事务记录。

那这样做有什么好处呢?

左边是采用同库模式前的调用关系图,在每次调用一个参与者的时候,都是先向TC注册一个分布式事务记录,TC再持久化存储在自己的数据库中,也就是说,一个分支事务记录的注册,包含一次RPC和一次持久化存储。

右边是优化后的调用关系图。从图中可以看出,每次调用一个参与者的时候,都是直接保存在业务的数据库中,从而减少与TC之间的RPC调用。优化后,有多少个参与者,就节约多少次RPC调用。

这就是同库模式的性能方案。把分支事务记录保存在业务数据库中,从而减少与TC的RPC调用。

异步化

另外一个性能优化方式就是异步化,什么是异步化。TCC模型的一个作用就是把两阶段拆分成了两个独立的阶段,通过资源业务锁定的方式进行关联。资源业务锁定方式的好处在于,既不会阻塞其他事务在第一阶段对于相同资源的继续使用,也不会影响本事务第二阶段的正确执行。从理论上来说,只要业务允许,事务的第二阶段什么时候执行都可以,反正资源已经业务锁定,不会有其他事务动用该事务锁定的资源。

假设只有一个中间账户的情况下,每次调用支付服务的Commit接口,都会锁定中间账户,中间账户存在热点性能问题。

但是,在担保交易场景中,七天以后才需要将资金从中间账户划拨给商户,中间账户并不需要对外展示。因此,在执行完支付服务的第一阶段后,就可以认为本次交易的支付环节已经完成,并向用户和商户返回支付成功的结果,并不需要马上执行支付服务二阶段的Commit接口,等到低峰期时,再慢慢消化,异步地执行。

2.3 Saga模式

Saga模式是Seata即将开源的长事务解决方案。在Saga模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。

分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会去退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。

Saga正向服务与补偿服务也需要业务开发者实现。有点像是TCC模式将Try过程和Confirm过程合并,所有参与者直接执行Try + Confirm,如果有人失败了,就反向依次Cancel。

由于该模式主要用于长事务场景,所以通常是由事件驱动的,各个参与者之间是异步执行的。

Saga模式适用于业务流程长且需要保证事务最终一致性的业务系统,Saga模式一阶段就会提交本地事务,无锁、长流程情况下可以保证性能。

事务参与者可能是其它公司的服务或者是遗留系统的服务,无法进行改造和提供TCC要求的接口,可以使用Saga模式。

Saga模式的优势是:

  • 一阶段提交本地数据库事务,无锁,高性能;
  • 参与者可以采用事务驱动异步执行,高吞吐;
  • 补偿服务即正向服务的“反向”,易于理解,易于实现;

缺点:Saga模式由于一阶段已经提交本地数据库事务,且没有进行“预留”动作,所以不能保证隔离性。

Seata目前是采用事件驱动的机制来实现的,Seata实现了一个状态机,可以编排服务的调用流程及正向服务的补偿服务,生成一个json文件定义的状态图,状态机引擎驱动业务的运行,当发生异常的时候状态机触发回滚,逐个执行补偿服务。当然在什么情况下触发回滚用户是可以自定义决定的。

由于它基于事件驱动架构,每个步骤都是异步执行的,步骤与步骤之间通过事件队列流转,极大的提高系统吞吐量。每个步骤执行时会记录事务日志,用于出现异常时回滚时使用,事务日志会记录在与业务表数据库内,提高性能。

和TCC模式一样Saga也会出现空回滚、悬挂、幂等的问题,这些都可以参考TCC中介绍的方案进行。

前面讲到Saga模式不保证事务的隔离性,在极端情况下可能出现脏写。比如在分布式事务未提交的情况下,前一个服务的数据被修改了,而后面的服务发生了异常需要进行回滚,可能由于前面服务的数据被修改后无法进行补偿操作。这时的一种处理办法可以是“重试”继续往前完成这个分布式事务。由于整个业务流程是由状态机编排的,即使是事后恢复也可以继续往前重试。所以用户可以根据业务特点配置该流程的事务处理策略是优先“回滚”还是“重试”,当事务超时的时候,Server端会根据这个策略不断进行重试。

此外,我们在业务设计的时候需要做到“宁可长款,不可短款”的原则,长款是指在出现差错的时候站在我方的角度钱多了的情况,钱少了则是短款,因为如果长款可以给客户退款,而短款则可能钱追不回来了,也就是说在业务设计的时候,一定是先扣客户帐再入帐,如果因为隔离性问题造成覆盖更新,也不会出现钱少了的情况。

2.4 XA模式

XA模式是Seata将来会开源的另一种无侵入的分布式事务解决方案,任何实现了XA协议的数据库都可以作为资源参与到分布式事务中,目前主流数据库,例如MySql、Oracle、DB2、Oceanbase等均支持XA协议。

XA协议有一系列的指令,分别对应一阶段和二阶段操作。“xa start”和“xa end”用于开启和结束XA事务;“xa prepare”用于预提交XA事务,对应一阶段准备;“xa commit”和“xa rollback”用于提交、回滚XA事务,对应二阶段提交和回滚。

在XA模式下,每一个XA事务都是一个事务参与者。分布式事务开启之后,首先在一阶段执行“xa start”、“业务SQL”、“xa end”和 “xa prepare”完成XA事务的执行和预提交;二阶段如果提交的话就执行“xa commit”,如果是回滚则执行“xa rollback”。这样便能保证所有XA事务都提交或者都回滚。

XA模式下,用户只需关注自己的“业务SQL”,Seata框架会自动生成一阶段、二阶段操作;XA模式的实现如下:

  • 一阶段:在XA模式的一阶段,Seata会拦截“业务SQL”,在“业务SQL”之前开启XA事务(“xa start”),然后执行“业务SQL”,结束XA事务“xa end”,最后预提交XA事务(“xa prepare”),这样便完成“业务SQL”的准备操作。
  • 二阶段提交:执行“xa commit”指令,提交XA事务,此时“业务SQL”才算真正的提交至数据库。
  • 二阶段回滚:执行“xa rollback”指令,回滚XA事务,完成“业务SQL”回滚,释放数据库锁资源。

XA模式下,用户只需关注“业务SQL”,Seata会自动生成一阶段、二阶段提交和二阶段回滚操作。XA模式和AT模式一样是一种对业务无侵入性的解决方案;但与AT模式不同的是,XA模式将快照数据和行锁等通过XA指令委托给了数据库来完成,这样XA模式实现更加轻量化。

2.5 MT模式

在之前的版本中,Seata中还有一个MT模式,它的一个重要作用就是,可以把非关系型数据库的资源,通过MT模式分支的包装,纳入到全局事务的管辖中来。比如,Redis、HBase、RocketMQ的事务消息等。

它的设计和2PC的风格类似,用户需要实现自己的MT接口:

  • 一阶段prepare行为:调用自定义的prepare逻辑。
  • 二阶段commit行为:调用自定义的commit逻辑。
  • 二阶段rollback行为:调用自定义的rollback逻辑。

所谓MT模式,是指支持把自定义的分支事务纳入到全局事务的管理中。不过该模式已经被删除了,我觉得它所做的工作通过TCC模式完全能够做到。

事务隔离

Seata的设计建立在一个共识上:绝大部分应用在读已提交的隔离级别下工作是没有问题的。而实际上,这当中又有绝大多数的应用场景,实际上工作在读未提交的隔离级别下同样没有问题。

纵观Seata提供的所有分支事务模式,除了AT模式和XA模式可以运行在读已提交的隔离级别下,其他模式都是运行在读未提交的级别下。在有必要时,应用需要通过业务逻辑的巧妙设定,来解决分布式事务隔离级别带来的问题,就像我们在TCC模式中介绍的例子。

AT模式

AT模式前面我们已经介绍过,RM在一阶段会向TC申请数据的主键锁,锁的结构是:resourceId + tableName + rowPK,在数据库本地隔离级别读已提交或以上的前提下,AT模式通过全局写排他锁,来保证事务间的写隔离,将全局事务默认定义在读未提交的隔离级别上,全局事务读未提交,并不是说本地事务的db数据没有正常提交,而是指全局事务二阶段commit | rollback未真正处理完(即未释放全局锁),而且这时候其他事务会读到一阶段提交的内容。

默认情况下,AT是工作在读未提交的隔离级别下,保证绝大多数场景的高效性。有些应用如果需要达到全局的读已提交,AT也提供了相应的机制来达到目的,那就是select for update + @GlobalLock,当执行该命令时RM会去TC确认该锁是否由他人占有,这样如果有一个分布式事务T1正在进行中时,另一个事务T2会因为发现锁冲突而阻塞后续代码的执行,当前面的分布式事务T1结束时,释放了相应的资源锁,T2才能读取到相应的数据,这样就达到读已提交的效果。

这里大家可能会有点疑问,因为AT模式下,TC是先放锁,再执行各个RM的Branch Commit过程,这是不是会出现select for update + @GlobalLock 的脏读啊? 答案是:不会。我们看Branch Commit过程,它实际上做的只是异步删除Undo log,真正执行的 SQL 在第一阶段就已经执行完了。而回滚时,是每执行完一个分支事务,再释放该分支事务的锁,这时候会读到全局事务开始之前的内容,也不会出现脏读。

XA模式

这里我们以MySQL为例,说一说XA的隔离级别问题。先看一下MySQL XA对本地隔离级别的要求:

However, for a distributed transaction, you must use the SERIALIZABLE isolation level to achieve ACID properties. It is enough to use REPEATABLE READ for a nondistributed transaction, but not for a distributed transaction

只有在Serializable隔离级别下,XA事务才能够避免脏读的。因为在Serializable隔离级别下,所有读操作都会施加排它锁,而在全局事务提交后,才会释放该锁。在分布式事务中,虽然不可能做到所有XA数据库同时提交本地事务,但是在一个分布式事务T1进行中,其他事务TN不可能读到T1的中间状态,它们只会以一定的顺序(因为锁阻塞)看到T1开始前的状态,或者T1结束后的状态,这就避免了脏读。

事务传播

XID是一个全局事务的唯一标识,事务传播机制要做的就是把XID在服务调用链路中传递下去,并绑定到服务的事务上下文中,这样,服务链路中的数据库更新操作,就都会向该XID代表的全局事务注册分支,纳入同一个全局事务的管辖。

基于这个机制,Seata是可以支持任何微服务RPC框架的。只要在特定框架中找到可以透明传播XID的机制即可,比如,Dubbo的Filter + RpcContext。

对于Java EE规范和Spring定义的事务传播属性,Seata的支持如下:

  1. PROPAGATION_REQUIRED:默认的spring事务传播级别,使用该级别的特点是,如果上下文中已经存在事务,那么就加入到事务中执行,如果当前上下文中不存在事务,则新建事务执行。

    • Seata的默认模式,在需要新建事务的所有地方使用@GlobalTransactional
  2. PROPAGATION_SUPPORTS:如果上下文存在事务,则支持事务加入事务,如果没有事务,则使用非事务的方式执行。

    • Seata的默认模式,只在最外层业务函数加@GlobalTransactional,中间层的时候不加该注解,它就不会注册新事务
  3. PROPAGATION_MANDATORY:该级别的事务要求上下文中必须要存在事务,否则就会抛出异常

    • 业务方可以通过调用静态函数RootContext#getXID查看是否处于事务中,如果发现不在事务中,则自己抛出异常
  4. PROPAGATION_REQUIRES_NEW:每次都会新建一个事务,并且同时将上下文中的事务挂起,执行当前新建事务完成以后(不关心成功与失败),上下文事务恢复再执行。

    • 定义一个壳函数,在壳函数中,先通过RootContext#getXID获取当前所处事务的XID,暂存起来,然后清除RootContext中的XID,接下来调用自己的实际函数,该实际函数需要打上GlobalTransactional注解和Spring的事务注解,spring的事务注解需要明确标识执行在PROPAGATION_REQUIRES_NEW传播级别下,实际函数执行结束后,在壳函数中要catch住实际函数的所有异常,最后将暂存的XID恢复进RootContext
  5. PROPAGATION_NOT_SUPPORTED:上下文中存在事务,则挂起事务,执行当前逻辑,结束后恢复上下文的事务。

    • 通过RootContext#getXID发现处于事务中,则catch住自己的所有异常
  6. PROPAGATION_NEVER:上下文中不能存在事务,一旦有事务,就抛出runtime异常,强制停止执行

    • 通过RootContext#getXID发现处于事务中,则抛出异常
  7. PROPAGATION_NESTED:如果上下文中存在事务,则嵌套事务执行,如果不存在事务,则新建事务。嵌套是子事务套在父事务中执行,子事务是父事务的一部分,在进入子事务之前,父事务建立一个回滚点,叫savepoint,然后执行子事务,这个子事务的执行也算是父事务的一部分,然后子事务执行结束,父事务继续执行。如果子事务回滚,父事务会回滚到进入子事务前建立的savepoint,然后尝试其他的事务或者其他的业务逻辑,父事务之前的操作不会受到影响,更不会自动回滚。如果父事务回滚,子事务是不会提交的,我们说子事务是父事务的一部分,正是这个道理。事务的提交时,子事务是父事务的一部分,由父事务统一提交。

    • 目前不支持。子事务失败,父事务继续执行,这个可以实现,参考PROPAGATION_REQUIRES_NEW就行了,但是这里涉及到子事务结束时,并不直接提交,而是随父事务一起提交,这需要改Seata的源码了,要在TC中保存父子事务的绑定关系,然后子事务提交时,TC先判断一下当前事务是否有父事务,并且传播级别是否是PROPAGATION_NESTED,如果都满足则不立即进行该事务的提交只保存意向,在父事务提交时,判断其有没有嵌套子事务,如果有的话就按照其意向进行提交。

HA

目前Seata没有真正意义上的HA Cluster方案,但是有一个临时方案:TC的数据可以存储在DB中。这和默认的方案(存储在本地文件中)相比,性能会差一点,但是借助数据库的HA机制,TC确实也能集群化部署。

将来Seata的HA-Cluster设计可能会按如下思路进行:

  1. 客户端发布信息的时候根据transactionId保证同一个transaction是在同一个Seata Cluster上,通过多个Seata Cluster Master水平扩展,提供并发处理性能。
  2. 在server端中一个master有多个slave,master中的数据实时同步到slave上,保证当master宕机的时候,还能有其他slave顶上来可以用。

就目前的实现进度而言,上图中的Master和Slave可以理解为DB的Master和Slave,这些都是有现成的,而且Seata支持将数据存在DB中,并使用DB锁来实现TC中的资源锁。这就相当于多个TC节点绑定在一个DB集群上,构成一个TC集群对外开放服务。不过说不定以后也会采用一致性协议,如Paxos或Raft,自己实现Cluster方案。

posted @ 2022-04-25 16:32  夏尔_717  阅读(224)  评论(0编辑  收藏  举报