文章目录
- 文章导图
- 什么是2PC
- 解决传统2PC方案
- XA方案
- DTP模型
- 举例:新用户注册送积分
- 总结:
- Seata方案
- 设计思想
- 执行流程
- 举例:新用户注册送积分
- Seata实现2PC事务(AT模式)
- 前提
- 整体机制
- 写隔离
- 读隔离
- 实际案例理解
- 要点说明
- 核心代码讲解
- bank1微服务代码
- bank2微服务代码
- 正常提交流程
- 回滚流程
- 测试场景
- 小结
分布式事务系列文章 |
---|
初探分布式事务:扫盲分布式事务的基础概念和理论知识点 |
图解分布式事务中的2PC与Seata方案 |
TODO:分布式事务之TCC |
TODO:事务分布式事务之MQ可靠消息最终一致性方案 |
TODO:TCC分布式事务之MQ最大努力通知 |
TODO:TCC分布式事务之综合案例分析 |
文章导图
什么是2PC
2PC即两阶段提交协议,是将整个事务流程分为两个阶段:准备阶段(Prepare phase)和提交阶段(Commit phase)。2PC中的“2”指的是两个段,“P”指的是准备阶段,“C”指的是提交阶段。下面讲个故事帮助理解:
故事背景:张三和李四是两个老朋友,他们决定一起出去吃饭。饭店老板的规定是:只有两人都付款,才能出票安排就餐。因此,他们面临一个需要协调的事务,类似于计算机科学中的“两阶段提交协议”。
准备阶段: 老板首先向张三要求付款。张三虽然有些不情愿,但还是支付了自己的部分。接着,老板又要求李四付款,李四也付了款。
提交阶段: 老板确认两人都已经付款,于是出票,两人各自拿到票后,愉快地坐下开始就餐。
但如果其中一人,比如说张三,拒绝付款,或者李四发现自己的钱不够,那么老板就不会出票,并会把已经收到的钱退还给张三。这样,虽然他们最终没有就餐,但他们的资金也没有损失。
在这个故事中,饭店老板就像事务管理器,负责协调整个过程,确保只有在所有条件都满足的情况下才执行最终的操作(出票)。而张三和李四则像事务参与者,负责各自部分的操作(付款)。如果任何一个环节出了问题,事务管理器就会回滚整个事务,确保没有人会受到损失。
在计算机中,部分关系数据库如Oracle、MySQL支持两阶段提交协议。如下图:
-
准备阶段(Prepare phase):事务管理器给每个参与者发送Prepare消息,每个数据库参与者在本地执行事务,并写本地的Undo/Redo日志,此时事务没有提交。(Undo日志是记录修改前的数据,用于数据库回滚,Redo日志是记录修改后的数据,用于提交事务后写入数据文件)
-
提交阶段(commit phase):如果事务管理器收到了参与者的执行失败或者超时消息时,直接给每个参与者发送回滚(Rollback)消息;否则,发送提交(Commit)消息;参与者根据事务管理器的指令执行提交或者回滚操作,并释放事务处理过程中使用的锁资源。注意:必须在最后阶段释放锁资源。
解决传统2PC方案
XA方案
2PC的传统方案是在数据库层面实现的,如Oracle、MySQL都支持2PC协议。为了统一标准减少行业内不必要的对接成本,需要制定标准化的处理模型及接口标准,国际开放标准组织Open Group定义了分布式事务处理模型DTP(Distributed Transaction Processing Reference Model)。
DTP模型
- AP(Application Program):即应用程序,可以理解为使用DTP分布式事务的程序。
- RM(Resource Manager):即资源管理器,可以理解为事务的参与者,一般情况下是指一个数据库实例,通过资源管理器对该数据库进行控制,资源管理器控制着分支事务。
- TM(Transaction Manager):事务管理器,负责协调和管理事务,事务管理器控制着全局事务,管理事务生命周期,并协调各个RM。全局事务是指分布式事务处理环境中,需要操作多个数据库共同完成一个工作,这个工作即是一个全局事务。
DTP模型定义TM和RM之间通讯的接口规范叫XA,简单理解为数据库提供的2PC接口协议,基于数据库的XA协议来实现2PC又称为XA方案。
以上三个角色之间的交互方式如下:
1)TM向AP提供 应用程序编程接口,AP通过TM提交及回滚事务。
2)TM交易中间件通过XA接口来通知RM数据库事务的开始、结束以及提交、回滚等。
举例:新用户注册送积分
为了让大家更明确XA方案的内容程,下面新用户注册送积分为例来说明:
执行流程如下:
1、应用程序(AP)持有用户库和积分库两个数据源。
2、应用程序(AP)通过TM通知用户库RM新增用户,同时通知积分库RM为该用户新增积分,RM此时并未提交事
务,此时用户和积分资源锁定。
3、TM收到执行回复,只要有一方失败则分别向其他RM发起回滚事务,回滚完毕,资源锁释放。
4、TM收到执行回复,全部成功,此时向所有RM发起提交事务,提交完毕,资源锁释放。
总结:
整个2PC的事务流程涉及到三个角色AP、RM、TM。AP指的是使用2PC分布式事务的应用程序;RM指的是资源管理器,它控制着分支事务;TM指的是事务管理器,它控制着整个全局事务。
1)在准备阶段RM执行实际的业务操作,但不提交事务,资源锁定;
2)在提交阶段TM会接受RM在准备阶段的执行回复,只要有任一个RM执行失败,TM会通知所有RM执行回滚操作,否则,TM将会通知所有RM提交该事务。提交阶段结束资源锁释放。
XA方案的问题:
1、需要本地数据库支持XA协议。
2、资源锁需要等到两个阶段结束才释放,性能较差。
Seata方案
- Seata是由阿里中间件团队发起的开源项目 Fescar,后更名为Seata,它是一个是开源的分布式事务框架。
- 传统2PC的问题在Seata中得到了解决,它通过对本地关系数据库的分支事务的协调来驱动完成全局事务,是工作在应用层的中间件。
- 主要优点是性能较好,且不长时间占用连接资源,它以高效并且对业务0侵入的方式解决微服务场景下面临的分布式事务问题,它目前提供AT模式(即2PC)及TCC模式的分布式事务解决方案。
设计思想
- Seata的设计目标其一是对业务无侵入,因此从业务无侵入的2PC方案着手,在传统2PC的基础上演进,并解决2PC方案面临的问题。
- Seata把一个分布式事务理解成一个包含了若干分支事务的全局事务。全局事务的职责是协调其下管辖的分支事务达成一致,要么一起成功提交,要么一起失败回滚。此外,通常分支事务本身就是一个关系数据库的本地事务。
与传统2PC 的模型类似,Seata定义了3个组件来协议分布式事务的处理过程:
- Transaction Coordinator (TC): 事务协调器,它是独立的中间件,需要独立部署运行,它维护全局事务的运行状态,接收TM指令发起全局事务的提交与回滚,负责与RM通信协调各各分支事务的提交或回滚。
- Transaction Manager ™: 事务管理器,TM需要嵌入应用程序中工作,它负责开启一个全局事务,并最终向TC发起全局提交或全局回滚的指令。
- Resource Manager (RM): 控制分支事务,负责分支注册、状态汇报,并接收事务协调器TC的指令,驱动分支(本地)事务的提交和回滚。
在Seata
的AT
模式中,TM
和RM
都作为SDK
的一部分和业务服务在一起,我们可以认为是Client
。TC
是一个独立的服务,通过服务的注册、发现将自己暴露给Client
们。
Seata
中有三大模块中,TM
和RM
是作为Seata
的客户端与业务系统集成在一起,TC
作为Seata
的服务端独立部署。
执行流程
在Seata
中,分布式事务的执行流程:
TM
开启分布式事务(TM
向TC
注册全局事务记录);- 按业务场景,编排数据库、服务等事务内资源(
RM
向TC
汇报资源准备状态); TM
结束分布式事务,事务一阶段结束(TM
通知TC
提交/回滚分布式事务);TC
汇总事务信息,决定分布式事务是提交还是回滚;TC
通知所有RM
提交/回滚资源,事务二阶段结束;
举例:新用户注册送积分
继续拿新用户注册送积分举例Seata的分布式事务过程,具体的执行流程如下:
-
用户服务的 TM 向 TC 申请开启一个全局事务,全局事务创建成功并生成一个全局唯一的XID。
-
用户服务的 RM 向 TC 注册 分支事务,该分支事务在用户服务执行新增用户逻辑,并将其纳入 XID 对应全局事务的管辖。
-
用户服务执行分支事务,向用户表插入一条记录。
-
逻辑执行到远程调用积分服务时(XID 在微服务调用链路的上下文中传播)。积分服务的RM 向 TC 注册分支事务,该分支事务执行增加积分的逻辑,并将其纳入 XID 对应全局事务的管辖。
-
积分服务执行分支事务,向积分记录表插入一条记录,执行完毕后,返回用户服务。
-
用户服务分支事务执行完毕。
-
TM 向 TC 发起针对 XID 的全局提交或回滚决议。
-
TC 调度 XID 下管辖的全部分支事务完成提交或回滚请求。
Seata实现2PC事务(AT模式)
Seata 实现分布式事务,设计了一个关键角色 UNDO_LOG (回滚日志记录表),我们在每个应用分布式事务的业务库中创建这张表,这个表的核心作用就是,将业务数据在更新前后的数据镜像组织成回滚日志,备份在 UNDO_LOG 表中,以便业务异常能随时回滚。
前提
- 基于支持本地 ACID 事务的关系型数据库。
- Java 应用,通过 JDBC 访问数据库。
整体机制
两阶段提交协议的演变:
- 一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
- 二阶段:
- 提交异步化,非常快速地完成。
- 回滚通过一阶段的回滚日志进行反向补偿。
写隔离
- 一阶段本地事务提交前,需要确保先拿到 全局锁 。
- 拿不到 全局锁 ,不能提交本地事务。
- 拿 全局锁 的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。
以一个示例来说明:
两个全局事务 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 持有的,所以不会发生 脏写 的问题。
读隔离
在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted) 。
如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。
SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。
出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。
实际案例理解
两个账户在三个不同的银行(张三在bank1、李四在bank2),bank1和bank2是两个微服务。交易过程是,张三给李四转账指定金额。
上述交易步骤,要么一起成功,要么一起失败,必须是一个整体性的事务。
交互流程如下:
1、请求bank1进行转账,传入转账金额。
2、bank1减少转账金额,调用bank2,传入转账金额。
要点说明
1、每个RM使用DataSourceProxy连接数据库,其目的是使用ConnectionProxy,使用数据源和数据连接代理的目的就是在第一阶段将undo_log和业务数据放在一个本地事务提交,这样就保存了只要有业务操作就一定有undo_log。
2、在第一阶段undo_log中存放了数据修改前和修改后的值,为事务回滚作好准备,所以第一阶段完成就已经将分支事务提交,也就释放了锁资源。
3、TM开启全局事务开始,将XID全局事务id放在事务上下文中,通过feign调用也将XID传入下游分支事务,每个分支事务将自己的Branch ID分支事务ID与XID关联。
4、第二阶段全局事务提交,TC会通知各各分支参与者提交分支事务,在第一阶段就已经提交了分支事务,这里各各参与者只需要删除undo_log即可,并且可以异步执行,第二阶段很快可以完成。
5、第二阶段全局事务回滚,TC会通知各各分支参与者回滚分支事务,通过 XID 和 Branch ID 找到相应的回滚日志,通过回滚日志生成反向的 SQL 并执行,以完成分支事务回滚到之前的状态,如果回滚失败则会重试回滚操作。
核心代码讲解
这里案例采用的是springcloud,具体用法自己去了解
bank1微服务代码
下面这段代码是张三微服务bank1的代码,这里将@GlobalTransactional注解标注在全局事务发起的Service实现方法上,开启全局事务:
- 底层实现是一个拦截器,
GlobalTransactionalInterceptor
会拦截@GlobalTransactional
注解的方法,生成全局事务ID(XID),XID会在整个分布式事务中传递。 - 在远程调用时,
spring-cloud-alibaba-seata
会拦截Feign调用将XID传递到下游李四微服务
@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {
@Autowired
AccountInfoDao accountInfoDao;
@Autowired
Bank2Client bank2Client;
@Transactional
@GlobalTransactional //开启全局事务
@Override
public void updateAccountBalance(String accountNo, Double amount) {
log.info("bank1 service begin,XID:{}", RootContext.getXID());
//扣减张三的金额
accountInfoDao.updateAccountBalance(accountNo,amount *-1);
//调用李四微服务,转账,这里会携带XID到李四微服务bank2
String transfer = bank2Client.transfer(amount);
if("fallback".equals(transfer)){
//调用李四微服务异常
throw new RuntimeException("调用李四微服务异常");
}
if(amount == 2){
//人为制造异常
throw new RuntimeException("bank1 make exception..");
}
}
}
bank2微服务代码
- bank2作为分支事务不使用
@GlobalTransactional
RootContext.getXID()
通过这个可以获取全局事务XID
@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {
@Autowired
AccountInfoDao accountInfoDao;
@Transactional
@Override
public void updateAccountBalance(String accountNo, Double amount) {
log.info("bank2 service begin,XID:{}",RootContext.getXID());
//李四增加金额
accountInfoDao.updateAccountBalance(accountNo,amount);
if(amount==3){
//人为制造异常
throw new RuntimeException("bank2 make exception..");
}
}
}
正常提交流程
下面这个流程图展示了 Seata 2PC 模型下的全局事务处理过程,分为全局事务的开启、分支事务的注册和执行、全局事务的提交,以及 undo_log 的删除。
这个过程确保了分布式事务的一致性和原子性,即使在跨多个服务的情况下,也能保证事务的完整性。
1、申请开启全局事务
- TM(Transaction Manager) 向 TC(Transaction Coordinator) 申请开启全局事务。
- RM1(Resource Manager 1) 和 RM2(Resource Manager 2) 也向 TC 注册,准备参与全局事务。
2、开启全局事务
- TM 使用
@GlobalTransactional
注解来开启全局事务。 - TC 返回全局事务ID(XID)给 TM。
3、bank1 RM 注册并执行分支事务
- RM1 注册分支事务到 TC,并获得分支事务ID(BranchId)。
- RM1 执行本地事务操作,包括写入业务数据和写入
undo_log
。 - RM1 提交本地事务,并上报分支事务处理结果给 TC。
4、bank1 RM 调用 bank2 RM 的服务
- RM1 通过 Feign 远程调用 RM2 的服务,并携带全局事务ID(XID)。
5、bank2 RM 注册并执行分支事务
- RM2 注册分支事务到 TC,并获得分支事务ID(BranchId)。
- RM2 执行本地事务操作,包括写入业务数据和写入
undo_log
。 - RM2 提交本地事务,并上报分支事务处理结果给 TC。
6、提交全局事务
- TM 向 TC 发出提交全局事务的请求。
- TC 调度并提交 RM1 和 RM2 的分支事务。
7、删除 undo_log
- RM1 和 RM2 在分支事务提交成功后,分别删除对应的
undo_log
回滚流程
下面这段流程图展示了在 Seata 2PC 模型下,全局事务处理失败时的回滚过程。包括全局事务的开启、分支事务的注册和执行、分支事务失败后的回滚操作,以及上报回滚状态的步骤。
这个过程确保了在事务失败时,所有参与的服务能够正确地回滚到一致状态,保证分布式事务的一致性和完整性。
1、开启全局事务
- TM(Transaction Manager) 使用
@GlobalTransactional
注解向 TC(Transaction Coordinator) 申请开启全局事务。 - TC 返回全局事务ID(XID)给 TM。
2、bank1 RM 注册并执行分支事务
- RM1(Resource Manager 1) 向 TC 注册分支事务,并获得分支事务ID(BranchId)。
- RM1 执行本地事务操作,包括写入业务数据、写入
undo_log
和提交本地事务。 - RM1 向 TC 上报分支事务处理结果,但结果失败。
3、bank1 RM 调用 bank2 RM 的服务
- RM1 通过 Feign 远程调用 RM2(Resource Manager 2) 的服务,并携带全局事务ID(XID)。
4、bank2 RM 注册并执行分支事务
- RM2 向 TC 注册分支事务,并获得分支事务ID(BranchId)。
- RM2 执行本地事务操作,包括写入业务数据、写入
undo_log
和提交本地事务。 - RM2 向 TC 上报分支事务处理结果,但结果失败。
5、回滚全局事务
- TM 向 TC 发出回滚全局事务的请求。
- TC 调度并回滚 RM1 和 RM2 的分支事务。
6、回滚分支事务
- RM1 执行分支事务的回滚操作,包括解锁
undo_log
、执行回滚操作、清除undo_log
和提交本地事务。 - TC 指示 RM2 回滚分支事务。
- RM2 执行分支事务的回滚操作,包括解锁
undo_log
、执行回滚操作、清除undo_log
和提交本地事务。
7、上报回滚状态
- RM1 向 TC 上报分支事务的回滚状态。
- RM2 向 TC 上报分支事务的回滚状态。
测试场景
seata实现2PC测试以下场景,结果应该都能正常跟本地事务一样
- 张三向李四转账成功。
- 李四事务失败,张三事务回滚成功。
- 张三事务失败,李四事务回滚成功。
- 分支事务超时测试。
小结
本节讲解了传统2PC(基于数据库XA协议)和Seata实现2PC的两种2PC方案,由于Seata的0侵入性并且解决了传统2PC长期锁资源的问题,所以推荐采用Seata实现2PC。
Seata实现2PC要点:
1、全局事务开始使用 @GlobalTransactional标识 。
2、每个本地事务方案仍然使用@Transactional标识。
3、每个数据都需要创建undo_log表,此表是seata保证本地事务一致性的关键。
参考文章:
https://seata.apache.org/zh-cn/docs/dev/mode/at-mode
黑马分布式事务