分布式事务的处理通常遵循ACID原则(原⼦性、⼀致性、隔离性、持久性),当⼀个业务流程涉及多 个独⽴的微服务或数据库时,分布式事务可以保证⼀组跨多个服务或者数据库实例的操作要么全部成 功,要么全部失败,确保在所有相关交易中数据的准确性和完整性。 理想状态下事务应该具备的特性:
-
Atomicity(原⼦性):⼀个事务(transaction)中的所有操作,要么都执⾏完成,要么全部不执 ⾏,不可能出现部分成功部分失败的情况。
-
Consistency(⼀致性):在事务开始之前和事务结束以后,数据库的完整性没有被破坏。完整性 规则是预定义的条件或规则,⽤于保持数据的准确性和⼀致性。这些规则在插⼊、修改或删除数据 时应⽤,并确保所有这些操作都不会导致数据不正确或不⼀致。 如果⼀个操作违反了完整性规则, 那么这个操作就会被拒绝
-
Isolation(隔离性):隔离性可以防⽌多个事务并发执⾏导致数据不⼀致,数据库允许多个并发事务同时对其数据进⾏读写和修改。事务隔离级别包括:未提交读(Read uncommitted)、提交读 (read committed)、可重复读(repeatable read)和串⾏化(Serializable)。
-
Durability(持久性):⼀旦事务被提交(commit),对数据库的改变就是永久性的。即便系统故障数据也不会丢失。持久性保证了数据库在任何情况下都不会丢失已提交事务的修改。
需要强调的是,并不是所有的分布式事务⽅案都满⾜ACID四个特性,例如事务消息⽅案,就⽆法从分⽀事务发起回滚,不是严格意义上的原⼦性。分布式事务常⻅解决⽅案有 两阶段提交、柔性事务 TCC、本地消息表、MQ事务消息、saga、AT 等。每⼀种⽅案都有其优点和缺点以及对应的适⽤场景。本⽂详细讨论!
⼀、分布式事务典型应⽤场景
-
在线转账:在银⾏的在线转账系统中,⼀个转账操作涉及到减少⼀个账⼾的余额,并增加另⼀个账⼾的余额。这两个操作需要作为⼀个事务来处理,以保证资⾦的⼀致性,要么转账成功,要么整个操作失败,账⼾余额不变。
-
电⼦商务:在电⼦商务⽹站中,⽤⼾可以从购物⻋中购买某个商品。该操作需要扣减库存,通知发货,扣除⽤⼾的账⼾余额等等。这些操作需要作为⼀个事务来处理,以确保数据的⼀致性。万⼀在处理过程中发⽣任何错误,事务应回滚,恢复到操作开始前的状态。
⼆、分布式事务分类
在⾮分布式的事务中,我们通常只需要关系型数据库⾃带的事务管理机制来实现事务性的需求。⽐如 mysql,具有ACID特性:原⼦性、⼀致性、隔离性、持久性。但是在微服务的环境下,多个服务操作多个数据库的时候,传统的单机事务模型已经⽆法胜任。如果我们期望实现⼀套严格满⾜ACID特性的分布式事务,很可能出现的情况就是在系统的可⽤性和严格⼀致性之间出现冲突。因此,如何构建⼀个兼顾可⽤性和⼀致性的分布式系统成为了⽆数⼯程师探讨的难题,出现了诸如CAP和BASE这样的分布式系统经典理论。
1.1 CAP理论
CAP定理是由加州⼤学伯克利分校Eric Brewer教授提出来的,指的是在⼀个分布式系统中,一致性(Consistency)、可⽤性(Availability)、分区容错性(Partition tolerance)这三个要素最多只能同时实现两点,不能三者兼得。
- ⼀致性
⼀致性指的是多个数据副本是否能保持⼀致的特性, 在一致性条件下,对系统的⼀个数据更新成功之后,如果所有⽤⼾都能够读取到最新的值,该系统就被认为具有强一致性。 - 可⽤性
可⽤性指分布式系统在⾯对各种异常时可以提供正常服务的能⼒,在可⽤性条件下,要求系统提供的服务⼀直处于可⽤的状态,对于⽤⼾的每⼀个操作请求总是能够在 有限的时间内返回结果。 - 分区容错性
⽹络分区指分布式系统中的节点被划分为多个区域,每个区域内部可以通信,但是区域之间⽆法通信。 在分区容错性条件下,分布式系统在遇到任何⽹络分区故障的时候,仍然需要能对外提供⼀致性和可⽤性的服务,除⾮是整个⽹络环境都发⽣了故障。
1.2 BASE理论
BASE 理论是基本可⽤(Basically Available)、软状态(Soft State)和最终⼀致性(Eventually Consistent)三个短语的缩写。BASE理论是对CAP中AP的⼀个扩展,它的核⼼思想是:即使⽆法做到强⼀致性,但每个应⽤都可以根据⾃⾝业务特点,采⽤适当的⽅式来使系统达到最终⼀致性。 BASE 理论⾯向的是⼤型⾼可⽤可扩展的分布式系统,和传统事务的 ACID 是相反的,它完全不同于 ACID 的 强⼀致性模型,⽽是通过牺牲强⼀致性来获得可⽤性,并允许数据在⼀段时间是不⼀致的。
1.3 分布式事务分类
基于以上理论,分布式事务整体上划分为如下两类:
- 刚性事务:遵循ACID原则,强⼀致性。
- 柔性事务:遵循BASE理论,也部分遵循 ACID规范,如下表:
1 | 原子性 | 严格保障 |
---|---|---|
2 | 一致性 | 最终一致,存在过程中的软状态 |
3 | 隔离型 | 事务并发执行保证结果符合预期,事务之间的可见性符合业务要求 |
4 | 持久性 | 严格保障 |
三、分布式应⽤下错误的事务解决⽅案
在传统的单体服务体系结构中,所有的业务逻辑都运⾏在⼀个单⼀的应⽤中,数据也储存在⼀个单⼀的数据库中。因此,⽤来处理逻辑问题和保证数据⼀致性的本地事务已⾜够有效。它们可以依赖于单个数据库系统的事务处理机制,遵循ACID原则(原⼦性、⼀致性、隔离性、持久性),使得事务管理相对简洁明了。然⽽,随着微服务架构的兴起,原来的单体应⽤被拆分成许多独⽴的微服务,每个微服务都有可能拥有⾃⼰的数据库。在这种情况下,⼀次业务操作可能涉及到跨多个微服务和数据库的操作,其中任何⼀个部分的失败都可能导致数据的不⼀致性。这时候,原有的本地事务已经不能满⾜ 需要,我们需要引⼊分布式事务。 在介绍分布式事务前,下⾯给出⼀些保证事务⼀致性的错误范例:本地事务中绑定RPC或者MQ消息来达到强⼀致 。⽅案伪代码如下:
开启事务
DB 操作
RPC调⽤/MQ消息
if(RPC调⽤成功)
提交DB事务
else
回滚事务
想实现的效果:
- RPC调⽤到下游成功,DB事务提交成功
- RPC调⽤下游下单失败,DB事务回滚
存在的问题:
问题⼀:RPC接⼝跨⽹络调⽤,响应变慢,可能导致事务⻓时间挂起,数据库连接⻓时间占⽤不释放。
问题⼆:RPC访问超时情况,⽆法知道当前接⼝调⽤成功还是失败,因此⽆法确定要提交还是会滚DB操作。
因此,上述方案无法实现分布式环境下多个分支事务的一致性。
四、分布式应⽤下合理的事务解决⽅案
1、XA事务
XA规范
XA 规范是⼀种分布式事务处理标准,由 X/Open 组织定义,主要⽤于分布式系统中协调和管理全局事务。 分布式事务处理(Distributed Transaction Processing,DTP)模型定义了⼀个标准化的分布式事务 处理的体系结构以及交互接⼝。应⽤程序(Application Program,AP)能够访问由多个资源管理器 (Resource Manager,RM)提供的资源。其中,每个资源管理器都具有独⽴性,且不必是同构的。全局事务的原⼦性由事务管理器来负责,各个模块的交互如下图所⽰。
XA规范中定义的分布式事务模 型包括三个组成部分:
- AP(应⽤程序),通过TM定义事务边界,执⾏全局事务
- TM(事务管理器),负责协调跨RM的全局事务的开启、提交和回滚
- RM(资源管理器),负责管理分布式系统中的部分数据资源,保障该部分数据的⼀致性,满⾜规 范要求的数据管理系统均可作为RM参与分布式事务,最典型的应⽤是数据库,如MySQL、 Oracle、SQLServer等均⽀持该规范
2PC要求RM必须实现XA协议,准确讲XA是⼀个规范,它只是定义了⼀系列的接⼝,只是⽬前⼤多数实现XA的都是数据库或者MQ,在微服务架构中,RM可能是任意的类型,可以是⼀个微服务,也可以是 ⼀个KV 存储。
两阶段提交(2PC)
1. Prepare 阶段
- 协调者向所有参与方发送prepare请求与事务内容,询问是否可以准备事务提交,并等待参与者的响应。
- 参与者执⾏事务中包含的操作,并记录undo⽇志(⽤于回滚)和redo⽇志(⽤于重放),但不提交。
- 参与者向协调者返回事务操作的执⾏结果。
2. Commit 阶段
若所有参与者返回OK,事务可以提交。
- 协调者向所有参与者发送Commit请求。
- 参与者收到Commit请求后,正式提交事务,释放占⽤的事务资源,向协调者返回OK。
- 协调者收到所有参与方的Ack消息,事务成功完成。
若存在参与者返回执行失败或者等待超时时间之后协调者没有收到所有参与者的返回的情况,中断事务。 - 协调者向所有参与方发送Rollback请求。
- 参与者收到Rollback请求后,根据undo⽇志回滚到事务执⾏前的状态,释放占⽤的事务资源。 完成事务回滚之后向协调这返回Ack。
- 协调者收到所有参与者的Ack消息,完成事务中断。
代码示例
1、Mysql支持的XA事务命令
To perform XA transactions in MySQL, use the following statements:(Detail)
XA {START|BEGIN} xid [JOIN|RESUME] // 开启XA事务
XA END xid [SUSPEND [FOR MIGRATE]] //将 XA 事务置为IDLE 状态,处于 IDLE 状态可以执⾏PREPARE 操作
XA PREPARE xid //⼆阶段提交:prepare
XA COMMIT xid [ONE PHASE] //提交事务
XA ROLLBACK xid //回滚事务
XA RECOVER [CONVERT XID] //列出所有处于prepared状态的事务
2、简单Demo
public class XaTransactionDemo {
public static void main(String[] args) throws Exception{
//RM1
Connection rm1Conn = DriverManager.getConnection("jdbc:mysql://IP1:3306/XXXX?useUnicode=true&characterEncoding=utf8","xxxx","xxxxx");
XAConnection rm1XAConn = new MysqlXAConnection((JdbcConnection) rm1Conn, true);
XAResource resource1 = rm1XAConn.getXAResource();
//RM2
Connection rm2Conn = DriverManager.getConnection("jdbc:mysql://IP2:3306/XXXX?useUnicode=true&characterEncoding=utf8","xxxx","xxxxx");
XAConnection rm2XAConn = new MysqlXAConnection((JdbcConnection) rm2Conn, true);
XAResource resource2 = rm2XAConn.getXAResource();
// 全局事务
byte[] globalId = UUID.randomUUID().toString().getBytes();
//一个标识
int formatId = 1;
//分支事务1
byte[] branch1Bqual = UUID.randomUUID().toString().getBytes();;
Xid xid1 = new MysqlXid(globalId, branch1Bqual, formatId);
//分支事务2
byte[] branch2Bqual = UUID.randomUUID().toString().getBytes();;
Xid xid2 = new MysqlXid(globalId, branch2Bqual, formatId);
try {
// 事务1开始
resource1.start(xid1, XAResource.TMNOFLAGS);
// 模拟业务
String sql1 = "update xxx set balance_amount = balance_amount - 100 where user_id= ? ";
PreparedStatement ps1 = rm1Conn.prepareStatement(sql1);
ps1.execute();
resource1.end(xid1, XAResource.TMSUCCESS);
// 事务2开始
resource1.start(xid2, XAResource.TMNOFLAGS);
// 模拟业务
String sql2 = "update xxx set balance_amount = balance_amount + 100 where user_id = ? ";
PreparedStatement ps2 = rm2Conn.prepareStatement(sql2);
ps2.execute();
resource1.end(xid2, XAResource.TMSUCCESS);
// 第一阶段:准备提交
int rm1_prepare = resource1.prepare(xid1);
int rm2_prepare = resource1.prepare(xid2);
// 第二阶段:TM根据第一阶段的情况决定是提交还是回滚
boolean onePhase = false;
if (rm1_prepare == XAResource.XA_OK && rm2_prepare == XAResource.XA_OK) {
resource1.commit(xid1, onePhase);
resource1.commit(xid2, onePhase);
} else {
resource1.rollback(xid1);
resource1.rollback(xid2);
}
} catch (Exception e) {
// 出现异常,回滚
resource1.rollback(xid1);
resource1.rollback(xid2);
e.printStackTrace();
}
}
}
BTW: 上述代码仅仅展示XA 两阶段提交的过程,⽆法在⽣产环境使⽤!
ACID特性
- 原⼦性:⽀持
- ⼀致性:强⼀致
- 隔离性:⽀持
- 持久性:⽀持
优劣总结
2PC优势
- 业务侵⼊度低,可以像使⽤本地事务⼀样使⽤基于 XA 协议的分布式事务
- 能够严格保障事务 ACID 特性。
2PC问题 - 单点故障:协调者故障会导致所有参与者阻塞,整个2PC⽆法运转。参与者故障时事务失败,并且协调者需要依赖超时机制来判断是否需要回滚事务。
- 同步阻塞:每个参与者在等待其他参与者响应的过程中,⽆法做其他操作,影响分布式系统的性能。
- 可⽤性低:2PC要求所有的参与者都处于在线状态,否则⽆法执⾏事务。当任意参与者故障时,系统⽆法运⾏,降低了可⽤性。
- 极端情况下数据不⼀致:第⼆阶段如果事务管理器因⽹络发⽣异常只成功发送部分 commit 命令,那么只有部分参与者接收到 commit 命令,即只有部分参与者提交了事务,最终系统数据不⼀ 致。
三阶段提交(3PC )
3PC在2PC的基础上,针对同步阻塞、单点问题做了进⼀步优化。为了解决两阶段提交存在的部分问题,三阶段提交引⼊了超时机制,将prepare阶段分为canCommi和preCommit两步。
详细执⾏流程:
- CanCommit 阶段
- 协调者向参与者发送CanCommit请求。询问是否可以执⾏事务提交操作。然后开始等待参与者的响应。
- 参与者接到CanCommit请求之后,如果其⾃⾝认为可以顺利执⾏事务,则返回OK,并进⼊预备状态。否则反馈No。
- PreCommit 阶段
若所有参与者返回OK,实现事务预提交。
- 协调者向参与者发送PreCommit请求,并进⼊Prepared阶段。
- 参与者接收到PreCommit请求后,会执⾏事务操作。
- 如果参与者成功的执⾏了事务操作,则返回ACK响应,同时开始等待最终指令。
若部分参与者返回No,或者等待超时时间之后协调者没有收到所有参与的响应,中断事务。 - 协调者向所有参与者发送abort请求。
- 参与者收到来⾃些调者的abort请求之后(或超时之后,仍未收到协调者的请求),执⾏事务的中断。
- DoCommit 阶段
如果协调者正常⼯作,并且接收到所有参与者的ack响应,实现事务提交。
- 协调者向所有参与者发送Commit请求。
- 参与者收到Commit请求后,正式提交事务,释放占⽤的事务资源,向协调者返回Ack。
- 协调者收到所有参与者的Ack消息,事务成功完成。
如果协调者正常⼯作,且有参与者返回No,或者等待超时后协调者没有收到所有参与者的响应,中断事务。 - 协调者向所有参与者发送Rollback请求。
- 参与者收到Rollback请求后,根据undo⽇志回滚到事务执⾏前的状态,释 放占⽤的事务资源。 完成事务回滚之后向协调者返回Ack。
- 协调者收到所有参与者的Ack消息,完成事务中断。
2PC和3PC的区别:
- 2PC只有协调者(TM)有超时机制,为解决单点问题,3PC的协调者(TM)和参与者(RM)都有超时机制。在第三阶段,如果指定时间未收到协调者发送的commit消息,则参与者默认提交事务。但是这种机制也会导致数据⼀致性问题,因为,由于⽹络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执⾏了commit操作。这样就和其他接到abort命令并执⾏回滚的参与者之间存在数据不⼀致的情况。
- 为减少同步阻塞的作⽤区域,3PC将2PC中的第⼀阶段拆分为2个阶段。通过CanCommit、 PreCommit、DoCommit三个阶段的设计,相较于2PC⽽⾔,多设置了⼀个缓冲阶段保证了在最后提交阶段之前各参与节点的状态是⼀致的。以上就是3PC相对于2PC的⼀个提⾼(相对缓解了2PC中的前两个问题),但是3PC依然没有完全解决数据不⼀致的问题。
2、Seata AT模式
应⽤前提
- 基于⽀持本地 ACID 事务的关系型数据库。
- Java 应⽤,通过 JDBC 访问数据库。
⼯作原理
Seata AT 模式是⼀种⾮侵⼊式的分布式事务解决⽅案,Seata 在内部做了对数据库操作的代理层,我们使⽤ Seata AT 模式时,实际上⽤的是 Seata ⾃带的数据源代理 DataSourceProxy,Seata 在这层代理中加⼊了很多逻辑,⽐如插⼊回滚 undo_log ⽇志,检查全局锁等。 AT 模式弥补了 XA 模式中资源锁定周期过⻓的缺点,相对于 XA 来说,性能更好⼀些。
阶段⼀:RM的⼯作:
- 注册⼀个分⽀事务到事务协调者TC
- 记录⼀个SQL更新前的快照和⼀个更新后的快照到undo_log⽇志表中
- 执⾏SQL并提交数据库事务
- 报告事务状态
阶段⼆:RM的⼯作:
- 所有分⽀事务执⾏成功,TC通知RM删除undo-log记录。
- 如果部分分⽀事务执⾏失败,则TC会通知RM根据undo-log记录的对应快照回滚数据。
分⽀事务执⾏过程
Table : product | ||
---|---|---|
Field | Type | Key |
id | bigint(20) | PRI |
name | varchar(100) | |
since | varchar(100) |
AT 分⽀事务业务SQL:
update product set name = 'GTS' where name = 'TXC';
⼀阶段过程:
- 解析 SQL:得到 SQL 的类型(UPDATE),表(product),条件(where name = ‘TXC’)等相关的信息。
- 查询前镜像:根据解析得到的条件信息,⽣成查询语句,定位数据。
select id, name, since from product where name = 'TXC';
得到前镜像:
Table : product | ||
---|---|---|
id | name | since |
1 | TXC | 2014 |
- 执⾏业务 SQL:更新这条记录的 name 为 ‘GTS’。
- 查询后镜像:根据前镜像的结果,通过 主键 定位数据。
select id, name, since from product where id = 1;
得到后镜像:
Table : product | ||
---|---|---|
id | name | since |
1 | GTS | 2014 |
- 插⼊回滚⽇志:把前后镜像数据以及业务 SQL 相关的信息组成⼀条回滚⽇志记录,插⼊到UNDO_LOG 表中
{
"branchId": 641789253,
"undoItems": [{
"afterImage": {
"rows": [{
"fields": [{
"name": "id",
"type": 4,
"value": 1
}, {
"name": "name",
"type": 12,
"value": "GTS"
}, {
"name": "since",
"type": 12,
"value": "2014"
}]
}],
"tableName": "product"
},
"beforeImage": {
"rows": [{
"fields": [{
"name": "id",
"type": 4,
"value": 1
}, {
"name": "name",
"type": 12,
"value": "TXC"
}, {
"name": "since",
"type": 12,
"value": "2014"
}]
}],
"tableName": "product"
},
"sqlType": "UPDATE"
}],
"xid": "xid:xxx"
}
- 提交前,向 TC 注册分⽀:申请 product 表中,主键值等于 1 的记录的 全局锁 。
- 本地事务提交:业务数据的更新和前⾯步骤中⽣成的 UNDO LOG ⼀并提交。
- 将本地事务提交的结果上报给 TC。
⼆阶段过程:
回滚
- 收到 TC 的分⽀回滚请求,开启⼀个本地事务,执⾏如下操作。
- 通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录。
- 数据校验:拿 UNDO LOG 中的后镜与当前数据进⾏⽐较,如果有不同,说明数据被当前全局事务
之外的动作做了修改。这种情况,需要根据配置策略来做处理,详细的说明在另外的⽂档中介绍。 - 根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息⽣成并执⾏回滚的语句:
update product set name = 'TXC' where id = 1;
- 提交本地事务。并把本地事务的执⾏结果(即分⽀事务回滚的结果)上报给 TC。
提交
- 收到 TC 的分⽀提交请求,把请求放⼊⼀个异步任务的队列中,⻢上返回提交成功的结果给 TC。
- 异步任务阶段的分⽀提交请求将异步和批量地删除相应 UNDO LOG 记录。
脏读脏写
全局锁解决脏读问题
在数据库本地事务隔离级别读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是读未提交(Read Uncommitted) 。如果应⽤在特定场景下,必须要求全局的 读已提交 ,⽬前 Seata 的⽅式是通过 SELECT FOR UPDATE 语句的代理。
SELECT FOR UPDATE 语句的执⾏会申请全局锁 ,如果全局锁被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执⾏)并重试。这个过程中,查询是被 block 住的,直到全局锁拿到,即读取的相关数据是已提交 的才返回。出于总体性能上的考虑,Seata ⽬前的⽅案并没有对所有 SELECT 语句都进⾏代理,仅针对 FOR UPDATE 的 SELECT 语句。
全局锁解决脏写问题
seata 使⽤全局锁解决AT 脏写问题
- ⼀阶段本地事务提交前,需要确保先拿到全局锁 。
- 拿不到全局锁 ,不能提交本地事务。
- 拿全局锁的尝试被限制在⼀定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。
以⼀个⽰例来说明:
两个全局事务 tx1 和 tx2,分别对某表的 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 持有的,所以不会发⽣ 脏写 的问题。
代码⽰例
https://github.com/apache/incubator-seata-samples
ACID特性
Seata AT模式⽀持分布式事务中的ACID特性:
- 原⼦性(Atomicity): Seata AT模式通过⼀阶段的⾃动提交和⼆阶段的⾃动回滚,保证了分布式事务的原⼦性。也就是说,全局事务要么全部提交成功,要么全部回滚,不会出现部分成功,部分失败的情况。
- ⼀致性(Consistency): 通过全局⼀阶段和⼆阶段的原⼦提交和回滚,Seata AT 模式确保了分布式事务的数据状态保持⼀致。
- 隔离性(Isolation):隔离性是指并发的事务之间⽆法相互⼲扰,每个事务都感觉不到系统中有其他事务在并发地执⾏。Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted) 。可以牺牲性能做到 读已提交隔离级别,因此并不能完全保证这⼀特性。
- 持久性(Durability):Seata AT模式的持久性是通过异步⽇志完成的,所有的全局事务⽇志保存在独⽴的⽇志存储中,确保了事务提交后的修改能持久保存。
根据上述分析,Seata AT模式本质上⽀持原⼦性和⼀致性,但隔离性有所降低(只保证读未提交/读已提交隔离级别),⽽持久性则得到了保证。
优劣总结
- 优点:业务⽆侵⼊、隔离性较⾼、⼏乎没有改造成本;
- 缺点:依赖存储⽀持、适⽤于没有集中热点数据更新的场景;
3、本地消息表
⼯作原理
本地消息表的⽅案最初由ebay的⼯程师提出,基于数据库本地事务,把需要保障最终⼀致性的远程服务调⽤信息和本地⽅法绑定在⼀个本地事务中写⼊DB。后续扫描该本地消息表,完成最终⼀致相关逻辑后,修改本地信息状态,完成最终⼀致保障。详细步骤如图:
- 在发起远程调⽤前,先将远程调⽤的上下⽂持久化到⼀个消息表中,并要求消息表的操作与业务表的操作在⼀个本地事务中,然后通过异步机制(JOB)去做远程调⽤。
- 消息表中维护了远程调⽤操作的状态,当远程调⽤成功后,需要更新状态为成功。
- 如果遇到异步调⽤没有成功触发(⽹络原因或下游宕机),需要有补偿重试机制,JOB扫描本地消息表的数据,触发远程调⽤直到成功。
ACID特性
- 原⼦性:⽀持
- ⼀致性:最终⼀致
- 隔离性:不⽀持(分⽀事务提交之后对其它事务可⻅)
- 持久性:⽀持
优劣总结
本地消息表优点
- 简单容易实现
- 没有引⼊额外组件依赖
- 系统吞吐量⾼,下游事务异步化
本地消息表缺点 - 业务侵⼊,该⽅案需要在每个使⽤该⽅案的业务系统专⻔维护⼀张消息表。
- 事务⽀持不完备,下游不能回滚,只能重试;
代码⽰例
无
4、MQ事务消息
Apache RocketMQ在4.3.0版中已经⽀持分布式事务消息,RocketMQ 的事务消息能够保证分布式系统 间的消息的最终⼀致性,其实现原理主要依赖于⼀种类似两阶段提交的机制。下⾯详细介绍它的⼯作 流程:
实现原理
- 发送方向 MQ 服务端发送消息;
- MQ Server 将消息持久化成功之后,向发送方 ACK 确认消息已经发送成功,此时消息为半消息。
- 发送方开始执行本地事务逻辑。
- 发送方根据本地事务执行结果向 MQ Server 提交二次确认(Commit 或是 Rollback),MQ Server 收到 Commit 状态则将半消息标记为可投递,订阅方最终将收到该消息;MQ Server 收到 Rollback 状态则删除半消息,订阅方将不会接受该消息。
- 在断网或者是应用重启的特殊情况下,上述步骤4提交的二次确认最终未到达 MQ Server,经过固定时间后 MQ Server 将对该消息发起消息回查。
- 发送方收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
- 发送方根据检查得到的本地事务的最终状态再次提交二次确认,MQ Server 仍按照步骤4对半消息进行操作。
ACID 特性
- 原⼦性:⽀持
- ⼀致性:最终⼀致
- 隔离性:不⽀持(分⽀事务提交之后对其它事务可⻅)
- 持久性:⽀持
优劣总结
优点
性能⾼:不会出现⻓时间持有资源的问题
缺点
- 业务⼊侵度⾼:需要⼀定的业务改造
- 事务特性⽀持程度⼀般:事务消息保证本地主分⽀事务和下游消息发送事务的 ⼀致性,但不保证消息消费结果和上游事务的⼀致性。因此需要下游业务分⽀⾃⾏保证消息正确处理,消费端做好消费重试.假设分支事务重生后一定能成功,不支持消费回滚 ,业务场景受限
代码示例
//TODO我来补充
5、TCC
提出背景
关于 TCC(Try-Confirm-Cancel)的概念,最早是由 Pat Helland 于 2007 年发表的⼀篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论⽂提出。 TCC 事务机制相⽐于上⾯ 介绍的 XA,解决了其⼏个缺点:
- TCC事务模型在设计上是一种去中心化的模型,可以解决协调者单点问题;
- Confirm 和 Cancel 阶段,每个参与者独立的进行业务操作的提交或者回滚,这种设计避免了全局事务提交时所有参与者必须等待最慢的参与者完成提交的问题,即减少了同步阻塞。
⼯作原理
TCC是⼀种最终一致性的分布式事务处理⽅案。主要的思想是将复杂的业务操作拆分为可以预先检查资源和预留资源的Try操作和实际执⾏业务操作的Confirm操作,这两个操作之间可以插⼊⼀种回滚操作,以便在必要的时候取消Try操作。其中,最关键的是设计好Try、Confirm和Cancel这三个操作,特别是要保证 Confirm操作和Cancel操作的幂等性。TCC⼯作过程三个阶段:尝试(Try)、确认(Confirm)和取消 (Cancel):
- 尝试(Try)阶段: 这个阶段的主要⼯作是进⾏资源的检查和预留。系统在执⾏业务操作之前,需要 确保所有的资源都可⽤,并且可以将这些资源预留出来。如果这些资源⽆法预留,那么事务就会在 此阶段失败,不会进⼊下⼀个阶段。
- 确认(Confirm)阶段: 这个阶段是在Try阶段成功后进⾏的。在确认阶段,事务的处理程序会实际 执⾏所有的业务操作,并且释放在Try阶段预留的所有资源。确认阶段的所有操作都应该是幂等的,即⽆论执⾏多少次,结果都是⼀样的。⼀般这个阶段的操作简单快速,不会有异常。
- 取消(Cancel)阶段: 这个阶段是在Try阶段成功,但是在Confirm阶段失败后进⾏。如果在 Confirm阶段抛出异常,那么就需要调⽤在Try阶段准备好的回滚(补偿)操作,以取消在Try阶段 进⾏的操作。和Confirm阶段相同,Cancel阶段的所有操作也应该是幂等的。
设计要点
空回滚
如果协调者的Try()请求因为⽹络超时失败,那么协调者在阶段⼆时会发送Cancel()请求,⽽这时这个 事务参与者实际上之前并没有执⾏Try()操作⽽直接收到了Cancel()请求。针对这个问题,TCC模式要求 在这种情况下Cancel()能直接返回成功,也就是要允许空回滚。
防悬挂
Try()请求超时,事务参与者收到Cancel()请求⽽执⾏了空回滚,但就在这之后⽹络 恢复正常,事务参与者⼜收到了这个Try()请求,所以Try()和Cancel()发⽣了悬挂,也就是先执⾏了 Cancel()后⼜执⾏了Try() 针对这个问题,TCC模式要求在这种情况下,事务参与者要记录下Cancel()的事务ID,当发现Try()的事务ID已经被回滚,则直接忽略掉该请求。
幂等性
Confirm()和Cancel()的实现必须是幂等的。当这两个操作执⾏失败时协调者都会发起重试。
ACID特性
- 原⼦性:⽀持
- ⼀致性:最终⼀致
- 隔离性:隔离性通过业务层面的设计和操作来保证,具体隔离级别取决于每个操作的实现。
- 持久性:⽀持
优劣总结
- 优点:避免了协调者单点问题,缓解了同阻塞,系统吞吐量⾼;
- 缺点:业务侵⼊度⾼,业务接⼝需分拆为Try/Confirm/Cancel三个操作
代码例⼦
https://github.com/apache/incubator-seata-samples
6、Saga (很少⽤)
应用条件
Saga模式侧重于解决⻓时间运⾏的事务,避免⻓时间的锁定资源。 通常在以下⼏种情况下使⽤:
- ⻓存活事务:如果⼀个事务需要运⾏很⻓时间,⽽在此期间不希望阻塞其他事务
- 补偿逻辑可实现:Saga模式要求对于每个事务操作都需要有对应的补偿操作,⽤于在事务失败时进 ⾏回滚。因此,这种模式需要你能为每个操作提供⼀个补偿逻辑。
- 可以接受最终⼀致性:Saga模式只能保证最终⼀致性
总的来说,Saga模式适⽤于⻓时间事务、可以实现补偿逻辑、可以接受最终⼀致性的场景
⼯作原理
Saga 是⼀种解决分布式事务问题的模型,最早由 Hector Garcia-Molina 和 Kenneth Salem 在1987年 的论⽂中引⼊。Saga 分布式事务由⼀系列的 T1、T2、....、Tn 的⼦事务构成。Saga 保证每个⼦事务 都有⼀个补偿(compensating)事务,这些补偿事务是 C1、C2、....、Cn 。当所有⼦事务 Ti 成功执⾏后,Saga 事务就成功完成了。如果某个⼦事务 Ti 失败( i <= n ),那么所有的 T1、T2、....、Ti-1 ⼦ 事务都需要执⾏其对应的补偿事务。这样,即使在分布式环境下事务失败,也能保证系统的最终⼀致 性。Saga 事务没有严格的两阶段提交 (2PC),因此可以⼤⼤提⾼系统的吞吐量,⽽且可以适应复杂的分布式系统。
Saga恢复策略
1、向前恢复(forward recovery)
对于执⾏不通过的事务,会尝试重试事务,这⾥有⼀个假设就是每个⼦事务最终都会成功。这种⽅式 适⽤于必须要成功的场景,如图,⼦事务按照从左到右的顺序执⾏,T1执⾏完毕以后T2 执⾏,然后是 T3、T4、T5。
部分节点失败,事务恢复的顺序也是按照:T1、T2、T3、T4、T5的⽅向进⾏,如果在执⾏T1的时候 失败了就重试T1,以此类推在哪个⼦事务执⾏时失败了就执⾏哪个事务。
2、向后恢复(backward recovery)
在执⾏事务失败时,补偿所有已完成的事务。如图所⽰,在执⾏到事务T3的时候,该事务执⾏失败 了,于是按照红线的⽅向开始执⾏补偿事务,先执⾏C3、然后是C2和C1,直到T0、T1、T2的补偿事 务C1、C2、C3都执⾏完毕。也就是回滚整个Saga的执⾏结果。
协调模式
当通过系统命令启动Saga时,协调逻辑必须选择并通知第⼀个Saga参与⽅执⾏本地事务。⼀旦该事务 完成, Saga协调选择并调⽤下⼀个Saga参与⽅。这个过程⼀直持续到Saga执⾏完所有步骤。如果任何本地事务失败,则Saga必须以相反的顺序执⾏补偿事务。
- 协同式(choreography):把Saga的决策和执⾏顺序逻辑分布在Saga的每⼀个参与⽅中,它们通过交换事件的⽅式来进⾏沟通。
- 编排式(orchestration):把Saga的决策和执⾏顺序逻辑集中在⼀个Saga编排器类中。这个类的唯⼀职责就是告诉Saga的参与⽅该做什么事情。为了完成Saga中的⼀个环节,编排器对某个参与⽅发出⼀个 命令式的消息,告诉这个参与⽅该做什么操作。当参与⽅服务完成操作后,会给编排器发送⼀个答复 消息。编排器处理这个消息,并决定Saga的下⼀步操作是什么。
编排/协同式 Saga 的好处和弊端
协同式 | 好处 | 1. 松耦合:参与⽅订阅事件并且彼此之间不会因此⽽产⽣耦合,各个服务间互不依赖,每个服务只需要知道⾃⼰的任务及完成后的动作,实现了服务间的解耦。 2. 扩展性:由于没有中⼼化的编排者,更容易扩展,适应微服务的特点。 |
---|---|---|
协同式 | 弊端 | 1. 服务之间可能存在循环依赖关系:Saga参与⽅订阅彼此的事件,这通常会导 致循环依赖关系 2. 流程难以追踪:由于没有中⼼化的编排者,整个流程分散在各个服务中,如果需要查找全局的执⾏情况或进⾏调试,⽐较困难。3. 更难理解:与编排式不同,代码中没有⼀个单⼀地⽅定义了Saga。逻辑分布在每个服务的实现中。因此,开发⼈员有时很难理解特定的Saga是如何⼯作的。 |
编排式 | 好处 | 1. 没有循环依赖 2. 由于有⼀个单⼀的“编排者”来驱动整个过程,每个参与的微服务只需执⾏被分配的任务,整个流程被明确地定义出来,容易理解和管理 |
编排式 | 弊端 | - 紧耦合:引⼊了⼀个中⼼化的编排者,需要为编排者编写处理流程和回滚策略的逻辑,由于编排者需要知道整个流程的细节,可能会使得微服务之间的耦合度增⾼ |
ACID 特性
Saga模式通过在每个本地事务操作中添加补偿事务的⽅式,提供了⼀种分布式事务解决⽅案。然⽽, 由于这种⽅式的特性,它并不能完全达到ACID事务模型中的全部要求:
- 原⼦性(Atomicity):Saga模式可以保证⼀定程度的原⼦性。如果某个全局事务中的本地事务失 败,Saga模式会使⽤与已完成的本地事务相应的补偿事务进⾏回滚操作。这种⽅式确保了在全局事务失败时,数据状态可以恢复到全局事务开始前的状态。
- ⼀致性(Consistency):Saga模式只能实现最终⼀致性,⽽⾮⽴即⼀致性。全局事务的所有分⽀事务完成后,系统会在最终达到⼀致的状态。
- 隔离性(Isolation):Saga模式不提供分布式事务之间的隔离性。因为所有的事务操作是独⽴的事务,⼀旦该事务提交,每个Saga的本地事务所做的更新都会⽴即被其他Sagas看到,所以其中的每个操作都可能会影响到其他的分布式事务。
- 持久性(Durability):在Saga模式中,所有的事务操作和补偿事务的执⾏结果都需要持久化存储。如果系统在完成事务操作后崩溃,重启后可以通过这些持久化的记录进⾏事务恢复。因此,可以认为Saga模式满⾜了ACID模型中的持久性要求。
总的来说,Saga模式⽀持⼀定程度的原⼦性和持久性,⽽且可以实现最终的⼀致性,但并不⽀持隔离性。
代码⽰例
https://github.com/apache/incubator-seata-samples
优劣总结
Saga优点:
- ⽆需⻓时间持有资源锁:Saga 模式适⽤于⻓存活事务,它降低了系统对⻓时间锁定的需求。
- ⾼可⽤性:Saga模式是异步的,并且每个步骤都独⽴进⾏,这降低了系统中各个服务之间的耦合度,提⾼了整体的可⽤性。
Saga缺点:
- 编程复杂度⾼:Saga 需要为每个事务操作定义⼀个补偿操作,这增加了编程的复杂性。
- 只⽀持最终⼀致性:与ACID模型相⽐,Saga模式只能提供最终⼀致性,这不适合对⼀致性要求很⾼的业务。
- 隔离性问题:Saga模式并不能很好地⽀持隔离性,可能会引发并发控制的问题。
7、横向对⽐
8、 总结
以上介绍的分布式事务模型可以看做是在性能、隔离性和业务侵⼊三者之间的权衡,当我们对性能和隔离性要求很⾼,这个时候TCC模式就是⽐较合适的选择;当我们要求业务⽆⼊侵和隔离性很⾼时,这个时候AT模式就是⽐较合适的选择;当我们要求业务⽆⼊侵和性能很⾼时,这个时候SAGA模式就是⽐较合适的选择;
9、参考⽂献
- 分布式系统的⼀致性协议之 2PC 和 3PC
- 蚂蚁⾦服科技| ⼀篇⽂章带你学习分布式事务
- 分布式事务如何实现?深入解读 Seata 的 XA 模式
- TCC适用模型与适用场景分析
- 分布式事务 Seata TCC 模式深度解析
- Rocketmq 设计
- XA 规范
- XA 模型
- …
评论区