Seata 设计方案

引言

在深入介绍 Seata 的实现之前,我们先在一个较高的层面一览 Seata 的整体设计思想,其他 Seata 相关文章均收录于 <Seata系列文章>中。

设计方案

整体架构

首先,很自然的,我们可以把一个分布式事务理解成一个包含了若干分支事务的全局事务。全局事务的职责是协调其下管辖的分支事务达成一致,要么一起成功提交,要么一起失败回滚。此外,通常分支事务本身就是一个满足 ACID 的本地事务。
branch-transaction
基于两阶段提交模式,从设计上我们可以将整体分成三个大模块,即TM、RM、TC,具体解释如下:

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

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

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

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

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

与 XA 的区别

我们先来看看 XA 协议的 2PC 过程:
xa-lock
无论 Phase2 的决议是 commit 还是 rollback,事务性资源的锁都要保持到 Phase2 完成才释放。

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

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

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

分支事务

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

AT

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

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

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

一阶段

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

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

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

二阶段
  • 如果 TM 决议是全局提交,此时分支事务实际上已经完成提交,TC 立刻释放该全局事务的所有锁,然后异步调用 RM 清理回滚日志,Phase2 可以非常快速地完成。
    at-commit
  • 如果决议是全局回滚,RM 收到协调器发来的回滚请求,通过 XID 和 Branch ID 找到相应的回滚日志记录,通过回滚记录生成反向的更新 SQL 并执行,以完成分支的回滚。当分支回滚顺利结束时,通知 TC 回滚完成,这时候 TC 才释放该分支事务相关的所有锁。
    at-rollback

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

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

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

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

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

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

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

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

TCC

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

业务模型

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

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

我们先来看扣钱的 TCC 资源怎么实现。场景为 A 转账 30 元给 B。账户 A 的余额中有 100 元,需要扣除其中 30 元。这里的余额就是所谓的业务资源,按照前面提到的原则,在第一阶段需要检查并预留业务资源,因此,我们在扣钱 TCC 资源的 Try 接口里先检查 A 账户余额是否足够,然后预留余额里的业务资源,即扣除 30 元。
business-model1
在 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 操作中,需要先利用数据库资源层面的加锁,检查账户可用余额,如果余额充足,则预留业务资源,扣除本次交易金额,一阶段结束后,虽然数据库层面资源锁被释放了,但这笔资金被业务隔离,不允许除本事务之外的其它并发事务动用。
business-model2
并发的事务 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 模型的设计思想。在实际中,为了更好的用户体验,在第一阶段,一般不会直接把账户的余额扣除,而是冻结,这样给用户展示的时候,就可以很清晰的知道,哪些是可用余额,哪些是冻结金额。

那业务模型变成什么样了呢?如图所示,需要在业务模型中增加冻结金额字段,用来表示账户有多少金额处以冻结状态。
business-model3
既然业务模型发生了变化,那扣钱和加钱的 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 接口,从而形成空回滚。
cancel-before-try
那会不会有空提交呢?理论上来说不会的,如果调用方宕机,那分布式事务默认是回滚的。如果是网络异常,那 RPC 调用失败,发起方应该通知 TC 回滚分布式事务,这里可以看出为什么是理论上的,就是说发起方可以在 RPC 调用失败的情况下依然通知 TC 提交,这时就会发生空提交,这种情况要么是编码问题,要么开发同学明确知道需要这样做。

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

幂等

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

什么样的情形会造成重复提交或回滚?从图中可以看到,提交或回滚是一次 TC 到参与者的网络调用。因此,网络故障、参与者宕机等都有可能造成参与者 TCC 资源实际执行了二阶段防范,但是 TC 没有收到返回结果的情况,这时,TC 就会重复调用,直至调用成功,整个分布式事务结束。
confirm-two-times
怎么解决重复执行的幂等问题呢?一个简单的思路就是记录每个分支事务的执行状态。在执行前状态,如果已执行,那就不再执行;否则,正常执行。前面在讲空回滚的时候,已经有一张事务控制表了,事务控制表的每条记录关联一个分支事务,那我们完全可以在这张事务控制表上加一个状态字段,用来记录每个分支事务的执行状态。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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 方法的逻辑可以如图所示:
exception-control-tcc
先插入事务控制表记录,如果插入成功,说明第二阶段还没有执行,可以继续执行第一阶段。如果插入失败,则说明第二阶段已经执行或正在执行,则抛出异常,终止即可。

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

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

通过这一部分的讲解,大家应该对 TCC 模型下最常见的三类异常 Case,空回滚、幂等、悬挂的成因有所了解,也从实际例子中知道了怎么解决这三类异常,在解决了这三类异常的情况下,我们的 TCC 接口设计就是比较完备的了。虽然现在 Seata 框架中上述方案尚未实装,但是以后一定会实装的,到那时会由 Seata 框架来完成异常的处理,开发 TCC 接口的同学就不再需要关心了。

性能再优化

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

同库模式

