随着业务的快速发展、业务复杂度越来越高,几乎每个公司的系统都会从单体走向分布式,特别是转向微服务架构。随之而来就必然遇到分布式事务这个难题,这篇文章通过seata框架总结了分布式事务的几种解决方案
关系型数据库具有解决复杂事务场景的能力,关系型数据库的事务满足 ACID 的特性。
CAP 是指在一个分布式系统下, 包含三个要素:Consistency(一致性)、Availability(可用性)、Partition tolerance(分区容错性),并且三者不可得兼。
BASE 理论主要是解决 CAP 理论中分布式系统的可用性和一致性不可兼得的问题。BASE 理论包含以下三个要素:
第一阶段(准备阶段)
TM 通知所有参与事务的各个 RM,给每个 RM 发送 prepare 消息。
RM 接收到消息后进入准备阶段后,要么直接返回失败,要么创建并执行本地事务,写本地事务日志(redo 和 undo 日志),但是不提交(此处只保留最后一步耗时最少的提交操作给第二阶段执行)。
第二阶段(提交 / 回滚阶段)
Seata框架
基于两阶段提交模式,从设计上我们可以将整体分成三个大模块,即TM、RM、TC,具体解释如下:
一个典型的分布式事务过程:
在 XA 模式下,每一个 XA 事务都是一个事务参与者。分布式事务开启之后
首先在一阶段执行“xa start”、“业务 SQL”、“xa end”和 “xa prepare” 完成 XA 事务的执行和预提交;
二阶段如果提交的话就执行 “xa commit”,如果是回滚则执行“xa rollback”。这样便能保证所有 XA 事务都提交或者都回滚。
无论 Phase2 的决议是 commit 还是 rollback,事务性资源的锁都要保持到 Phase2 完成才释放。
一个正常运行的业务,大概率是 90% 以上的事务最终应该是成功提交的,我们是否可以在 Phase1 就将本地事务提交呢?这样 90% 以上的情况下,可以省去 Phase2 持锁的时间,整体提高效率。
分支事务中数据的本地锁由本地事务管理,在分支事务 Phase1 结束时释放,这时候其他本地事务就能读取到最新的数据。 - 同时,随着本地事务结束,连接也得以释放。 - 分支事务中数据的全局锁在事务协调器管理,在决议 Phase2 全局提交时,全局锁马上可以释放,注意这里是先释放锁,再进行分支事务的提交过程。只有在决议全局回滚的情况下,全局锁才被持有至分支的 Phase2 结束,即所有分支事务回滚结束。
这个设计,极大地减少了分支事务对资源(数据和连接)的锁定时间,给整体并发和吞吐的提升提供了基础。但是分布式事务的隔离级别变化了
XA 模式和 下面的AT 模式一样是一种对业务无侵入性的解决方案;但与 AT 模式不同的是,XA 模式将快照数据和行锁等通过 XA 指令委托给了数据库来完成,这样 XA 模式实现更加轻量化
AT 模式是一种无侵入的分布式事务解决方案。在 AT 模式下,用户只需关注自己的“业务 SQL”,用户的 “业务 SQL” 就是全局事务一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作。
一阶段
首先,应用要使用 Seata 的 JDBC 数据源代理,也就是前面提到的 RM 概念,所有对 DB 的操作都是通过 Seata RM 代理完成。在这层代理中,Seata 会自动控制 SQL 的执行,提交,回滚。
Seata代理会把业务数据在更新前后的数据镜像(beforeImage & afterImage)组织成回滚日志,利用本地事务的 ACID 特性,将业务数据的更新和回滚日志的写入在同一个本地事务中提交。这样,可以保证:任何提交的业务数据的更新一定有相应的回滚日志存在。
然后,本地事务在提交之前, 还需要通过 RM 向 TC 注册本地分支,这个注册过程中会根据刚才执行的 SQL 拿到所有涉及到的数据主键,以 resourceId + tableName + rowPK 作为锁的 key,向 TC 申请所有涉及数据的写锁,当获得所有相关数据的写锁后,再执行本地事务的 Commit 过程。如果有任何一行数据的写锁没有拿到的话,TC 会以 fastfail 的方式回复该 RM,RM 会以重试 + 超时机制重复该过程,直到超时。
完成本地事务后,RM 会向 TC 汇报本地事务的执行情况,并完成业务 RPC 的调用过程。
二阶段
case1:如果 TM 决议是全局提交,此时分支事务实际上已经完成提交,TC 立刻释放该全局事务的所有锁,然后异步调用 RM 清理回滚日志,Phase2 可以非常快速地完成。
case2:如果决议是全局回滚,RM 收到协调器发来的回滚请求,通过 XID 和 Branch ID 找到相应的回滚日志记录,通过回滚记录生成反向的更新 SQL 并执行,以完成分支的回滚。当分支回滚顺利结束时,通知 TC 回滚完成,这时候 TC 才释放该分支事务相关的所有锁。
注:RM 在进行回滚时,会先跟 afterImage 进行比较: - 如果一致:则执行逆向 SQL - 如果不一致: 再跟 beforeImage 进行比较 - 如果一致:说明没必要执行回滚 SQL 了,数据已经恢复了 - 如果不一致:说明出现了脏数据,这时候就抛出异常,需要人工处理
TCC 模式需要用户根据自己的业务场景实现 Try、Confirm 和 Cancel 三个操作;事务发起方先在 TC 中注册全局事务,然后在一阶段执行 Try 方法,在二阶段提交的话 TC 会去执行各个 RM 的 Confirm 方法,二阶段回滚则 TC 会去执行各个 RM 的 Cancel 方法。
与 AT 模式一样,Seata 会给实际方法的执行加切面,该切面会拦截所有对 TCC 接口的调用。在调用 Try 接口时,如果发现处在全局事务中,切面会先向 TC 注册一个分支事务,和 AT 不同的是TCC 注册分支事务是不加锁的,注册完成后去执行原来的 RPC 调用。当请求链路调用完成后,TC 通过分支事务的资源 ID 回调到正确的参与者去执行对应 TCC 资源的 Confirm 或 Cancel 方法。
TCC 模式的整体框架相对于 AT 来说更加简单,主要是扫描 TCC 接口,注册资源,拦截接口调用,注册分支事务,最后回调二阶段接口。最核心的实际上是 TCC 接口的实现逻辑。
从 TCC 模型的框架可以发现,TCC 模型的核心在于 TCC 接口的设计。用户在接入 TCC 时,大部分工作都集中在如何实现 TCC 服务上。这就是 TCC 模式最主要的问题,对业务侵入比较大,要花很大的功夫来实现 TCC 服务。
设计一套 TCC 接口最重要的是什么?主要有两点,第一点,需要将操作分成两阶段完成。TCC(Try-Confirm-Cancel)分布式事务模型相对于 XA 等传统模型,其特征在于它不依赖 RM 对分布式事务的支持,而是通过对业务逻辑的分解来实现分布式事务。
TCC 分布式事务模型需要业务系统提供三段业务逻辑: 1. 初步操作 Try:完成所有业务检查,预留必须的业务资源。 2. 确认操作 Confirm:真正执行的业务逻辑,不做任何业务检查,只使用 Try 阶段预留的业务资源。因此,只要 Try 操作成功,Confirm 必须能成功。另外,Confirm 操作需满足幂等性,保证一笔分布式事务能且只能成功一次。 3. 取消操作 Cancel:释放 Try 阶段预留的业务资源。同样的,Cancel 操作也需要满足幂等性。因此,TCC 模型的隔离性思想就是通过业务的改造,在第一阶段结束之后,从底层数据库资源层面的加锁过渡为上层业务层面的加锁,释放底层数据库锁资源,放宽分布式事务锁协议,将锁的粒度降到最低,以最大限度提高业务并发性能。
第二点,就是要根据自身的业务模型去控制并发,Seata 框架本身仅提供两阶段原子提交协议,保证分布式事务原子性。事务的隔离需要交给业务逻辑来实现。隔离的本质就是控制并发,防止并发事务操作相同资源而引起的结果错乱。例如:“账户 A 上有 100 元,事务 T1 要扣除其中的 30 元,事务 T2 也要扣除 30 元,出现并发”。在第一阶段 Try 操作中,需要先利用数据库资源层面的加锁,检查账户可用余额,如果余额充足,则预留业务资源加到各自的冻结里,扣除本次交易金额,一阶段结束后,虽然数据库层面资源锁被释放了,但这笔资金被业务隔离,不允许除本事务之外的其它并发事务动用。
空回滚
空回滚就是对于一个分布式事务,在没有调用 TCC 资源 Try 方法的情况下,调用了二阶段的 Cancel 方法,Cancel 方法需要识别出这是一个空回滚,然后直接返回成功。
Cancel 要识别出空回滚,直接返回成功。那关键就是要识别出这个空回滚。思路很简单就是需要知道一阶段是否执行,如果执行了,那就是正常回滚;如果没执行,那就是空回滚。因此,需要一张额外的事务控制表,其中有分布式事务 ID 和分支事务 ID,第一阶段 Try 方法里会插入一条记录,表示一阶段执行了。Cancel 接口里读取该记录,如果该记录存在,则正常回滚;如果该记录不存在,则是空回滚。
悬挂
悬挂就是对于一个分布式事务,其二阶段 Cancel 接口比 Try 接口先执行。因为允许空回滚的原因,Cancel 接口认为 Try 接口没执行,空回滚直接返回成功,对于 Seata 框架来说,认为分布式事务的二阶段接口已经执行成功,整个分布式事务就结束了。但是这之后 Try 方法才真正开始执行,预留业务资源,回想一下前面提到事务并发控制的业务加锁,对于一个 Try 方法预留的业务资源,只有该分布式事务才能使用,然而 Seata 框架认为该分布式事务已经结束,也就是说,当出现这种情况时,该分布式事务第一阶段预留的业务资源就再也没有人能够处理了。
比如在 RPC 调用时,先注册分支事务,再执行 RPC 调用,如果此时 RPC 调用的网络发生拥堵,通常 RPC 调用是有超时时间的,RPC 超时以后,发起方就会通知 TC 回滚该分布式事务,可能回滚完成后,RPC 请求才到达参与者,真正执行,从而造成悬挂。
幂等
幂等就是对于同一个分布式事务的同一个分支事务,重复去调用该分支事务的第二阶段接口,因此,要求 TCC 的二阶段 Confirm 和 Cancel 接口保证幂等,不会重复使用或者释放资源。如果幂等控制没有做好,很有可能导致资损等严重问题。
解决思路
Try 方法主要需要考虑两个问题,一个是 Try 方法需要能够告诉二阶段接口,已经预留业务资源成功。第二个是需要检查第二阶段是否已经执行完成,如果已完成,则不再执行
Confirm 方法。因为 Confirm 方法不允许空回滚,也就是说,Confirm 方法一定要在 Try 方法之后执行。因此,Confirm 方法只需要关注重复提交的问题。需要一张事务执行记录表,可以先锁定事务记录,如果事务记录为空,则说明是一个空提交,不允许,终止执行。如果事务记录不为空,则继续检查状态是否为初始化,如果是,则说明一阶段正确执行,那二阶段正常执行即可。如果状态是已提交,则认为是重复提交,直接返回成功即可;如果状态是已回滚,也是一个异常,一个已回滚的事务,不能重新提交,需要能够拦截到这种异常情况,并报警。
Cancel 方法。因为 Cancel 方法允许空回滚,并且要在先执行的情况下,让 Try 方法感知到 Cancel 已经执行,所以和 Confirm 方法略有不同。首先依然是锁定事务记录。如果事务记录为空,则认为 Try 方法还没执行,即是空回滚。空回滚的情况下,应该先插入一条事务记录,确保后续的 Try 方法不会再执行。如果插入成功,则说明 Try 方法还没有执行,空回滚继续执行。如果插入失败,则认为Try 方法正在执行,等待 TC 的重试即可。如果一开始读取事务记录不为空,则说明 Try 方法已经执行完毕,再检查状态是否为初始化,如果是,则还没有执行过其他二阶段方法,正常执行 Cancel 逻辑。如果状态为已回滚,则说明这是重复调用,允许幂等,直接返回成功即可。如果状态为已提交,则同样是一个异常,一个已提交的事务,不能再次回滚
Saga 模式是 Seata 即将开源的长事务解决方案。在 Saga 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。
分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会去退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。
Saga 正向服务与补偿服务也需要业务开发者实现。有点像是 TCC 模式将 Try 过程和 Confirm 过程合并,所有参与者直接执行 Try + Confirm,如果有人失败了,就反向依次 Cancel。
由于该模式主要用于长事务场景,所以通常是由事件驱动的,各个参与者之间是异步执行的。
Saga 模式适用于业务流程长且需要保证事务最终一致性的业务系统,Saga 模式一阶段就会提交本地事务,无锁、长流程情况下可以保证性能
Saga模式的优势是:
缺点:Saga 模式由于一阶段已经提交本地数据库事务,且没有进行“预留”动作,所以不能保证隔离性。
事务隔离
纵观 Seata 提供的所有分支事务模式, 除了 AT 模式和 XA 模式可以运行在读已提交的隔离级别下, 其他模式都是运行在读未提交的级别下。在有必要时,应用需要通过业务逻辑的巧妙设定,来解决分布式事务隔离级别带来的问题
AT 模式通过全局写排他锁,来保证事务间的写隔离,将全局事务默认定义在读未提交的隔离级别上,全局事务读未提交,并不是说本地事务的db数据没有正常提交,而是指全局事务二阶段commit | rollback未真正处理完(即未释放全局锁),而且这时候其他事务会读到一阶段提交的内容。
有些应用如果需要达到全局的读已提交,AT 也提供了相应的机制来达到目的,那就是 select for update + @GlobalLock, 当执行该命令时 RM 会去 TC 确认该锁是否由他人占有, 这样如果有一个分布式事务 T1 正在进行中时, 另一个事务 T2 会因为发现锁冲突而阻塞后续代码的执行, 当前面的分布式事务 T1 结束时, 释放了相应的资源锁, T2 才能读取到相应的数据, 这样就达到读已提交的效果
利用 MQ 组件实现的二阶段提交。此方案涉及 3 个模块:
上游应用将本地业务执行和消息发送绑定在同一个本地事务中,保证要么本地操作成功并发送 MQ 消息,要么两步操作都失败并回滚。
异常处理
上游异常
可靠消息服务定时监听消息的状态,如果存在状态为待确认并且超时的消息,则表示上游应用和可靠消息交互中的步骤 4 或者 5 出现异常。
下游异常
实际过程中,还需要引入人工干预功能。比如引入重发次数限制,超过重发次数限制的将消息修改为死亡消息,等待人工处理。
AT其实就是一个自实现的XA事务,其实可以知道,AT在sql支持上远不及XA模式,AT需要做sql解析背后的实现只能自己解决,目前只能靠社区的贡献者来提供解决方案,这是一个长期的关键性的问题,也有很多用户选择在AT模式上重写sql来获取AT模式的支持,sql支持上XA是完胜的。
AT模式是通过解析sql获取涉及的主键id,生成行锁。也就是AT模式的隔离靠的是全局锁来保证的,粒度细至行级。锁信息存储在seata server侧。XA的隔离级别是由本地数据库保证,锁存储在各个本地数据库中。由于XA模式一旦执行了prepare后,再也无法重入这个XA事务也无法跟其他XA事务共享锁,因为XA协议仅仅是通过XID来start一个事务,本身不存在分支事务的说法。也就是说他只管自己
通过上面的信息可以发现谁更底层,入侵性则更小,所以由数据库自身支持的XA模式来说,入侵性无疑最小,使用成本最低。 XA的RM实际是在数据库,而AT则是以中间件层部署在应用这一侧的,不依赖数据库本身的协议支持,这点对于微服务架构来说是至关重要的。应用层不需要为本地事务和分布式事务多类不同场景来适配多套不通的驱动
本质上seata框架支持了3大补偿事务模式,AT, TCC,Saga都是补偿型的。补偿型事务处理机制是构建在事务资源之上的,事务资源本身对分布式事务是无感知的,事务资源对分布式事务无感知存在一个根本性问题,就是无法做到真正的全局一致性。 比如一条库存记录,处在补偿型事务处理过程中,由100扣减为50,此时仓库管理员连接数据库查看就会查询到50,之后事务异常回滚,库存就会被补偿回滚为100,显然仓库管理员查询到的50就是脏数据。那XA的价值是什么,与补偿型事务不同,XA协议要求事务资源本身提供对规范和协议的支持。因为事务资源感知并参与分布式事务处理过程,所以事务资源可以保证从任意视角对数据的访问有效隔离,比如上面XA事务处理过程中,中间态的50是不会被查询到的(当然隔离级别要在读已提交以上),以此来满足全局一致性。
随着业务的快速发展、业务复杂度越来越高,几乎每个公司的系统都会从单体走向分布式,特别是转向微服务架构。随之而来就必然遇到分布式事务这个难题,这篇文章通过seata框架总结了分布式事务的几种解决方案。