1: 分布式事务简介
大多数场景下,我们的应用都只需要操作单一的数据库,这种情况下的事务称之为本地事务(LocalTransaction)。本地事务的ACID特性是数据库直接提供支持。本地事务应用架构如下所示:
但是在微服务架构中,完成某一个业务功能可能需要横跨多个服务,操作多个数据库。这就涉及到到了分布式事务,需要操作的资源位于多个资源服务器上,而应用需要保证对于多个资源服务器的数据操作,要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同资源服务器的数据一致性。
1.1: 跨库事务
跨库事务指的是,一个应用某个功能需要操作多个库,不同的库中存储不同的业务数据。下图演示了一个服务同时操作2个库的情况:
1.2: 分库分表
通常一个库数据量比较大或者预期未来的数据量比较大,都会进行分库分表。如下图,将数据库B拆分成了2个库:
对于分库分表的情况,一般开发人员都会使用一些数据库中间件来降低sql操作的复杂性。如,对于sql:insert into user (id,name) values (1,"张三"),(2,"李四")
。这条sql是操作单库的语法,单库情况下,可以保证事务的一致性。但是由于现在进行了分库分表,开发人员希望将1号记录插入分库1,2号记录插入分库2。所以数据库中间件要将其改写为2条sql,分别插入两个不同的分库,此时要保证两个库要不都成功,要不都失败,因此基本上所有的数据库中间件都面临着分布式事务的问题。
1.3: 微服务架构
下图演示了一个3个服务之间彼此调用的微服务架构:
ServiceA完成某个功能需要直接操作数据库,同时需要调用ServiceB和ServiceC,而ServiceB又同时操作了2个数据库,ServiceC也操作了一个库。需要保证这些跨服务调用对多个数据库的操作要么都成功,要么都失败,实际上这可能是最典型的分布式事务场景。
1.4: 小结
上述讨论的分布式事务场景中,无一例外的都直接或者间接的操作了多个数据库。如何保证事务的ACID特性,对于分布式事务实现方案而言,是非常大的挑战。同时,分布式事务实现方案还必须要考虑性能的问题,如果为了严格保证ACID特性,导致性能严重下降,那么对于一些要求快速响应的业务,是无法接受的。
2: 分布式事务解决方案种类
解决分布式问题是一个很复杂的问题,对于不同的业务场景,对业务的一致性要求和高并发要求的权衡, 都需要精心选用不同模式的实现方案.
分布式事务实现方案从类型上去分刚性事务、柔型事务:
- 刚性事务满足CAP的CP理论
- 柔性事务满足BASE理论(基本可用,最终一致,即AP)
其中刚性事务实现主要有 2PC模式,XA协议, 3PC, Seata AT 模式
柔性事务主要有, TCC ,Saga, 可靠消息最终一致, 最大努力通知等,
放一张网络上的图
3: 刚性事务
3.1: 2PC
两阶段提交(TwoPhaseCommit),就是将提交(commit)过程划分为2个阶段(Phase),但是在介绍两个阶段之前,首先要知道,在2PC事务中的两个角色
分别为:
- 协调者角色(事务管理器TM)
- 参与者角色(资源管理器RM)
TM 负责收集各个参与者反馈的状态, 并统筹整体事务,向各个参与者发送指令,做出提交或者回滚决策
RM 接收协调者的指令执行事务操作,向协调者反馈操作结果,并继续执行协调者发送的最终指令
举例 :在学校中, 同学A和同学B一起到校门口集合,由于两同学间没有联系, 所以只能靠老师依次联系,要求其到校门口集合, 在这件事中需要两同学要不都来, 要不都不来
准备阶段 :同学A先到,在这里等待,其次同学B后带,人到齐
提交阶段 :老师宣布到齐,集体出发
例子中形成两一个事务,若同学A或同学B有一个因为有事没来, 这件事就办不成,只能让已经来的同学先回班级 。
整个事务过程由事务管理器和参与者组成,老师就是事务管理器,两同学就是事务参与者,事务管理器负责决策整个分布式事务的提交和回滚,事务参与者负责自己本地事务的提交和回滚。
下面再看一下两个阶段:
阶段1(准备阶段):
TM通知各个RM准备提交它们的事务分支。如果RM判断自己进行的工作可以被提交,那就对工作内容进行持久化,再给TM肯定答复;要是发生了其他情况,那给TM的都是否定答复。
以mysql数据库为例,在第一阶段,事务管理器向所有涉及到的数据库服务器发出prepare"准备提交"请求,数据库收到请求后执行数据修改和日志记录等处理,处理完成后只是把事务的状态改成"可以提交",然后把结果返回给事务管理器。
阶段2(提交阶段)
TM根据阶段1各个RM prepare的结果,决定是提交还是回滚事务。如果所有的RM都prepare成功,那么TM通知所有的RM进行提交;如果有RM prepare失败的话,则TM通知所有RM回滚自己的事务分支。
以mysql数据库为例,如果第一阶段中所有数据库都prepare成功,那么事务管理器向数据库服务器发出"确认提交"请求,数据库服务器把事务的"可以提交"状态改为"提交完成"状态,然后返回应答。如果在第一阶段内有任何一个数据库的操作发生了错误,或者事务管理器收不到某个数据库的回应,则认为事务失败,回撤所有数据库的事务。数据库服务器收不到第二阶段的确认提交请求,也会把"可以提交"的事务回撤。
两阶段提交方案下全局事务的ACID特性,是依赖于RM的。一个全局事务内部包含了多个独立的事务分支,这一组事务分支要么都成功,要么都失败。各个事务分支的ACID特性共同构成了全局事务的ACID特性。也就是将单个事务分支支持的ACID特性提升一个层次到分布式事务的范畴。
都成功的示意图:
有失败时的示意图:
两阶段提交存在的问题:
- 同步阻塞问题 2PC中的参与者是阻塞的。在第一阶段收到请求后就会预先锁定资源,一直到commit后才会释放。(如例子中,两同学在收到是集合出发,还是全部回家之前,都需要为去门口集合做准备,不能随便乱跑,别人也让他两干不了其他事)
- 单点故障 由于协调者的重要性,一旦协调者TM发生故障,参与者RM会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(例子中,如果老师没来,先来的同学会一直在门口等着, 或者某一个同学迟迟没有消息,既没有跟老师说不来,也没有跟老师说来, 将也会导致先来的同学一致等待)
- 数据不一致, 若协调者第二阶段发送提交请求时崩溃,可能部分参与者收到commit请求提交了事务,而另一部分参与者未收到commit请求而放弃事务,从而造成数据不一致的问题。(如果老师宣布出发时,有一个同学没听见, 或者分神了,都将会导致没有集体出发)
3.2: XA 协议
2PC的传统方案是在数据库层面实现的,如Oracle、MySQL都支持2PC协议,为了统一标准减少行业内不必要的对接成本,需要制定标准化的处理模型及接口标准,国际开放标准组织Open Group定义分布式事务处理模型DTP(Distributed Transaction Processing Reference Model)。即 TM和RM之间通信的协议定义,即接口的定义, 若各开发商需要实现XA协议的2PC模式, 需要对XA接口进行实现
一般我们常用的 数据库默认都 实现了 XA接口, 例如Mysql等等 . 例如Seata 分布式框架的XA模式,即是将默认支持XA规范的数据源做一层封装而已,使用起来更简单
3.3: 3PC
三阶段提交协议(3PC)主要是为了解决两阶段提交协议的阻塞问题,2pc存在的问题是当协作者崩溃时,参与者不能做出最后的选择。因此参与者可能在协作者恢复之前保持阻塞。三阶段提交(Three-phase commit),是二阶段提交(2PC)的改进版本。目的就是解决一阶段中的阻塞问题,或者说是部分阻塞
所谓的三个阶段分别是:询问,然后再锁资源,最后真正提交。
可以理解为 在二阶段之前 添加了 询问操作,
- 第一阶段:CanCommit
- 第二阶段:PreCommit
- 第三阶段:Do Commit
阶段一:CanCommit
- 事务询问。协调者向所有参与者发送包含事务内容的canCommit的请求,询问是否可以执行事务提交,并等待应答;
- 各参与者反馈事务询问。正常情况下,如果参与者认为可以顺利执行事务,则返回Yes,否则返回No。
经过这一轮询问下来,保证了所有节点此时都是畅通的, 并且资源充足等等,并为后面做好了准备, 至少避免了99% 的事务异常的情况导致的阻塞,使异常提前发生,避免了在有些事务已经预提交后再发生问题, 也使得后面的行为更加的大胆
阶段二:PreCommit
在本阶段,协调者会根据上一阶段的反馈情况来决定是否可以执行事务的PreCommit操作。有以下两种可能
- 执行事务预提交(CanCommit全部返回YES)
- 中断事务 (任意一个NO)
执行事务预提交
- 发送预提交请求。协调者向所有节点发出PreCommit请求,并进入prepared阶段;
- 事务预提交。参与者收到PreCommit请求后,会开始事务操作,并将Undo和Redo日志写入本机事务日志;
- 各参与者成功执行事务操作,同时将反馈以Ack响应形式发送给协调者,同事等待三阶段的最终的Commit或Abort指令。
中断事务
如果任意一个参与者向协调者发送No响应,或者等待超时,协调者在没有得到所有参与者响应时,即可以中断事务。
中断事务的操作为:
- 发送中断请求。 协调者向所有参与者发送Abort请求;
- 中断事务。无论是participant 收到协调者的Abort请求,还是participant 等待协调者请求过程中出现超时,参与者都会中断事务;
阶段三:doCommit
在这个阶段,会真正的进行事务提交,同样存在两种可能。
- 执行提交
- 回滚事务
执行提交
- coordinator发送提交请求。假如coordinator协调者收到了所有参与者的Ack响应,那么将从预提交转换到提交状态,并向所有参与者,发送doCommit请求;
- 事务提交。参与者收到doCommit请求后,会正式执行事务提交操作,并在完成提交操作后释放占用资源;
- 反馈事务提交结果。参与者将在完成事务提交后,向协调者发送Ack消息;
- 完成事务。协调者接收到所有参与者的Ack消息后,完成事务。
回滚事务
在该阶段,假设正常状态的协调者接收到任一个参与者发送的No响应,或在超时时间内,仍旧没收到反馈消息,就会回滚事务:
- 发送中断请求。协调者向所有的参与者发送rollback请求;
- 事务回滚。参与者收到rollback请求后,会利用阶段二中的Undo消息执行事务回滚,并在完成回滚后释放占用资源;
- 反馈事务回滚结果。参与者在完成回滚后向协调者发送Ack消息;
- 回滚事务。协调者接收到所有参与者反馈的Ack消息后,完成事务回滚。
如何解决阻塞
在doCommit阶段,如果参与者无法及时接收到来自协调者的doCommit或者rollback请求时,会在等待超时之后,继续进行事务的提交。因为如果能进入第三阶段,那么第一个阶段所有节点都返回了YES,换句话说就是 :
当进入第三阶段时,由于网络超时/网络分区等原因,虽然参与者没有收到commit或者abort响应,但是他有理由相信:成功提交的几率很大。
所以理论上就是解决了2PC中, 因为超时或者协调者宕机,导致所以资源一直等待, 3PC则更加大胆, 超时直接提交
这个阻塞还是存在的,毕竟各个参与者还是会开启事务。那就存在一段时间,所有参与者都在事务中,还是会停止相应其他操作。
但是阻塞不会一直持续下去。在两阶段提交中,如果阻塞发生后协调者宕机,则阻塞会一直存在,无法解开;而三阶段提交中,即使协调者宕机,参与者也会自动提交事务进而解开阻塞。
3.4: Seata AT 模式
3.4.1: 概述
AT 模式是 Seata 创新的一种非侵入式的分布式事务解决方案,Seata 在内部做了对数据库操作的代理层,我们使用 Seata AT 模式时,实际上用的是 Seata 自带的数据源代理 DataSourceProxy,Seata 在这层代理中加入了很多逻辑,比如插入回滚 undo_log 日志,检查全局锁等。Seata AT 模式是标准2PC的演变,在此基础上进行优化
使用前提:
- 基于支持本地 ACID 事务的关系型数据库。
- Java 应用,通过 JDBC 访问数据库。
两阶段提交协议的演变:
- 一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
- 二阶段:
- 提交异步化,非常快速地完成。
- 回滚通过一阶段的回滚日志进行反向补偿。
3.4.2: 三个角色
在Seata AT的架构中,一共有三个角色:
-
TC(TransactionCoordinator)-事务协调者
维护全局和分支事务的状态,驱动全局事务提交或回滚。
-
TM(TransactionManager)-事务管理器
定义全局事务的范围:开始全局事务、提交或回滚全局事务。
-
RM(ResourceManager)-资源管理器
管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
其中,TC为单独部署的Server服务端,TM和RM为嵌入到应用中的Client客户端。
在Seata中,一个分布式事务的生命周期如下:
1.客户端A作为入口, 远程调用了客户端B和C的资源, 此时就在客户端A创建了一个TM, TM请求TC开启一个全局事务。TC会生成一个XID作为该全局事务的编号。XID会在微服务的调用链路中传播(例如使用OpenFeign调用时,会拦截进行添加header),保证将多个微服务的子事务关联在一起。
2.被调用的链路中的RM会请求TC将本地事务注册为全局事务的分支事务,通过全局事务的XID进行关联。
3.当客户端A调用完毕,并且B,C都没有报错,执行到客户端A方法的尾部, 那么处于客户端A中的TM会请求TC告诉XID对应的全局事务是进行提交, 如果有报错则全部通知回滚。
4.TC驱动RM们将XID对应的自己的本地事务进行提交还是回滚。
如下图:
3.4.3: 两个阶段
以一个示例来说明整个 AT 分支的工作过程。
如下product表
Field | Type | Key |
---|---|---|
id | bigint(20) | PRI |
name | varchar(100) | |
since | varchar(100) |
某分支执行如下sql:
update product set name = 'GTS' where name = 'TXC';
一阶段
- 解析 SQL:得到 SQL 的类型(UPDATE),表(product),条件(where name = 'TXC')等相关的信息。
- 查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据。(修改前的数据 )
- 执行业务 SQL:更新这条记录的 name 为 'GTS'。
- 查询后镜像:根据前镜像的结果,通过 主键 定位数据
- 插入回滚日志:把前后镜像数据以及业务 SQL 相关的信息组成一条回滚日志记录,插入到
UNDO_LOG
表中。用作后续二阶段做准备 - 提交前,向 TC 注册分支:申请
product
表中,主键值等于 1 的记录的 全局锁 。 - 本地事务提交:业务数据的更新和前面步骤中生成的 UNDO LOG 一并提交。
- 将本地事务提交的结果上报给 TC。
如下示意图:
由此可以看出,Seata AT 模式和 传统2PC的根本区别在于, 一阶段中AT模式是将数据真正提交, 此时将会释放掉资源, 数据的回滚是靠记录的表数据进行, 而传统2PC 此处将一直保持连接, 直到全局事务的结束
二阶段-回滚:
- 收到 TC 的分支回滚请求,开启一个本地事务,执行如下操作。
- 通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录。
- 数据校验:拿 UNDO LOG 中的后镜与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作做了修改。这种情况,需要根据配置策略来做处理,详细的说明请自行查阅 Seata 官网。
- 根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句
- 提交本地事务。并把本地事务的执行结果(即分支事务回滚的结果)上报给 TC。
二阶段-提交:
- 收到 TC 的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC。
- 异步任务阶段的分支提交请求将异步和批量地删除相应 UNDO LOG 记录。
3.4.5: Seata AT模式存在的问题,以及解决方案
问题一:
通过上面的学习,我们知道Seata AT的一阶段是真实将数据库提交的, 那么对于这条记录,其他业务此时是可以对这条记录进行修改的, 但是我们知道,在二阶段中, 我们有可能将此记录回滚, 这时就出现了脏写的问题
写隔离: Seata 使用了两把锁解决此问题
- 一阶段本地事务提交前,需要确保先拿到 全局锁 。
- 拿不到 全局锁 ,不能提交本地事务。
- 拿 全局锁 的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。
以一个示例来说明:
两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。
tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的 全局锁 ,本地提交释放本地锁。 tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待 全局锁 。
tx1 二阶段全局提交,释放 全局锁 。tx2 拿到 全局锁 提交本地事务。
如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。
此时,如果 tx2 仍在等待该数据的 全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的 全局锁 等锁超时,放弃 全局锁 并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。(相当于人为的制造两锁之间的死锁, 然后是其中一方超时释放资源回滚, 另外一方一直重试,即可拿到锁)
因为整个过程 全局锁 在 tx1 结束前一直是被 tx1 持有的,所以不会发生 脏写 的问题。
本地锁的目的是为了在后面的事务读到的是前事务提交后的数据, 例如在本例中, tx1在修改1000- 100 = 900 并且没有提交时, tx2开始执行,如果没有本地锁, 将读到 1000 ,并且也进行 1000 -100 = 900 ,那么当tx1顺利全局提交后,tx2也提交后,最终数据是 900, 与实际相悖, 而存在本地锁时, tx1,在读取开始到提交结束时, 一直都是持锁状态, tx2 需要等到 数据变成900 后才能进行操作, 那么将进行 900 -100的操作, 那么最终当 tx1 和 tx2提交后, 数据为800
全局锁的目的是为了在全局提交和全局回滚时防止数据出现异常, 例如上述tx1,tx2,是tx1先持有到全局锁,那么将先执行1000-100 = 900, tx2执行 900 -100 = 800, 如果没有全局锁, 可能会产生,tx2反而先提交, 先为800,后为900的情况, 同时全局锁和本地锁配合也能解决脏写问题
问题二:
在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted) 。因为一阶段的提交,是将事务彻底提交,并记录undo_log日志表的方式, 所以在全局事务彻底提交之前, 后续事务会读取到该数据, 例如上面的问题一中, tx2 就可以读到 tx1修改后的 1000-100, 所以在全局事务的视角上, 该事务为读未提交
读隔离:
如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE
语句的代理。比如上述的例子中,就强制要求tx2, 必须等到 tx1 真正全局提交后,再读取数据
SELECT FOR UPDATE
语句的执行会申请 全局锁 (此时全局锁在tx1手上,只有全局提交,或回滚后才释放),如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。
出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。
4: 柔性事务
柔性事务主要分为补偿型和通知型
通知型: 可靠消息最终一致、最大努力通知型
补偿型: TCC、Saga;
4.1: 可靠消息最终一致性(异步确保型事务)
需要解决下面两个问题:
发送方: 事务事务参与方接收消息的可靠性,即本地事务和消息发送成功的一致性
接收方: 消息重复消费的问题,要解决消息重复消费的问题就要实现事务参与方的方法幂等性
4.1.1: 本地消息表方案
本地消息表这个方案最初是 eBay 提出的,此方案的核心是通过本地事务保证数据业务操作和消息的一致性,然后通过定时任务将消息发送至消息中间件,待确认消息发送给消费方成功再将消息删除。
如上图所示: 用户系统新增用户后,需要对此用户赠送积分,调用积分服务增加积分,此处要保证用户新增和新增
积分最终都要成功
1. 用户系统接受注册请求, 开启本地事务,并新增一条用户信息
1. 在本地事务中, 继续新增 积分新增 消息日志表记录, 由于是在同一个本地事务中,步骤一二保证一致性
1. 定时任务程序定期扫描积分消息表, 读取未发送状态的消息记录,进行发送消息,发送成功后(ACK机制)更新消息记录状态为已发送(这里可以主动扫描记录表,也可监听记录表的插入事件,例如使用canal监听binlog)
1. MQ服务接受到消息,并将消息发送给积分服务
1. 积分服务消费消息,进行增加用户积分操作,这里需要保证消费接口的幂等性,保证消息重复消费不会重复增加积分,并且这里需要保证消息的
4.1.2: 事务性消息
上面的本地消息方案中,确保事务成功发送,是由一个服务进行扫描消息表, 也就是MQ的客户端保证
事务性消息,即是通过消息发送方通知,或者靠自身定时回查发送方状态来决定是否将消息进行投递
例如自带事务性消息的Rocketmq
如下图所示:
以Rocketmq为例:
- 生产者将消息发送至RocketMQ服务端
- RocketMQ服务端将消息持久化成功之后,向生产者返回Ack确认消息已经发送成功,此时消息被标记为"暂不能投递",这种状态下的消息即为半事务消息(预通知MQ,将需要发送的消息预先保存在MQ服务端)
- 生产者开始执行本地事务逻辑
- 生产者根据本地事务执行结果向服务端提交二次确认结果(Commit或是Rollback),服务端收到确认结果后处理逻辑如下:
- 二次确认结果为Commit:服务端将半事务消息标记为可投递,并投递给消费者。
- 二次确认结果为Rollback:服务端将回滚事务,不会将半事务消息投递给消费者。
- 在断网或者是生产者应用重启的特殊情况下,若服务端未收到发送者提交的二次确认结果,或服务端收到的二次确认结果为Unknown未知状态,经过固定时间后,服务端将对消息生产者即生产者集群中任一生产者实例发起消息回查。
- 生产者收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
- 生产者根据检查到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤4对半事务消息进行处理。
RocketMQ提供RocketMQLocalTransactionListener接口:
public interface RocketMQLocalTransactionListener {
/**
* 发送 prepare 消息成功此方法被回调,该方法用于执行本地事务
*
* @param msg 回传的消息,利用 transactionId 即可获取到该消息的唯一Id
* @param arg 调用 send方法时传递的参数,当send时候若有额外的参数可以传递到send方法中,这里能获取到
* @return 返回事务状态,COMMIT:提交ROLLBACK:回滚UNKNOW:回调
*/
RocketMQLocalTransactionState executeLocalTransaction(final Message msg, final Object arg);
/**
* @param msg 通过获取 transactionId 来判断这条消息的本地事务执行状态
* @return 返回事务状态, COMMIT:提交ROLLBACK:回滚UNKNOW:回调
*/
RocketMQLocalTransactionState checkLocalTransaction(final Message msg);
}
4.1.3: 二者区别
4.2: 最大努力通知
和可靠消息投递不同的是, 可靠消息投递,是事务发起方尽可能的保证消息的投递,保证最终一致性, 其内部使用消息中间件作为通讯中介, 一般用在内部系统使用
最大努力通知事务,主要靠事务的被调用方发起通知, 主要用于外部系统,因为外部的网络环境更加复杂和不可信,所以通知的手段也可依不同的场景进行选择,不能只依靠MQ, 要尽最大努力去通知实现数据最终一致性,,比如充值平台与运营商、支付对接、商户通知等等跨平台、跨企业的系统间业务交互场景;
如下图, 账户系统接受到充值请求,调用充值系统进行支付(例如外部支付宝)
其中需要主要的是两个点
- 结果通知, 发起通知方需要尽可能的将处理结果通知到接受通知方, 通知手段可以使用MQ,如果使用MQ,也需要保证消息的可靠投递,也可以使用HTTP调用, 如果通知失败,需要在间隔时间内进行重试
- 消息校对, 接收通知方也可主动请求查询结果, 可以作为通知不成功的兜底补偿方案
4.3: TCC
关于TCC(Try-Confirm-Cancel)的概念,最早是由Pat Helland于2007年发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文提出。在该论文中,TCC还是以Tentative-Confirmation-Cancellation作为名称;正式以Try-Confirm-Cancel作为名称的,可能是Atomikos(Gregor Hohpe所著书籍《Enterprise Integration Patterns》中收录了关于TCC的介绍,提到了Atomikos的Try-Confirm-Cancel,并认为二者是相似的概念)。
TCC事务机制相对于传统事务机制(X/Open XA Two-Phase-Commit),其特征在于它不依赖资源管理器(RM)对XA的支持,而是通过对(由业务系统提供的)业务逻辑的调度来实现分布式事务。
TCC 分为三个阶段,分别为 “准备”、“提交”和“回滚” ,三个阶段都需要自己业务逻辑实现, 所以理论上, TCC模式并不依赖于任何对于资源的限制, 并且,由于是 自己实现,如果考虑周全, 提交或者回滚完全可以间隔很长时间后执行, 保证最终一致就可
总体来说, TCC仍然是两阶段提交的模型, 如一个扣款例子
- 一阶段(Try): 预留业务资源, 例如将张三的账户的余额扣除,并保留在冻结字段
- 二阶段(Confirm/Cancel): 由全局事务通知提交或回滚, 执行自定义的 提交或者回滚方法,将张三的冻结金清除,或者加回余额
优点:
- 相比较传统2PC的强一致性方案, TCC实现了最终一致性,在try阶段就将资源提交, 不会长时间的占用资源
- 对比 Seata AT 模式, TCC和 他有些相似,都是先将资源提交,再用事先准备好的提交,回滚方案进行保证事务一致性, 只不过Seata AT方案是框架做好的,自动生成前置,后置镜像, 所以Seata AT依赖资源自身需要满足ACID的要求, 即是传统数据库, 而 TCC的预留资源, 提交资源, 回滚资源都是由业务自己实现, 所以可以是任何类型的资源, 并且提交回滚的异步操作, 也使得其性能更高,对系统进行削峰填谷
缺点:
- TCC 是一种侵入式的分布式事务解决方案,以上三个操作都需要业务系统自行实现,对业务系统有着非常大的入侵性
- 设计相对复杂, 尤其是自己实现三个方法事, 需要考虑方方面面, 其中最为常见的有空回滚、幂等、悬挂(Seata TCC的实现,在新版本中已经自动给我们解决)等。
其中Seata 中 对TCC模式进行了实现, 可以参考如下文档:
https://seata.apache.org/zh-cn/docs/user/mode/tcc
4.3.1: 空回滚,幂等,悬挂问题
如何处理空回滚:
空回滚指的是在一个分布式事务中,在没有调用参与方的 Try 方法的情况下,TM 驱动二阶段回滚调用了参与方的 Cancel 方法。
例如在上面的转账案例中, 要扣款100 元, 那么在try方法中, 将对余额执行 余额-100
的操作, cancel方法将执行 余额+100
的操作, 若此时,因为节点异常, 调用try失败,那么当全局任务回滚时,执行了该分支事务的 cancel方法, 如果没有控制, 那么将导致余额变动
要想防止空回滚,那么必须在 Cancel 方法中识别这是一个空回滚,
可以添加一张事务控制表,表中记录了每个分支事务当前执行的状态, 例如在执行try方法后, 将表中标识置为1, 那么当因为错误问题空回滚执行 cancel时,只需判断当前节点是否为1即可
如何处理幂等
幂等问题指的是 TC 重复进行二阶段提交,因此 Confirm/Cancel 接口需要支持幂等处理,即不会产生资源重复提交或者重复释放。
如何产生幂等问题:
如上图所示,参与者 A 执行完二阶段之后,由于网络抖动或者宕机问题,会造成 TC 收不到参与者 A执行二阶段的返回结果,TC 会重复发起调用,直到二阶段执行结果成功。
同样,解决方法同样可以依赖控制表, 如果执行过 confirm方法, 则将标识置为 2, 如果发现标识已经是2,并且又调到 confirm方法,则直接跳过
如何处理悬挂
悬挂指的是二阶段 Cancel 方法比 一阶段 Try 方法优先执行,由于允许空回滚的原因,在执行完二阶段 Cancel 方法之后直接空回滚返回成功,此时全局事务已结束,但是由于 Try 方法随后执行,这就会造成一阶段 Try 方法预留的资源永远无法提交和释放了。
如何产生:
如上图所示,在执行参与者 A 的一阶段 Try 方法时,出现网路拥堵,由于 Seata 全局事务有超时限制,执行 Try 方法超时后,TM 决议全局回滚,回滚完成后如果此时 RPC 请求才到达参与者 A,执行Try 方法进行资源预留,从而造成悬挂。
同样的控制表解决, 执行try方法时表中的字段应该为0, 如果事先先执行了confirm, 那么此时表中的字段为 2了, 那么直接报错即可
参考资料
https://www.bytesoft.org/tcc-intro/
https://seata.apache.org/zh-cn/docs/dev/mode/at-mode
https://rocketmq.apache.org/zh/docs/featureBehavior/04transactionmessage
https://www.cnblogs.com/crazymakercircle/p/13917517.html#autoid-h3-26-0-0