@[toc]
还是那句老话,网上对于分布式事务解说实践比拟多,案例比拟少,最近松哥想通过几个案例,来和大家把常见的分布式事务解决方案过一遍,后面我和大家分享了 Seata 中的 AT 模式,明天咱们来看 TCC 模式。
TCC 模式和松哥后面跟大家演示的 AT 模式有很多类似的中央,也有很多不同的中央,之前读者麻瓜大佬投稿过一篇文章讲 TCC 模式:
- 分布式事务 TCC 原来是这么来的!
感兴趣的小伙伴也能够先看看。
明天咱们还是先来整一个案例,把案例剖析完了,大家基本上就明确 TCC 是咋回事了,同时也就明确 TCC 和 AT 之间的差别了。
1. 上代码
还是 Seata 官网的那个仓库,它里边有 TCC 的案例,不过因为它这个仓库案例较多,须要下载的依赖也较多,所以全副导入会容易导入失败,上面是松哥整顿好的案例(去除了不必要的工程),能够间接导入,大家能够在公号后盾回复 seata-demo
下载这个案例。
官网给的 TCC 案例是一个经典的转账案例,很多小伙伴第一次接触事务的时候,学的案例就是转账,所以这个业务对于大家来说很好了解。
1.1 业务流程
我先来说一下这个案例的业务逻辑,而后咱们再来看代码,他的流程是这样的:
- 这个我的项目分两局部,provider 和 consumer(要是只有一个我的项目也就不存在分布式事务问题了)。
- provider 中提供两个转账相干的接口,一个是负责解决扣除账户余额的接口,另一个则是负责给账户增加金额的接口。在该案例中,这两个我的项目中由一个 provider 提供,在实际操作中,小伙伴们也能够用两个 provider 来别离提供这两个接口。
- provider 提供的接口通过 dubbo 裸露进来,consumer 则通过 dubbo 来援用这些裸露进去的接口。
- 转账操作分两步:首先调用 FirstTccAction 从一个账户中减除金额;而后调用 SecondTccAction 给一个账户减少金额。两个操作要么同时胜利,要么同时失败。
有人可能会说,都是 provider 提供的接口,也算分布式事务?算!当然算!尽管下面提到的两个接口都是 provider 提供的,然而因为这里存在两个数据库,不同接口操作不同的数据库,所以仍然是分布式事务。
这是这个我的项目大抵上要做的事件。
1.2 案例配置
官网的案例用的是 H2 数据库,这个大家不不便看成果,因而,咱们这里略微做一点配置,将数据库换为 MySQL,这样咱们不便看转账成果。
具体配置步骤如下:
- 首先在本地 MySQL 中创立两个数据库:
创立两个空的库就行了,不必创立表,我的项目启动的时候会主动初始化表。
- transfer_from_db:转出账户的库。
- transfer_to_db:转入账户的库。
- 批改我的项目的数据库连接池版本。
官网给的案例有点小问题,间接启动会报错,起因在于案例中应用的 DBCP 和 MyBatis 版本抵触,须要大家先在 pom.xml 中把 DBCP 的版本号改为 1.4,如下:
<properties>
<curator.version>4.2.0</curator.version>
<commons-dbcp.version>1.4</commons-dbcp.version>
<h2.version>1.4.181</h2.version>
<mybatis.version>3.5.6</mybatis.version>
<mybatis.spring.version>1.3.1</mybatis.spring.version>
</properties>
而后咱们再退出 MySQL 驱动,如下:
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.25</version>
</dependency>
尽管案例中有的货色有点像老古董了,然而本着能简则简的准则,我就不去批改了,咱们只有我的项目跑起来,可能帮忙咱们了解 TCC 就行了。
另外,这个我的项目援用的 Dubbo 版本也有问题,咱们手动给其加上版本号(默认的 3.0.1 这个版本有问题,松哥亲测 2.7.3 可用):
<dependency>
<groupId>org.apache.dubbo</groupId>
<artifactId>dubbo</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring</artifactId>
</exclusion>
</exclusions>
<version>2.7.3</version>
</dependency>
- 批改数据库配置。
数据库配置有两个,一个是转账转出数据源,另一个是转账转入数据源,相干配置在 src/main/resources/db-bean
目录下。
先来批改 from-datasource-bean.xml,次要批改数据源,如下:
<bean id="fromAccountDataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close">
<property name="driverClassName">
<value>com.mysql.cj.jdbc.Driver</value>
</property>
<property name="url">
<value>jdbc:mysql:///transfer_from_db?serverTimezone=Asia/Shanghai</value>
</property>
<property name="username">
<value>root</value>
</property>
<property name="password">
<value>123</value>
</property>
</bean>
改四个货色:数据库驱动、数据库连贯地址、数据库用户名、数据库明码。
再来批改 to-datasource-bean.xml:
<bean id="toAccountDataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close">
<property name="driverClassName">
<value>com.mysql.cj.jdbc.Driver</value>
</property>
<property name="url">
<value>jdbc:mysql:///transfer_to_db?serverTimezone=Asia/Shanghai</value>
</property>
<property name="username">
<value>root</value>
</property>
<property name="password">
<value>123</value>
</property>
</bean>
这两个配置次要是连贯的数据库不同。
OK,如此之后,咱们的配置就算实现了。
1.3 案例运行
案例运行分为两局部。
1.3.1 启动 Provider
找到 src/main/java/io/seata/samples/tcc/transfer/starter/TransferProviderStarter.java
,执行 main 办法,间接执行即可,执行之后,控制台看到如下信息就示意我的项目启动胜利并且表构造以及表数据初始化胜利:
启动过程中,可能会有一个空指针异样,不过并不影响应用,所以能够疏忽之。
我的项目启动胜利之后,咱们能够查看一下刚刚创立好的两个数据库,每个数据库里边都有三张表:
先来看转出的库:
account 表中有两条记录:
这张表中有 A、B 两个账户,各有 100 块钱,各自被解冻的资金(freezed_amount)都为 0。
business_action 和 business_activity 都是空表。
再来看转入的库:
能够看到,和 transfer_from_db 截然不同的三张表,就是 account 中的用户是 C,也有 100 块钱。
1.3.2 开启转账逻辑
找到 src/main/java/io/seata/samples/tcc/transfer/starter/TransferApplication.java
,这个里边的 main 办法中有两个测试方法,doTransferSuccess
会转账胜利,doTransferFailed
则会转账失败。
这两个办法咱们首先正文掉 doTransferFailed
,运行 doTransferSuccess
办法,控制台输入日志如下:
这示意转账胜利。
此时查看数据库,A 账户少了 10 块钱,C 账户多了 10 块钱:
而后咱们正文掉 doTransferSuccess
,运行 doTransferFailed
办法,后果如下:
能够看到,转账失败,此时查看数据库,发现两个库中的数据均未产生扭转,阐明数据曾经回滚了。
好啦,这就是官网给咱们提供的一个典型的转账案例。那么这个转账案例是怎么实现的?接下来咱们来剖析一下代码,代码剖析完了,大家就明确什么是 TCC 了!
2. 代码剖析
这里对于 Dubbo 的调用逻辑,松哥就不多说了,置信大家都会,咱们次要来说说跟分布式事务相干的代码。
首先,这个我的项目中提供了两个接口:
- FirstTccAction
- SecondTccAction
这两个接口别离代表了转账时候的两个步骤:
- FirstTccAction:这个接口中用来解决转出账户余额问题(减钱),这个接口中应用的数据源就是 transfer_from_db。
- SecondTccAction:这个接口用来解决转入账户问题(加钱),这个接口中应用的数据源就是 transfer_to_db。
这两个接口的定义其实十分相似,只有咱们看懂其中一个,另外一个就很容易懂了。
2.1 FirstTccAction
这是把钱转出去的接口,咱们先来看接口的定义:
public interface FirstTccAction {
/**
* 一阶段办法
*
* @param businessActionContext
* @param accountNo
* @param amount
*/
@TwoPhaseBusinessAction(name = "firstTccAction", commitMethod = "commit", rollbackMethod = "rollback")
public boolean prepareMinus(BusinessActionContext businessActionContext,
@BusinessActionContextParameter(paramName = "accountNo") String accountNo,
@BusinessActionContextParameter(paramName = "amount") double amount);
/**
* 二阶段提交
* @param businessActionContext
* @return
*/
public boolean commit(BusinessActionContext businessActionContext);
/**
* 二阶段回滚
* @param businessActionContext
* @return
*/
public boolean rollback(BusinessActionContext businessActionContext);
}
能够看到,接口中有三个办法:
- prepareMinus
- commit
- rollback
这三个办法的名字并不是固定的,能够本人定义,咱们来看这三个办法是干嘛的(实现类是 FirstTccActionImpl):
- prepareMinus:这个办法看名字就晓得能够在该办法中做筹备工作,转账的筹备工作都是什么呢?查看账户是否存在、解冻转账资金等等操作都能够在这个办法中实现。以下面的案例为例(A 账户转账 10 块钱到 C 账户),具体来说,在
FirstTccActionImpl#prepareMinus
办法中:
@Override
public boolean prepareMinus(BusinessActionContext businessActionContext, final String accountNo, final double amount) {
// 分布式事务 ID
final String xid = businessActionContext.getXid();
return fromDsTransactionTemplate.execute(new TransactionCallback<Boolean>(){
@Override
public Boolean doInTransaction(TransactionStatus status) {
try {
// 校验账户余额
Account account = fromAccountDAO.getAccountForUpdate(accountNo);
if(account == null){throw new RuntimeException("账户不存在");
}
if (account.getAmount() - amount < 0) {throw new RuntimeException("余额有余");
}
// 解冻转账金额
double freezedAmount = account.getFreezedAmount() + amount;
account.setFreezedAmount(freezedAmount);
fromAccountDAO.updateFreezedAmount(account);
System.out.println(String.format("prepareMinus account[%s] amount[%f], dtx transaction id: %s.", accountNo, amount, xid));
return true;
} catch (Throwable t) {t.printStackTrace();
status.setRollbackOnly();
return false;
}
}
});
}
这个办法就干了三件事:1. 查看 A 账户是否存在,不存在就抛异样;2. 查看 A 账户余额是否小于 10 块钱,如果是,抛异样(钱不够,没法转账);3. 批改 A 账户的数据库记录,将冻结资金标记进去(A 账户的 freezed_amount 字段将被批改为 10)。
- prepareMinus 办法所做的事件都属于一阶段的事件。
- prepareMinus 办法有一个 @TwoPhaseBusinessAction 注解,用来标记事务,该注解中,commitMethod 注解示意事务提交的办法,rollbackMethod 示意事务回滚的办法,这两个办法都是该事务中定义的办法。
- prepareMinus 办法是由开发者本人调用,因而能够自定义参数传进来,而 commit 和 rollback 办法则是由框架来调用(如果一阶段出问题了,二阶段主动回滚;一阶段没问题,二阶段就主动提交),然而在框架调用的时候,咱们可能还是须要一些业务相干的参数,所以在 prepareMinus 办法中,咱们能够通过 @BusinessActionContextParameter 注解来把在 commit 以及 rollback 中须要的参数绑定到 BusinessActionContext 中,未来在 commit 和 rollback 办法中就能够获取到这些参数。
- commit 办法是二阶段提交的办法,如果一阶段的工作都顺利进行完了,则进行二阶段的事务提交。具体实现在
FirstTccActionImpl#commit
办法中:
@Override
public boolean commit(BusinessActionContext businessActionContext) {
// 分布式事务 ID
final String xid = businessActionContext.getXid();
// 账户 ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
// 转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
return fromDsTransactionTemplate.execute(new TransactionCallback<Boolean>() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try{Account account = fromAccountDAO.getAccountForUpdate(accountNo);
// 扣除账户余额
double newAmount = account.getAmount() - amount;
if (newAmount < 0) {throw new RuntimeException("余额有余");
}
account.setAmount(newAmount);
// 开释账户 解冻金额
account.setFreezedAmount(account.getFreezedAmount() - amount);
fromAccountDAO.updateAmount(account);
System.out.println(String.format("minus account[%s] amount[%f], dtx transaction id: %s.", accountNo, amount, xid));
return true;
}catch (Throwable t){t.printStackTrace();
status.setRollbackOnly();
return false;
}
}
});
}
看看这个办法的执行逻辑:
- 首先从 BusinessActionContext 对象中把 prepareMinus 中的那几个参数拎进去。
- 而后判断一下账户余额是否短缺(是否够转账)。
- 更新账户余额和解冻的金额(余额失常转账,解冻的金额归零)。
这就是 commit 办法所做的事件。
- rollback 办法是二阶段的回滚办法,如果一阶段的办法执行出问题了,二阶段就要回滚,回滚要做的事件就是反向弥补操作,具体实现在
FirstTccActionImpl#rollback
办法中:
@Override
public boolean rollback(BusinessActionContext businessActionContext) {
// 分布式事务 ID
final String xid = businessActionContext.getXid();
// 账户 ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
// 转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
return fromDsTransactionTemplate.execute(new TransactionCallback<Boolean>() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try{Account account = fromAccountDAO.getAccountForUpdate(accountNo);
if(account == null){
// 账户不存在,回滚什么都不做
return true;
}
// 开释解冻金额
account.setFreezedAmount(account.getFreezedAmount() - amount);
fromAccountDAO.updateFreezedAmount(account);
System.out.println(String.format("Undo prepareMinus account[%s] amount[%f], dtx transaction id: %s.", accountNo, amount, xid));
return true;
}catch (Throwable t){t.printStackTrace();
status.setRollbackOnly();
return false;
}
}
});
}
能够看到,回滚的反向弥补其实很简略,先看下账户是否存在,账户存在的话,把解冻的资金勾销解冻就行了。
这就是把钱转出去的整个过程。
2.2 SecondTccAction
这是把钱转进来的接口。
public interface SecondTccAction {
/**
* 一阶段办法
*
* @param businessActionContext
* @param accountNo
* @param amount
*/
@TwoPhaseBusinessAction(name = "secondTccAction", commitMethod = "commit", rollbackMethod = "rollback")
public boolean prepareAdd(BusinessActionContext businessActionContext,
@BusinessActionContextParameter(paramName = "accountNo") String accountNo,
@BusinessActionContextParameter(paramName = "amount") double amount);
/**
* 二阶段提交
* @param businessActionContext
* @return
*/
public boolean commit(BusinessActionContext businessActionContext);
/**
* 二阶段回滚
* @param businessActionContext
* @return
*/
public boolean rollback(BusinessActionContext businessActionContext);
}
接口的实现类:
public class SecondTccActionImpl implements SecondTccAction {
/**
* 加钱账户 DAP
*/
private AccountDAO toAccountDAO;
private TransactionTemplate toDsTransactionTemplate;
/**
* 一阶段筹备,转入资金 筹备
* @param businessActionContext
* @param accountNo
* @param amount
* @return
*/
@Override
public boolean prepareAdd(final BusinessActionContext businessActionContext, final String accountNo, final double amount) {
// 分布式事务 ID
final String xid = businessActionContext.getXid();
return toDsTransactionTemplate.execute(new TransactionCallback<Boolean>(){
@Override
public Boolean doInTransaction(TransactionStatus status) {
try {
// 校验账户
Account account = toAccountDAO.getAccountForUpdate(accountNo);
if(account == null){System.out.println("prepareAdd: 账户 ["+accountNo+"] 不存在, txId:" + businessActionContext.getXid());
return false;
}
// 待转入资金作为 不可用金额
double freezedAmount = account.getFreezedAmount() + amount;
account.setFreezedAmount(freezedAmount);
toAccountDAO.updateFreezedAmount(account);
System.out.println(String.format("prepareAdd account[%s] amount[%f], dtx transaction id: %s.", accountNo, amount, xid));
return true;
} catch (Throwable t) {t.printStackTrace();
status.setRollbackOnly();
return false;
}
}
});
}
/**
* 二阶段提交
* @param businessActionContext
* @return
*/
@Override
public boolean commit(BusinessActionContext businessActionContext) {
// 分布式事务 ID
final String xid = businessActionContext.getXid();
// 账户 ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
// 转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
return toDsTransactionTemplate.execute(new TransactionCallback<Boolean>() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try{Account account = toAccountDAO.getAccountForUpdate(accountNo);
// 加钱
double newAmount = account.getAmount() + amount;
account.setAmount(newAmount);
// 解冻金额 革除
account.setFreezedAmount(account.getFreezedAmount() - amount);
toAccountDAO.updateAmount(account);
System.out.println(String.format("add account[%s] amount[%f], dtx transaction id: %s.", accountNo, amount, xid));
return true;
}catch (Throwable t){t.printStackTrace();
status.setRollbackOnly();
return false;
}
}
});
}
/**
* 二阶段回滚
* @param businessActionContext
* @return
*/
@Override
public boolean rollback(BusinessActionContext businessActionContext) {
// 分布式事务 ID
final String xid = businessActionContext.getXid();
// 账户 ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
// 转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
return toDsTransactionTemplate.execute(new TransactionCallback<Boolean>() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try{Account account = toAccountDAO.getAccountForUpdate(accountNo);
if(account == null){
// 账户不存在, 无需回滚动作
return true;
}
// 解冻金额 革除
account.setFreezedAmount(account.getFreezedAmount() - amount);
toAccountDAO.updateFreezedAmount(account);
System.out.println(String.format("Undo prepareAdd account[%s] amount[%f], dtx transaction id: %s.", accountNo, amount, xid));
return true;
}catch (Throwable t){t.printStackTrace();
status.setRollbackOnly();
return false;
}
}
});
}
}
看懂了下面的 FirstTccActionImpl,SecondTccActionImpl 这个接口松哥就不啰嗦了,简略说一下:
- 在 prepareAdd 办法中,判断转入账户是否存在,如果存在的话,就把转入资金先存入解冻的那个字段中(不是间接加到账户余额上)。
- 在 commit 办法中,事务提交的时候,把解冻的资金退出到账户余额中,同时革除解冻金额。
- 在 rollback 办法中,事务回滚的时候,反向弥补把解冻的资金革除即可。
这就是把钱收进来的大抵过程。
2.3 TransferServiceImpl
具体转账是在 TransferServiceImpl 类中,在它的 transfer 办法中,去调用 FirstTccAction 和 SecondTccAction,一起来看下:
public class TransferServiceImpl implements TransferService {
private FirstTccAction firstTccAction;
private SecondTccAction secondTccAction;
/**
* 转账操作
* @param from 扣钱账户
* @param to 加钱账户
* @param amount 转账金额
* @return
*/
@Override
@GlobalTransactional
public boolean transfer(final String from, final String to, final double amount) {
// 扣钱参与者,一阶段执行
boolean ret = firstTccAction.prepareMinus(null, from, amount);
if(!ret){
// 扣钱参与者,一阶段失败; 回滚本地事务和分布式事务
throw new RuntimeException("账号:["+from+"] 预扣款失败");
}
// 加钱参与者,一阶段执行
ret = secondTccAction.prepareAdd(null, to, amount);
if(!ret){throw new RuntimeException("账号:["+to+"] 预收款失败");
}
System.out.println(String.format("transfer amount[%s] from [%s] to [%s] finish.", String.valueOf(amount), from, to));
return true;
}
public void setFirstTccAction(FirstTccAction firstTccAction) {this.firstTccAction = firstTccAction;}
public void setSecondTccAction(SecondTccAction secondTccAction) {this.secondTccAction = secondTccAction;}
}
来看一下具体的转账逻辑:
- 首先注入刚刚的 FirstTccAction 和 SecondTccAction,如果这是一个微服务项目,那就在这里把各自的 Feign 搞进来。
- transfer 办法就执行具体的转账逻辑,该办法加上 @GlobalTransactional 注解。这个办法中次要是去调用 prepareXXX 实现一阶段的事件,如果一阶段出问题了,那么就会抛出异样,则事务会回滚(二阶段),回滚就会主动调用 FirstTccAction 和 SecondTccAction 各自的 rollback 办法(反向弥补);如果一阶段执行没问题,则二阶段就调用 FirstTccAction 和 SecondTccAction 的 commit 办法,实现提交。
这就是大抵的转账逻辑。
3. TCC Vs AT
通过下面的剖析,置信小伙伴们对 TCC 曾经有一些感觉了。
那么什么是 TCC?
TCC 是 Try-Confirm-Cancel 英文单词的简写。
在 TCC 模式中,一个事物是通过 Do-Commit/Rollback 来实现的,开发者须要给每一个服务间调用的操作接口,都提供一套 Try-Confirm/Cancel 接口,这套接口就相似于咱们下面的 prepareXXX/commit/rollback 接口。
再举一个简化的电商案例,用户领取实现的时候由先订单服务解决,而后调用商品服务去减库存,这两个操作同时胜利或者同时失败,这就波及到分布式事务了:在 TCC 模式下,咱们须要 3 个接口。首先是减库存的 Try 接口,在这里,咱们要查看业务数据的状态、查看商品库存够不够,而后做资源的预留,也就是在某个字段上设置预留的状态,而后在 Confirm 接口里,实现库存减 1 的操作,在 Cancel 接口里,把之前预留的字段重置(预留的状态其实就相似于后面案例的冻结资金字段 freezed_amount
)。
为什么搞得这么麻烦呢?分成三个步骤来做有一个益处,就是在出错的时候,可能顺利的实现数据库重置(反向弥补),并且,只有咱们 prepare 中的逻辑是正确的,那么即便 confirm 执行出错了,咱们也能够进行重试。
咱们再来看上面一张图:
依据两阶段行为模式的不同,咱们将分支事务划分为 Automatic (Branch) Transaction Mode 和 TCC (Branch) Transaction Mode。
AT 模式基于反对本地 ACID 事务的关系型数据库:
- 一阶段 prepare 行为:在本地事务中,一并提交业务数据更新和相应回滚日志记录。
- 二阶段 commit 行为:马上胜利完结,主动异步批量清理回滚日志。
- 二阶段 rollback 行为:通过回滚日志,主动 生成弥补操作,实现数据回滚。
对于 AT 这块,如果小伙伴们不相熟,能够参考松哥后面的文章:
- 五分钟带你体验一把分布式事务!so easy!
相应的,TCC 模式,不依赖于底层数据资源的事务反对:
- 一阶段 prepare 行为:调用自定义的 prepare 逻辑。
- 二阶段 commit 行为:调用自定义的 commit 逻辑。
- 二阶段 rollback 行为:调用自定义的 rollback 逻辑。
所谓 TCC 模式,是指反对把自定义的分支事务纳入到全局事务的治理中。
回顾后面的案例,小伙伴们发现,分布式事务两阶段提交,在 TCC 中,prepare、commit 以及 rollback 中的逻辑都是咱们本人写的,因而说 TCC 不依赖于底层数据资源的事务反对。
相比于 AT 模式,TCC 须要咱们本人实现 prepare、commit 以及 rollback 逻辑,而在 AT 模式中,commit 和 rollback 都不必咱们去管,Seata 会主动帮咱们实现。
4. 小结
好啦,明天这篇文章松哥就和大家简略分享一下 Seata 中的 TCC 模式,倡议小伙伴们肯定先跑一下文章中的案例,而后再去看剖析,就很容易懂了~
分布式事务的其余解决方案,咱们前面再持续聊~
公众号江南一点雨后盾回复 seata-demo
,能够下载本文案例。