第一个优化方案是改为同库模式。同库模式简单来说,就是分支事务记录与业务数据在相同的库中。什么意思呢?之前提到,在注册分支事务记录的时候,框架的调用方切面会先向 TC 注册一个分支事务记录,注册成功后,才会继续往下执行 RPC 调用。TC 在收到分支事务记录注册请求后,会往自己的数据库里插入一条分支事务记录,从而保证事务数据的持久化存储。那同库模式就是调用方切面不再向 TC 注册了,而是直接往业务的数据库里插入一条事务记录。
same-db
在讲解同库模式的性能优化点之前,先给大家简单讲讲同库模式的恢复逻辑。一个分布式事务的提交或回滚还是由发起方通知 TC,但是由于分支事务记录保存在业务数据库,而不是 TC 端。因此,TC 不知道有哪些分支事务记录,在收到提交或回滚的通知后,仅仅是记录一下该分布式事务的状态。那分支事务记录怎么真正执行第二阶段呢?需要在各个参与者内部启动一个异步任务,定期捞取业务数据库中未结束的分支事务记录,然后向 TC 检查整个分布式事务的状态,即图中的 StateCheckRequest 请求。TC 在收到这个请求后,会根据之前保存的分布式事务的状态,告诉参与者是提交还是回滚,从而完成分支事务记录。
same-db-advantage
那这样做有什么好处呢?左边是采用同库模式前的调用关系图,在每次调用一个参与者的时候,都是先向 TC 注册一个分布式事务记录,TC 再持久化存储在自己的数据库中,也就是说,一个分支事务记录的注册,包含一次 RPC 和一次持久化存储。

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

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

异步化

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

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

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

Saga

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

分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会去退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。
saga
Saga 正向服务与补偿服务也需要业务开发者实现。有点像是 TCC 模式将 Try 过程和 Confirm 过程合并,所有参与者直接执行 Try + Confirm,如果有人失败了,就反向依次 Cancel。

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

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

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

Saga模式的优势是:

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

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

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

由于它基于事件驱动架构,每个步骤都是异步执行的,步骤与步骤之间通过事件队列流转,极大的提高系统吞吐量。每个步骤执行时会记录事务日志,用于出现异常时回滚时使用,事务日志会记录在与业务表数据库内,提高性能。
sage-process
和 TCC 模式一样 Saga 也会出现空回滚、悬挂、幂等的问题,这些都可以参考 TCC 中介绍的方案进行。

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

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

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
XA 模式下,用户只需关注自己的“业务 SQL”,Seata 框架会自动生成一阶段、二阶段操作;XA 模式的实现如下:
xa-process

  • 一阶段:在 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 模式实现更加轻量化。

MT

在之前的版本中,Seata 中还有一个 MT 模式,它的一个重要作用就是,可以把非关系型数据库的资源,通过 MT 模式分支的包装,纳入到全局事务的管辖中来。比如,Redis、HBase、RocketMQ 的事务消息等。
mt
它的设计和 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:如果上下文中存在事务,则嵌套事务执行,如果不存在事务,则新建事务。嵌套是子事务套在父事务中执行,子事务是父事务的一部分,在进入子事务之前,父事务建立一个回滚点,叫save point,然后执行子事务,这个子事务的执行也算是父事务的一部分,然后子事务执行结束,父事务继续执行。如果子事务回滚,父事务会回滚到进入子事务前建立的save point,然后尝试其他的事务或者其他的业务逻辑,父事务之前的操作不会受到影响,更不会自动回滚。如果父事务回滚,子事务是不会提交的,我们说子事务是父事务的一部分,正是这个道理。事务的提交时,子事务是父事务的一部分,由父事务统一提交。
    • 目前不支持。子事务失败,父事务继续执行,这个可以实现,参考 PROPAGATION_REQUIRES_NEW 就行了,但是这里涉及到子事务结束时,并不直接提交,而是随父事务一起提交,这需要改 Seata 的源码了,要在 TC 中保存父子事务的绑定关系,然后子事务提交时,TC 先判断一下当前事务是否有父事务, 并且传播级别是否是PROPAGATION_NESTED, 如果都满足则不立即进行该事务的提交只保存意向, 在父事务提交时, 判断其有没有嵌套子事务, 如果有的话就按照其意向进行提交。

HA

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

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

  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 方案。

参考内容

[1] fescar锁设计和隔离级别的理解
[2] 分布式事务中间件 Fescar - RM 模块源码解读
[3] Fescar分布式事务实现原理解析探秘
[4] Seata TCC 分布式事务源码分析
[5] 深度剖析一站式分布式事务方案 Seata-Server
[6] 分布式事务 Seata Saga 模式首秀以及三种模式详解
[7] 蚂蚁金服大规模分布式事务实践和开源详解
[8] 分布式事务 Seata TCC 模式深度解析
[9] Fescar (Seata)0.4.0 中文文档教程
[10] Seata Github Wiki
[11] 深度剖析一站式分布式事务方案Seata(Fescar)-Server

贝克街的流浪猫 wechat
您的打赏将鼓励我继续分享!
  • 本文作者: 贝克街的流浪猫
  • 本文链接: https://www.beikejiedeliulangmao.top/middleware/seata/design/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
  • 创作声明: 本文基于上述所有参考内容进行创作,其中可能涉及复制、修改或者转换,图片均来自网络,如有侵权请联系我,我会第一时间进行删除。