介绍
2PC,全称为两阶段提交(Two-Phase Commit),是一种在分布式系统中用来保证事务原子性和一致性的协议。它主要用于协调分布式数据库或分布式事务环境中的多个参与者,确保所有参与者要么一起成功提交事务,要么一起回滚事务,以保持数据的一致性。
图片
在2PC协议中有两个主要阶段:
- 准备阶段(Prepare Phase):
事务协调器接收到发起事务的客户端请求后,向所有参与该事务的资源管理器(例如数据库、服务节点等)发送“准备提交”请求。
每个资源管理器执行事务操作,并将事务相关的更改锁定但不提交,然后回复事务协调器它们是否准备好提交事务(根据各自是否能够成功完成事务而定)。
- 提交阶段(Commit Phase):
如果事务协调器收到了所有资源管理器的肯定答复,即所有参与者都准备好提交事务,则向所有参与者发出“正式提交”指令。
若协调器收到任何一个参与者的否定响应,或者在等待超时后仍有参与者未响应,则向所有参与者发出“回滚事务”的指令。
通过这种方式,2PC确保了所有节点要么全部完成事务,要么全部撤销事务,从而维护了分布式环境下的事务原子性。然而,2PC也存在一些缺点,比如单点故障问题(即事务协调器宕机可能导致事务长期阻塞)、网络分区情况下的不确定性以及性能上的潜在瓶颈。
Seata把一个分布式事务理解成一个包含了若干分支事务的全局事务。全局事务的职责是协调其下管辖的分支事务 达成一致,要么一起成功提交,要么一起失败回滚。此外,通常分支事务本身就是一个关系数据库的本地事务,下图是全局事务与分支事务的关系图:
图片
与 传统2PC 的模型类似,Seata定义了3个组件来协议分布式事务的处理过程
图片
- Transaction Coordinator (TC):事务协调器,它是独立的中间件,需要独立部署运行,它维护全局事务的运行状态,接收TM指令发起全局事务的提交与回滚,负责与RM通信协调各各分支事务的提交或回滚。
- Transaction Manager (TM):事务管理器,TM需要嵌入应用程序中工作,它负责开启一个全局事务,并最终向TC发起全局提交或全局回滚的指令。
- Resource Manager (RM):控制分支事务,负责分支注册、状态汇报,并接收事务协调器TC的指令,驱动分支(本地)事务的提交和回滚。
具体实现
案例分析:两个账户在不同的银行(张三在bank1、李四在bank2),bank1和bank2是两个微服务。交易过程是,张三给李四转账指定金额。
上述交易步骤,要么一起成功,要么一起失败,必须是一个整体性的事务。
图片
为了简化环境搭建,小编这里采用file启动seata,项目搭建也只是两个普通的SpringBoot项目,未使用微服务。
下载seata服务器
官方下载地址:https://github.com/seata/seata/releases
- registry.type=file:
registry.type=file 其类型设置为 file 时,意味着 Seata 的服务注册中心不依赖于外部的如 Nacos、Eureka、Zookeeper 等第三方注册中心,而是使用本地文件的方式来存储和管理服务节点信息。这种模式主要用于快速测试或简单的单机部署场景,因为在这种模式下无法自动发现和管理集群环境中的其他 Seata Server 节点,不具备高可用性。
- config.type=file:
config.type=file 表示 Seata 使用本地文件作为配置源。这意味着 Seata 会从指定的本地文件中读取全局事务协调器(TC)、事务管理器(TM)和资源管理器(RM)等组件所需的配置信息,而不是通过Nacos、Apollo或其他远程配置中心获取配置。这种方式同样适用于快速验证和简单部署情况,实际生产环境中可能需要结合分布式配置中心来动态更新和管理配置。
- seata安装初始化参考《SpringCloud Alibaba微服务实战之环境准备》,注意本次启动是采用file方式启动
- seata启动:/bin/seata-server.bat -m file
图片
- bank-1 和 bank-2启动:
图片
bank-1 和 bank-2服务搭建
库表建立
CREATE DATABASE `bank1` CHARACTER SET 'utf8' COLLATE 'utf8_general_ci';
CREATE TABLE `account_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`account_name` varchar(100) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL COMMENT '户主姓名',
`account_no` varchar(100) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL COMMENT '银行卡号',
`account_password` varchar(100) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL COMMENT '帐户密码',
`account_balance` double NULL DEFAULT NULL COMMENT '帐户余额',
PRIMARY KEY (`id`) USING BTREE )
ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8 COLLATE = utf8_bin ROW_FORMAT = Dynamic;
INSERT INTO `account_info` VALUES (2, '张三的账户', '1', '', 10000);
CREATE DATABASE `bank2` CHARACTER SET 'utf8' COLLATE 'utf8_general_ci';
CREATE TABLE `account_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`account_name` varchar(100) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL COMMENT '户主姓名',
`account_no` varchar(100) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL COMMENT '银行卡号',
`account_password` varchar(100) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL COMMENT '帐户密码',
`account_balance` double NULL DEFAULT NULL COMMENT '帐户余额',
PRIMARY KEY (`id`) USING BTREE)
ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8 COLLATE = utf8_bin ROW_FORMAT = Dynamic;
INSERT INTO `account_info` VALUES (3, '李四的账户', '2', NULL, 0);
备注:分别在bank1、bank2库中创建undo_log表,此表为seata框架使用
依赖引入
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.4.2</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.1</version>
</dependency>
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!--bank-2 不需要-->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
</dependencies>
定义配置
server:
port: 8081
#port: 8082
spring:
application:
name: bank-1
#name: bank-2
datasource:
url: jdbc:mysql://localhost:3306/bank1?characterEncoding=utf8&useSSL=false
#url: jdbc:mysql://localhost:3306/bank2?characterEncoding=utf8&useSSL=false
driver-class-name: com.mysql.jdbc.Driver
username: root
password: root
seata:
tx-service-group: order_tx_group #自定义事务组名称需要与seata-server中的对应
service:
vgroup-mapping:
order_tx_group: default # TC 集群(必须与seata-server保持一致)
定义mapper
# bank-1
@Update("update account_info set account_balance = account_balance + #{amount} where account_no = #{accountNo}")
int updateAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);
# bank-2
@Update("UPDATE account_info SET account_balance = account_balance + #{amount} WHERE account_no = #{accountNo}")
int updateAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);
服务调用
bank-1:
@GlobalTransactional
@Override
public void updateAccountBalance(String accountNo, Double amount) {
log.info("******** Bank1 Service Begin ... xid: {}" , RootContext.getXID());
//张三扣减金额
baseMapper.updateAccountBalance(accountNo,amount * -1);
//向李四转账
CloseableHttpClient httpclient = HttpClients.createDefault();
HttpGet httpget = new HttpGet("http://localhost:8082/bank2/transfer?amount="+amount);
httpget.addHeader(RootContext.KEY_XID,RootContext.getXID());
try{
CloseableHttpResponse response = httpclient.execute(httpget);
HttpEntity entity = response.getEntity();
String result = EntityUtils.toString(entity);
log.info("bank2 服务返回结果:"+result);
}catch (Exception e){
throw new RuntimeException("bank2 服务异常");
}
//人为制造错误
if(amount > 100){
throw new RuntimeException("bank1 make exception amount > 100");
}
}
当业务方法开启全局异常处理器后,TM注册到TC获取到一个XID,此时在业务中,服务远程访问时,此XID会被下面分支业务方法RM接收到,当各个方法处理完成后RM会向TC直接交互把结果通过XID通知给TC,最后业务方法结束后,TM会通知TC业务已经完成,TC会根据RM通知的结果来通知各个RM提交或者回滚。但是在分布式事务中,入口TM传出时不会将XID放入请求头中向其他服务传递,这样就导致全局异常捕获失效,因此需要手动将XID设置到请求头中,携带给各分支业务来避免事务失效问题。
bank-2:
@Transactional
@Override
public void updateAccountBalance(String accountNo, Double amount) {
log.info("******** Bank2 Service Begin ... xid: {}" , RootContext.getXID());
//李四增加金额
baseMapper.updateAccountBalance(accountNo,amount);
//制造异常
if(amount < 100){
throw new RuntimeException("bank1 make exception amount < 100");
}
}
服务配置seata
file.conf:
图片
registry.conf:
图片
执行流程
正常流程:
图片
回滚流程:
图片