上篇中我们讲述了seata的基于2PC的AT事物实战篇。在下篇中我们将会非常详细的描述一下如何利用seata来实现TCC事务补偿机制的原理。
目前网上所有的对于seata的TCC讲解只有一篇阿里原本的seata-tcc,它原本自带的这个例子有如下几个缺点:
然后网上所有的博客全部是围绕着这篇helloworld级别的例子而讲,其实很多都是抄袭,没有一篇融入了自己的领会与思想,也没有去把原本的例子按照生产级去做分离,这显然会误导很多读者。
因此,我们这次就在原本阿里官方的例子上做生产级别的增强,使得它可以适应你正要准备做的生产环境全模拟。
还记得我们在上曾经出现过这么一个例子用于详细描述TCC描述事务的原理吧?
今天我们就会围绕着这个例子来进一步用代码演示它。所有代码我已经上传到了我的GIT上了,地址在这:https://github.com/mkyuangithub/mkyuangithub
我们假设有这么一个业务场景:
你的公司是一家叫moneyking的第三方支付公司,连接着几个主要的银行支付渠道;
现在有一个帐户A要通过工行向另一个位于招商银行的B帐户转帐;
转帐要么成功要么失败;
于是我们结合着例子创建了3个项目:
tcc-bank-cmb和tcc-bank-icbc都是dubbo provider,它们分别连接着自己的数据库(不同的url)。
两个不同的schema,一个schema叫bank_icbc,一个schema叫bank_cmb,每个schema中的表结构相同。
我们下面给出相关的建表语句,每个业务表内的undo_log请各位看上篇冬日魔幻之旅-seata+dubbo+nacos+springboot解决分布式事务的全网段唯一实践之作(上)中所介绍的(内含有undo_log表建表语句)。
CREATE TABLE `bank_account` (
`account_id` varchar(32) COLLATE utf8_bin NOT NULL,
`amount` double(11,2) DEFAULT '0.00',
`freezed_amount` double(11,2) DEFAULT '0.00',
PRIMARY KEY (`account_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
tcc-bank-icbc和tcc-bank-cmb分别连接着这2个schema。而tcc-money-king就是一个consumer,它来模拟你所在的那家第三方支付公司,所有的客户都是通过tcc-money-king来进行转帐的。
如上篇中一样,我们在讲述具体的代码前先要把tcc如何在seata中实现的一些个坑给“填了”。
和seata中的AT模式不同TCC的全局事务不需要你设置datasourceProxy代理,它只需要把事务范围和事务组申明好就可以了。
我们这边的事务组如下所示:
在我们的tcc-money-king中有一个业务方法,在这个业务方法中只需要如此使用@GlobalTransaction申明即可启用seata的tcc机制
没错!截止发稿稿为止seata-1.0GA的tcc不支持@Service, @Reference这样注解方式的dubbo发布,它虽然不会出错可是会使得整个tcc事务失效(AT模式中是完全可以使用注解模式的,TCC模式目前还不支持),只有那些使用普通的spring的.xml配置来申明的provider和reference才能享受tcc的“盛餐”。
那么这对于我们的spring boot工程来说岂不是很“恶心”的一件事?不要急,笔者已经探索出来了一条“熊掌与鱼兼得”法,即混用springboot和普通spring .xml文件配置。
即,只对dubbo bean进行.xml配置而对其它我们坚持可以使用spring boot的全注解方法来搭建整个项目的框架,见下例。
这边除了dubbo和一个比较特殊的transactionTemplate需要使用.xml,其它我们照样可以使用spring boot的全注解配置yyaa,只需要在我们的XxxConfig文件内写上这么一句即可:
@Configuration
@ImportResource(locations = { "spring/spring-bean.xml", "spring/dubbo-bean.xml" })
public class TccBankConfig {
然后在你使用到的地方比如说我们在tcc-money-king中使用了.xml文件配置一个dubbo的引用,那么此时你只需要在你要Reference的Service方法内@Autowired一下即可,如下例:
整个tcc它围绕着try, confirm, cancel这3个方法来运作的。这使得你需要使用tcc事务的话就必须对原有代码有侵入性。可是seata在这方面做的很好,它j 通过远程调用、AOP来做的全局事务切入进而实现这一过程的。
所以在seata tcc编程中最最重要的有这么几个元素:
下面我们就以实例来感受seata tcc是如何做到尽量少侵入业务代码、又能做到性能最优、同时做到数据的最终一致性吧。
pom.xml
4.0.0
org.sky.demo
nacos-parent
0.0.1-SNAPSHOT
org.sky.demo
tcc-bank-icbc
0.0.1
tcc-bank-icbc
Demo project Dubbo+Nacos+SeataTCC
UTF-8
org.mybatis
mybatis
org.mybatis
mybatis-spring
org.springframework.boot
spring-boot-starter-jdbc
org.springframework.boot
spring-boot-starter-logging
org.apache.dubbo
dubbo
org.apache.curator
curator-framework
org.apache.curator
curator-recipes
mysql
mysql-connector-java
com.alibaba
druid
org.springframework.boot
spring-boot-starter-test
test
org.spockframework
spock-core
test
org.spockframework
spock-spring
org.springframework.boot
spring-boot-configuration-processor
true
org.springframework.boot
spring-boot-starter-log4j2
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-logging
org.springframework.boot
spring-boot-starter-tomcat
org.aspectj
aspectjweaver
com.lmax
disruptor
redis.clients
jedis
com.google.guava
guava
com.alibaba
fastjson
org.apache.dubbo
dubbo-registry-nacos
com.alibaba.nacos
nacos-client
org.sky.demo
skycommon
${skycommon.version}
io.seata
seata-all
com.alibaba.boot
nacos-config-spring-boot-starter
nacos-client
com.alibaba.nacos
io.netty
netty-all
org.projectlombok
lombok
${project.artifactId}
src/main/java
src/test/java
org.apache.maven.plugins
maven-compiler-plugin
org.springframework.boot
spring-boot-maven-plugin
-Dfile.encoding=UTF-8
repackage
org.apache.maven.plugins
maven-war-plugin
2.6
false
src/main/resources
application*.properties
src/main/webapp
META-INF/resources
**/**
src/main/resources
true
application.properties
application.properties
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driverClassName=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://192.168.56.101:3306/bank_icbc?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.username=icbc
spring.datasource.password=111111
spring.datasource.initialize=false
spring.datasource.initialSize=5
spring.datasource.minIdle=5
spring.datasource.maxActive: 20
spring.datasource.maxWait: 30000
spring.datasource.validationQuery=SELECT 1 FROM DUAL
spring.datasource.testWhileIdle=true
spring.datasource.testOnBorrow=false
spring.datasource.testOnReturn=false
spring.datasource.poolPreparedStatements=true
spring.datasource.maxPoolPreparedStatementPerConnectionSize=128
logging.config=classpath:log4j2.xml
由于我们对于dubbo需要使用.xml文件的方式配置,因此我们的application.properties文件内容相对简单
TccBankConfig.java
package org.sky.tcc.bank.icbc.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import com.alibaba.druid.pool.DruidDataSource;
import io.seata.spring.annotation.GlobalTransactionScanner;
@Configuration
@ImportResource(locations = { "spring/spring-bean.xml", "spring/dubbo-bean.xml" })
public class TccBankConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DruidDataSource druidDataSource() {
return new DruidDataSource();
}
@Bean
public DataSourceTransactionManager transactionManager(DruidDataSource druidDataSource) {
return new DataSourceTransactionManager(druidDataSource);
}
@Bean
public JdbcTemplate jdbcTemplate(DruidDataSource druidDataSource) {
return new JdbcTemplate(druidDataSource);
}
@Bean
public GlobalTransactionScanner globalTransactionScanner() {
return new GlobalTransactionScanner("tcc-bank-icbc", "demo-tx-grp");
}
}
这个,就是我们的全局配置类,在这个配置类内对于datasource, transaction manager, global transactional我们使用的是全注解。
我们在spring/spring-bean.xml文件内申明了transactional template
spring/spring-bean.xml
PROPAGATION_REQUIRES_NEW
对于dubbo我们使用的是spring/dubbo-bean.xml来配置的
spring/dubbo-bean.xml
我们可以看到在这个dubbo-bean.xml文件中我们配置了一个核心的org.sky.tcc.bank.icbc.dubbo.MinusMoneyAction,我们先来看这个MinusMoneyAction。
因为我们是从:
工行划款;
招行打款;
因此我们相应的在tcc-bank-cmb中还有一个核心的dubbo叫PlusMoneyAction。
MinusMoneyAction的接口类,注意此接口类为一个“残根”即“被调用者”,因此我们把它放置于了skycommon工程内了。
package org.sky.tcc.bank.icbc.dubbo;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
public interface MinusMoneyAction {
public String sayHello() throws RuntimeException;
/**
* 一阶段从from帐户扣钱
*
* @param businessActionContext
* @param accountNo
* @param amount
*/
@TwoPhaseBusinessAction(name = "minusMoneyAction", 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);
}
我们可以通过实现类看到它其实是事先了tcc的3个阶段:
这3个方法的实现就是让我们在尽量少破坏业务代码的方法下实现tcc补偿式事务的。这3个方法是相当特殊的,它们的调用为“被seata server端全自动异步回调”,即不需要你try if xxx catch exception rollback的,你要做的只是告诉业务方法在何种状态它应该要rollback;何种状态属于调用成功即自动commit。一切都是自动的。
而这边的commit也不是我们传统意义的数据库层面的commit。
让我们来一起看一下它的实现类
MinusMoneyActionImpl.java
package org.sky.tcc.bank.icbc.dubbo;
import org.sky.service.BaseService;
import org.sky.tcc.bank.icbc.dao.TransferMoneyDAO;
import org.sky.tcc.bean.AccountBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
public class MinusMoneyActionImpl extends BaseService implements MinusMoneyAction {
/**
* 扣钱账户 DAO
*/
@Autowired
private TransferMoneyDAO transferMoneyDAO;
/**
* 扣钱数据源事务模板
*/
@Autowired
private TransactionTemplate transactionTemplate;
@Override
public String sayHello() throws RuntimeException {
return "hi I am icbc-dubbo";
}
@Override
public boolean prepareMinus(BusinessActionContext businessActionContext, String accountNo, double amount) {
logger.info("==========into prepareMinus");
// 分布式事务ID
final String xid = RootContext.getXID();
return transactionTemplate.execute(new TransactionCallback() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try {
// 校验账户余额
AccountBean account = transferMoneyDAO.getAccountForUpdate(accountNo);
if (account == null) {
throw new RuntimeException("账户不存在");
}
if (account.getAmount() - amount < 0) {
throw new RuntimeException("余额不足");
}
// 冻结转账金额
double freezedAmount = account.getFreezedAmount() + amount;
account.setFreezedAmount(freezedAmount);
transferMoneyDAO.updateFreezedAmount(account);
logger.info(String.format("======>prepareMinus account[%s] amount[%f], dtx transaction id: %s.",
accountNo, amount, xid));
return true;
} catch (Throwable t) {
logger.error("======>error occured in MinusMoneyActionImpl.prepareMinus: " + t.getMessage(),
t.getCause());
status.setRollbackOnly();
return false;
}
}
});
}
@Override
public boolean commit(BusinessActionContext businessActionContext) {
logger.info("======>into MinusMoneyActionImpl.commit() method");
// 分布式事务ID
final String xid = RootContext.getXID();
// 账户ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
// 转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
return transactionTemplate.execute(new TransactionCallback() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try {
AccountBean account = transferMoneyDAO.getAccountForUpdate(accountNo);
// 扣除账户余额
double newAmount = account.getAmount() - amount;
if (newAmount < 0) {
throw new RuntimeException("余额不足");
}
account.setAmount(newAmount);
// 释放账户 冻结金额
account.setFreezedAmount(account.getFreezedAmount() - amount);
transferMoneyDAO.updateAmount(account);
logger.info(String.format("======>minus account[%s] amount[%f], dtx transaction id: %s.", accountNo,
amount, xid));
return true;
} catch (Throwable t) {
logger.error("======>error occured in MinusMoneyActionImpl.commit: " + t.getMessage(),
t.getCause());
status.setRollbackOnly();
return false;
}
}
});
}
@Override
public boolean rollback(BusinessActionContext businessActionContext) {
logger.info("======>into MinusMoneyActionImpl.rollback() method");
// 分布式事务ID
final String xid = RootContext.getXID();
// 账户ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
// 转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
return transactionTemplate.execute(new TransactionCallback() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try {
AccountBean account = transferMoneyDAO.getAccountForUpdate(accountNo);
if (account == null) {
// 账户不存在,回滚什么都不做
return true;
}
// 释放冻结金额
if (account.getFreezedAmount() >= amount) {
account.setFreezedAmount(account.getFreezedAmount() - amount);
transferMoneyDAO.updateFreezedAmount(account);
}
logger.info(
String.format("======>Undo prepareMinus account[%s] amount[%f], dtx transaction id: %s.",
accountNo, amount, xid));
return true;
} catch (Throwable t) {
logger.error("======>error occured in MinusMoneyActionImpl.rollback: " + t.getMessage(),
t.getCause());
status.setRollbackOnly();
return false;
}
}
});
}
}
从以上代码我们可以看到它是一个“工行划款”的全过程。
一开始它会从prepareMinus方法走起,你在consumer端只需要调用这个prepareMinus然后后面的commit与rollback是seata根据业务方法执行的状态自动回调并决定后一步调用到底是调用commit还是调用rollback的,即在consumer端的业务方法内是不含有commit和rollback的。
此处的PrepareMinus要做的事就是:
这就是prepare阶段,prepare阶段如果成功seata会自动走下一步commit,如果遇到有问题就可以运行rollback方法。
那么我们来看业务commit(即confirm)方法吧:
很多人在此处要问,为什么需要增加一个freezed_amount,直接扣不就完了。
是!你可以直接扣,可是我们前面说过幂等了,那么请问你在commit或者是在rollback时你会怎么回滚这个数据?
我们人操作的话就是原来转出10元,失败了,把10元退给原帐户!
因此我们这边拿了这个freezed_amount就是来做计算机可以认得的这个“中间暂存”变量。还记得我们在上篇中提到的业务幂等吗?我们需要保存一切中间状态以便于“业务回退/反交易”。
那么我们下面来看看这个“业务回退”是怎么样的,即rollback方法
rollback要做的,拿icbc是扣款来说就是一个“业务回退”,它先查询该帐户是否存在,如果不存在那也不要做了,帐户不存在不存在任何抛错只要return true就可以了什么都不用做。这边的return true是什么意思尼?这叫空回滚。
所谓空回滚就是
事务协调器在调用TCC服务的一阶段Try操作时,可能会出现因为丢包而导致的网络超时,此时事务协调器会触发二阶段回滚,调用TCC服务的Cancel操作;TCC服务在未收到Try请求的情况下收到Cancel请求,这种场景被称为空回滚;TCC服务在实现时应当允许空回滚的执行;如果你觉得前面这段话有点拗口,那么我们再说了白一点,看下图就能理解了
从上图看到,这个rollback以返回true来判定回滚成功,此时你要不给它true给它false或者是Exception的话它就会不断的尝试回滚,于是你在后台会看到一堆的try rollback but failed try again...,要try多少次呢?它是依赖于seata server端的conf/nacos-config.txt中的这么几个参数来设定的
client.tm.commit.retry.count=1
client.tm.rollback.retry.count=1
你现在理解为什么在rollback调用时如果检查到了帐户已经不存在,直接返回true而不需要再thru什么Exception或者是return false了吧?再加上你如果前面这2个retry.count参数没有设好,到时你就会限入“无限回滚”(因为默认这两个值是-1,代表无限尝试)的状态 ,最后把jvm给搞爆掉。
下面给出DAO的详细代码,DAO代码很简单,没什么需要多说的
TransferMoneyDAO.java
package org.sky.tcc.bank.icbc.dao;
import org.sky.tcc.bean.AccountBean;
public interface TransferMoneyDAO {
public void addAccount(AccountBean account) throws Exception;
public int updateAmount(AccountBean account) throws Exception;
public AccountBean getAccount(String accountNo) throws Exception;
public AccountBean getAccountForUpdate(String accountNo) throws Exception;
public int updateFreezedAmount(AccountBean account) throws Exception;
}
TransferMoneyDAOImpl.java
package org.sky.tcc.bank.icbc.dao;
import java.sql.ResultSet;
import java.sql.SQLException;
import org.sky.tcc.bean.AccountBean;
import org.sky.tcc.dao.BaseDAO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component;
@Component
public class TransferMoneyDAOImpl extends BaseDAO implements TransferMoneyDAO {
@Autowired
private JdbcTemplate fromJdbcTemplate;
@Override
public void addAccount(AccountBean account) throws Exception {
String sql = "insert into bank_account(account_id,amount,freezed_amount) values(?,?,?)";
fromJdbcTemplate.update(sql, account.getAccountId(), account.getAmount(), account.getFreezedAmount());
}
@Override
public int updateAmount(AccountBean account) throws Exception {
String sql = "update bank_account set amount=?, freezed_amount=? where account_id=?";
int result = 0;
result = fromJdbcTemplate.update(sql, account.getAmount(), account.getFreezedAmount(), account.getAccountId());
return result;
}
@Override
public AccountBean getAccount(String accountNo) throws Exception {
String sql = "select account_id,amount,freezed_amount from bank_account where account_id=?";
AccountBean account = null;
// Object[] params = new Object[] { accountNo };
try {
account = fromJdbcTemplate.queryForObject(sql, new RowMapper() {
@Override
public AccountBean mapRow(ResultSet rs, int rowNum) throws SQLException {
AccountBean account = new AccountBean();
account.setAccountId(rs.getString("account_id"));
account.setAmount(rs.getDouble("amount"));
account.setFreezedAmount(rs.getDouble("freezed_amount"));
return account;
}
}, accountNo);
} catch (Exception e) {
logger.error("getAccount error: " + e.getMessage(), e);
account = null;
}
return account;
}
@Override
public AccountBean getAccountForUpdate(String accountNo) throws Exception {
String sql = "select account_id,amount,freezed_amount from bank_account where account_id=? for update";
AccountBean account = null;
// Object[] params = new Object[] { accountNo };
try {
account = fromJdbcTemplate.queryForObject(sql, new RowMapper() {
@Override
public AccountBean mapRow(ResultSet rs, int rowNum) throws SQLException {
AccountBean account = new AccountBean();
account.setAccountId(rs.getString("account_id"));
account.setAmount(rs.getDouble("amount"));
account.setFreezedAmount(rs.getDouble("freezed_amount"));
return account;
}
}, accountNo);
} catch (Exception e) {
logger.error("getAccount error: " + e.getMessage(), e);
return null;
}
return account;
}
@Override
public int updateFreezedAmount(AccountBean account) throws Exception {
String sql = "update bank_account set freezed_amount=? where account_id=?";
int result = 0;
result = fromJdbcTemplate.update(sql, account.getFreezedAmount(), account.getAccountId());
return result;
}
}
用于启用动的ICBCApplication,此处因为我们用了.xml模式配置dubbo,因此可就不能使用@EnableDubbo了啊
package org.sky.tcc.bank.icbc;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
@EnableAutoConfiguration
@ComponentScan(basePackages = { "org.sky.tcc.bank" })
public class ICBCApplication {
public static void main(String[] args) {
SpringApplication.run(ICBCApplication.class, args);
}
}
这是一个“招行打款”的dubbo provider,它和前面的工行扣款类似,也是实现了TCC的提交方式,只不过它要做的是“增加余额操作”。
其它逻辑和tcc-bank-icbc一样,我们在此看一下它的三个TCC吧
PlusMoneyActionImpl.java
package org.sky.tcc.bank.cmb.dubbo;
import org.sky.service.BaseService;
import org.sky.tcc.bank.cmb.dao.TransferMoneyDAO;
import org.sky.tcc.bean.AccountBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
public class PlusMoneyActionImpl extends BaseService implements PlusMoneyAction {
@Autowired
private TransactionTemplate transactionTemplate;
@Autowired
private TransferMoneyDAO transferMoneyDAO;
@Override
public String sayHello() throws RuntimeException {
return "hi I am cmb-dubbo";
}
@Override
public boolean prepareAdd(BusinessActionContext businessActionContext, String accountNo, double amount) {
logger.info("======>inti prepare add");
final String xid = RootContext.getXID();
return transactionTemplate.execute(new TransactionCallback() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try {
// 校验账户
AccountBean account = transferMoneyDAO.getAccountForUpdate(accountNo);
if (account == null) {
logger.info(
"======>prepareAdd: 账户[" + accountNo + "]不存在, txId:" + businessActionContext.getXid());
return false;
}
// 待转入资金作为 不可用金额
double freezedAmount = account.getFreezedAmount() + amount;
account.setFreezedAmount(freezedAmount);
transferMoneyDAO.updateFreezedAmount(account);
logger.info(String.format(
"PlusMoneyActionImpl.prepareAdd account[%s] amount[%f], dtx transaction id: %s.", accountNo,
amount, xid));
return true;
} catch (Throwable t) {
logger.error("======>error occured in PlusMoneyActionImpl.prepareAdd: " + t.getMessage(),
t.getCause());
status.setRollbackOnly();
return false;
}
}
});
}
@Override
public boolean commit(BusinessActionContext businessActionContext) {
logger.info("======>into PlusMoneyActionImpl.commit() method");
// 分布式事务ID
final String xid = RootContext.getXID();
// 账户ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
// 转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
return transactionTemplate.execute(new TransactionCallback() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try {
AccountBean account = transferMoneyDAO.getAccountForUpdate(accountNo);
// 加钱
double newAmount = account.getAmount() + amount;
account.setAmount(newAmount);
// 冻结金额 清除
account.setFreezedAmount(account.getFreezedAmount() - amount);
transferMoneyDAO.updateAmount(account);
logger.info(String.format("======>add account[%s] amount[%f], dtx transaction id: %s.", accountNo,
amount, xid));
return true;
} catch (Throwable t) {
logger.error("======>error occured in PlusMoneyActionImpl.commit: " + t.getMessage(), t.getCause());
status.setRollbackOnly();
return false;
}
}
});
}
@Override
public boolean rollback(BusinessActionContext businessActionContext) {
logger.info("======>into PlusMoneyActionImpl.rollback() method");
// 分布式事务ID
final String xid = RootContext.getXID();
// 账户ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
// 转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
return transactionTemplate.execute(new TransactionCallback() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try {
AccountBean account = transferMoneyDAO.getAccountForUpdate(accountNo);
if (account == null) {
// 账户不存在, 无需回滚动作
return true;
}
// 冻结金额 清除
if (account.getFreezedAmount() >= amount) {
account.setFreezedAmount(account.getFreezedAmount() - amount);
transferMoneyDAO.updateFreezedAmount(account);
}
logger.info(String.format("======>Undo account[%s] amount[%f], dtx transaction id: %s.", accountNo,
amount, xid));
return true;
} catch (Throwable t) {
logger.error("======>error occured in PlusMoneyActionImpl.rollback: " + t.getMessage(),
t.getCause());
status.setRollbackOnly();
return false;
}
}
});
}
}
pom.xml
4.0.0
org.sky.demo
nacos-parent
0.0.1-SNAPSHOT
org.sky.demo
tcc-bank-cmb
0.0.1
tcc-bank-cmb
Demo project Dubbo+Nacos+SeataTCC
UTF-8
org.mybatis
mybatis
org.mybatis
mybatis-spring
org.springframework.boot
spring-boot-starter-jdbc
org.springframework.boot
spring-boot-starter-logging
org.apache.dubbo
dubbo
org.apache.curator
curator-framework
org.apache.curator
curator-recipes
mysql
mysql-connector-java
com.alibaba
druid
org.springframework.boot
spring-boot-starter-test
test
org.spockframework
spock-core
test
org.spockframework
spock-spring
org.springframework.boot
spring-boot-configuration-processor
true
org.springframework.boot
spring-boot-starter-log4j2
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-logging
org.springframework.boot
spring-boot-starter-tomcat
org.aspectj
aspectjweaver
com.lmax
disruptor
redis.clients
jedis
com.google.guava
guava
com.alibaba
fastjson
org.apache.dubbo
dubbo-registry-nacos
com.alibaba.nacos
nacos-client
org.sky.demo
skycommon
${skycommon.version}
io.seata
seata-all
com.alibaba.boot
nacos-config-spring-boot-starter
nacos-client
com.alibaba.nacos
io.netty
netty-all
org.projectlombok
lombok
${project.artifactId}
src/main/java
src/test/java
org.apache.maven.plugins
maven-compiler-plugin
org.springframework.boot
spring-boot-maven-plugin
-Dfile.encoding=UTF-8
repackage
org.apache.maven.plugins
maven-war-plugin
2.6
false
src/main/resources
application*.properties
src/main/webapp
META-INF/resources
**/**
src/main/resources
true
application.properties
application.properties
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driverClassName=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://192.168.56.101:3306/bank_cmb?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.username=cmb
spring.datasource.password=111111
spring.datasource.initialize=false
spring.datasource.initialSize=5
spring.datasource.minIdle=5
spring.datasource.maxActive: 20
spring.datasource.maxWait: 30000
spring.datasource.validationQuery=SELECT 1 FROM DUAL
spring.datasource.testWhileIdle=true
spring.datasource.testOnBorrow=false
spring.datasource.testOnReturn=false
spring.datasource.poolPreparedStatements=true
spring.datasource.maxPoolPreparedStatementPerConnectionSize=128
logging.config=classpath:log4j2.xml
spring/dubbo-bean.xml
spring/spirng-bean.xml
PROPAGATION_REQUIRES_NEW
自动装配用TccBankConfig.java,这边要注意的是此处的GlobalTransaction里的名字可必须是tcc-bank-cmb啦,不要复制粘贴后忘改了
package org.sky.tcc.bank.cmb.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import com.alibaba.druid.pool.DruidDataSource;
import io.seata.spring.annotation.GlobalTransactionScanner;
@Configuration
@ImportResource(locations = { "spring/spring-bean.xml", "spring/dubbo-bean.xml" })
public class TccBankConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DruidDataSource druidDataSource() {
return new DruidDataSource();
}
@Bean
public DataSourceTransactionManager transactionManager(DruidDataSource druidDataSource) {
return new DataSourceTransactionManager(druidDataSource);
}
@Bean
public JdbcTemplate jdbcTemplate(DruidDataSource druidDataSource) {
return new JdbcTemplate(druidDataSource);
}
@Bean
public GlobalTransactionScanner globalTransactionScanner() {
return new GlobalTransactionScanner("tcc-bank-cmb", "demo-tx-grp");
}
}
TransferMoneyDAO.java
package org.sky.tcc.bank.cmb.dao;
import org.sky.tcc.bean.AccountBean;
public interface TransferMoneyDAO {
public void addAccount(AccountBean account) throws Exception;
public int updateAmount(AccountBean account) throws Exception;
public AccountBean getAccount(String accountNo) throws Exception;
public AccountBean getAccountForUpdate(String accountNo) throws Exception;
public int updateFreezedAmount(AccountBean account) throws Exception;
}
TransferMoneyDAOImpl.java
package org.sky.tcc.bank.cmb.dao;
import java.sql.ResultSet;
import java.sql.SQLException;
import org.sky.tcc.bean.AccountBean;
import org.sky.tcc.dao.BaseDAO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component;
@Component
public class TransferMoneyDAOImpl extends BaseDAO implements TransferMoneyDAO {
@Autowired
private JdbcTemplate toJdbcTemplate;
@Override
public void addAccount(AccountBean account) throws Exception {
String sql = "insert into bank_account(account_id,amount,freezed_amount) values(?,?,?)";
toJdbcTemplate.update(sql, account.getAccountId(), account.getAmount(), account.getFreezedAmount());
}
@Override
public int updateAmount(AccountBean account) throws Exception {
String sql = "update bank_account set amount=?, freezed_amount=? where account_id=?";
int result = 0;
result = toJdbcTemplate.update(sql, account.getAmount(), account.getFreezedAmount(), account.getAccountId());
return result;
}
@Override
public AccountBean getAccount(String accountNo) throws Exception {
String sql = "select account_id,amount,freezed_amount from bank_account where account_id=?";
AccountBean account = null;
// Object[] params = new Object[] { accountNo };
try {
account = toJdbcTemplate.queryForObject(sql, new RowMapper() {
@Override
public AccountBean mapRow(ResultSet rs, int rowNum) throws SQLException {
AccountBean account = new AccountBean();
account.setAccountId(rs.getString("account_id"));
account.setAmount(rs.getDouble("amount"));
account.setFreezedAmount(rs.getDouble("freezed_amount"));
return account;
}
}, accountNo);
} catch (Exception e) {
logger.error("getAccount error: " + e.getMessage(), e);
account = null;
}
return account;
}
@Override
public AccountBean getAccountForUpdate(String accountNo) throws Exception {
String sql = "select account_id,amount,freezed_amount from bank_account where account_id=? for update";
AccountBean account = null;
// Object[] params = new Object[] { accountNo };
try {
account = toJdbcTemplate.queryForObject(sql, new RowMapper() {
@Override
public AccountBean mapRow(ResultSet rs, int rowNum) throws SQLException {
AccountBean account = new AccountBean();
account.setAccountId(rs.getString("account_id"));
account.setAmount(rs.getDouble("amount"));
account.setFreezedAmount(rs.getDouble("freezed_amount"));
return account;
}
}, accountNo);
} catch (Exception e) {
logger.error("getAccount error: " + e.getMessage(), e);
account = null;
}
return account;
}
@Override
public int updateFreezedAmount(AccountBean account) throws Exception {
String sql = "update bank_account set freezed_amount=? where account_id=?";
int result = 0;
result = toJdbcTemplate.update(sql, account.getFreezedAmount(), account.getAccountId());
return result;
}
}
用于启用动的CMBApplication,此处因为我们用了.xml模式配置dubbo,因此可就不能使用@EnableDubbo了哦再次提醒一次。
package org.sky.tcc.bank.cmb;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
@EnableAutoConfiguration
@ComponentScan(basePackages = { "org.sky.tcc.bank" })
public class CMBApplication {
public static void main(String[] args) {
SpringApplication.run(CMBApplication.class, args);
}
}
到此为止两个dubbo provider制作 完成,我们把它们分别运行起来。
启动之前我放出此次在生产环境调整过的nacos-config.txt文件,你只要在nacos服务启动的情况下重新在seata/conf下
./nacos-config.sh localhost
就可以了。
seata/conf/nacos-config.txt
transport.type=TCP
transport.server=NIO
transport.heartbeat=true
transport.thread-factory.boss-thread-prefix=NettyBoss
transport.thread-factory.worker-thread-prefix=NettyServerNIOWorker
transport.thread-factory.server-executor-thread-prefix=NettyServerBizHandler
transport.thread-factory.share-boss-worker=false
transport.thread-factory.client-selector-thread-prefix=NettyClientSelector
transport.thread-factory.client-selector-thread-size=1
transport.thread-factory.client-worker-thread-prefix=NettyClientWorkerThread
transport.thread-factory.boss-thread-size=1
transport.thread-factory.worker-thread-size=8
transport.shutdown.wait=3
service.vgroup_mapping.demo-tx-grp=default
service.default.grouplist=192.168.56.101:8091
service.enableDegrade=false
service.disable=false
service.max.commit.retry.timeout=10000
service.max.rollback.retry.timeout=3
client.async.commit.buffer.limit=10000
client.lock.retry.internal=3
client.lock.retry.times=3
client.lock.retry.policy.branch-rollback-on-conflict=true
client.table.meta.check.enable=true
client.report.retry.count=1
client.tm.commit.retry.count=1
client.tm.rollback.retry.count=1
store.mode=db
store.file.dir=file_store/data
store.file.max-branch-session-size=16384
store.file.max-global-session-size=512
store.file.file-write-buffer-cache-size=16384
store.file.flush-disk-mode=async
store.file.session.reload.read_size=100
store.db.datasource=druid
store.db.db-type=mysql
store.db.driver-class-name=com.mysql.jdbc.Driver
store.db.url=jdbc:mysql://192.168.56.101:3306/seata?useUnicode=true
store.db.user=seata
store.db.password=111111
store.db.min-conn=1
store.db.max-conn=3
store.db.global.table=global_table
store.db.branch.table=branch_table
store.db.query-limit=100
store.db.lock-table=lock_table
recovery.committing-retry-period=1000
recovery.asyn-committing-retry-period=1000
recovery.rollbacking-retry-period=1000
recovery.timeout-retry-period=1000
transaction.undo.data.validation=true
transaction.undo.log.serialization=jackson
transaction.undo.log.save.days=1
transaction.undo.log.delete.period=86400000
transaction.undo.log.table=undo_log
transport.serialization=seata
transport.compressor=none
metrics.enabled=false
metrics.registry-type=compact
metrics.exporter-list=prometheus
metrics.exporter-prometheus-port=9898
support.spring.datasource.autoproxy=false
为了全真模拟生产,我们制作了一个spring boot的consumer,在这个工程里我们依然使用springboot+xml配置混合的方式,关键 在该工程的业务方法内,我们看下去。
pom.xml
4.0.0
org.sky.demo
nacos-parent
0.0.1-SNAPSHOT
org.sky.demo
tcc-money-king
0.0.1
war
Demo project Dubbo+Nacos+SeataTCC Consumer
org.apache.dubbo
dubbo
org.apache.curator
curator-recipes
org.springframework.boot
spring-boot-starter-test
test
org.spockframework
spock-core
test
org.spockframework
spock-spring
org.springframework.boot
spring-boot-configuration-processor
true
org.springframework.boot
spring-boot-starter-log4j2
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-logging
org.springframework.boot
spring-boot-starter-tomcat
org.springframework.boot
spring-boot-starter-tomcat
compile
org.aspectj
aspectjweaver
com.lmax
disruptor
redis.clients
jedis
com.google.guava
guava
com.alibaba
fastjson
org.apache.dubbo
dubbo-registry-nacos
com.alibaba.nacos
nacos-client
org.sky.demo
skycommon
${skycommon.version}
io.seata
seata-all
com.alibaba.boot
nacos-config-spring-boot-starter
nacos-client
com.alibaba.nacos
org.apache.curator
curator-framework
org.apache.curator
curator-recipes
io.netty
netty-all
org.projectlombok
lombok
javax.servlet
javax.servlet-api
${javax.servlet.version}
provided
src/main/java
src/test/java
org.springframework.boot
spring-boot-maven-plugin
src/main/resources
src/main/webapp
META-INF/resources
**/**
src/main/resources
true
application.properties
application-${profileActive}.properties
application.properties
server.port=8082
server.tomcat.maxConnections=300
server.tomcat.maxThreads=300
server.tomcat.uriEncoding=UTF-8
server.tomcat.maxThreads=300
server.tomcat.minSpareThreads=150
server.connectionTimeout=20000
server.tomcat.maxHttpPostSize=0
server.tomcat.acceptCount=300
logging.config=classpath:log4j2.xml
spring/dubbo-reference.xml
spring boot自动注解用SeataAutoConfig.java
package org.sky.tcc.moneyking.config;
import io.seata.spring.annotation.GlobalTransactionScanner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
@Configuration
@ImportResource(locations = { "spring/dubbo-reference.xml" })
public class SeataAutoConfig {
@Bean
public GlobalTransactionScanner globalTransactionScanner() {
return new GlobalTransactionScanner("tcc-bank-sample", "demo-tx-grp");
}
}
这边的GlobalTRansactionScanner里的第一个参数可就是事务边界了啊,注意这边的事务group必须和seata端的nacos-config.txt内配置的完全一致。
TccMoneyKingBizService.java
package org.sky.tcc.moneyking.service.biz;
import org.sky.exception.DemoRpcRunTimeException;
public interface TccMoneyKingBizService {
public boolean transfer(String from, String to, double amount) throws DemoRpcRunTimeException;
}
核心业务方法TccMoneyKingBizServiceImpl.java
package org.sky.tcc.moneyking.service.biz;
import org.sky.exception.DemoRpcRunTimeException;
import org.sky.service.BaseService;
import org.sky.tcc.bank.cmb.dubbo.PlusMoneyAction;
import org.sky.tcc.bank.icbc.dubbo.MinusMoneyAction;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import io.seata.spring.annotation.GlobalTransactional;
@Service
public class TccMoneyKingBizServiceImpl extends BaseService implements TccMoneyKingBizService {
@Autowired
private MinusMoneyAction minusMoneyAction;
@Autowired
private PlusMoneyAction plusMoneyAction;
@Override
@GlobalTransactional(timeoutMills = 300000, name = "tcc-bank-sample")
public boolean transfer(String from, String to, double amount) throws DemoRpcRunTimeException {
boolean answer = minusMoneyAction.prepareMinus(null, from, amount);
if (!answer) {
// 扣钱参与者,一阶段失败; 回滚本地事务和分布式事务
throw new DemoRpcRunTimeException("账号:[" + from + "] 预扣款失败");
}
// 加钱参与者,一阶段执行
answer = plusMoneyAction.prepareAdd(null, to, amount);
if (!answer) {
throw new DemoRpcRunTimeException("账号:[" + to + "] 预收款失败");
}
return true;
}
}
这边可以看到是如何调用icbc的扣款和cmb的打款动作 的,这边根本不需要你再去写什么commit和rollback,只要这两个dubbo provider中的prepare方法执行正常,seata就会自动回调icbc和cmb中的commit方法;只要icbc或者 是cmb中有任何一步抛错,就会触发这两个provider中的业务回滚rollback方法。
MonekyKingController.java
package org.sky.tcc.moneyking.controller;
import java.util.HashMap;
import java.util.Map;
import org.sky.controller.BaseController;
import org.sky.tcc.bean.AccountBean;
import org.sky.tcc.moneyking.service.biz.TccMoneyKingBizService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
@RestController
@RequestMapping("moneyking")
public class MonekyKingController extends BaseController {
@Autowired
private TccMoneyKingBizService tccMoneyKingBizService;
@PostMapping(value = "/transfermoney", produces = "application/json")
public ResponseEntity transferMoney(@RequestBody String params) throws Exception {
ResponseEntity response = null;
String returnResultStr;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON_UTF8);
Map result = new HashMap<>();
try {
logger.info("input params=====" + params);
JSONObject requestJsonObj = JSON.parseObject(params);
Map acctMap = getAccountFromJson(requestJsonObj);
AccountBean acctFrom = acctMap.get("account_from");
AccountBean acctTo = acctMap.get("account_to");
boolean answer = tccMoneyKingBizService.transfer(acctFrom.getAccountId(), acctTo.getAccountId(),
acctFrom.getAmount());
// tccMoneyKingBizService.icbcHello();
// tccMoneyKingBizService.cmbHello();
result.put("account_from", acctFrom.getAccountId());
result.put("account_to", acctTo.getAccountId());
result.put("transfer_money", acctFrom.getAmount());
result.put("message", "transferred successfully");
returnResultStr = JSON.toJSONString(result);
logger.info("transfer money successfully======>\n" + returnResultStr);
response = new ResponseEntity<>(returnResultStr, headers, HttpStatus.OK);
} catch (Exception e) {
logger.error("transfer money with error: " + e.getMessage(), e);
result.put("message", "transfer money with error[ " + e.getMessage() + "]");
returnResultStr = JSON.toJSONString(result);
response = new ResponseEntity<>(returnResultStr, headers, HttpStatus.EXPECTATION_FAILED);
}
return response;
}
}
用于启动的MoneyKingApplication.java
package org.sky.tcc.moneyking;
import org.apache.dubbo.config.spring.context.annotation.EnableDubbo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@ServletComponentScan
@EnableAutoConfiguration
@ComponentScan(basePackages = { "org.sky" })
public class MoneyKingApplication {
public static void main(String[] args) {
SpringApplication.run(MoneyKingApplication.class, args);
}
}
把MoneyKingApplication启动起来。
看,两个dubbo provider已经被seata纳入托管。
我们初始化两个帐户,一个叫a一个叫b。然后通过 a给b每次打100块钱。
use bank_icbc;
delete from bank_account;
insert into bank_account
(account_id,amount,freezed_amount)values('a',50000,0);
use bank_cmb;
delete from bank_account;
insert into bank_account
(account_id,amount,freezed_amount)values('b',100,0);
{
"account_from" : "a",
"account_to" : "b",
"transfer_money" : 100
}
请观察icbc和cmb的后台,从prepare为人为触发外,commit的一系列的动作都 是被自动触发的。
再看数据库端
{
"account_from" : "a",
"account_to" : "b",
"transfer_money" : 1000000000
}
来看icbc和cmb端的回滚
看到没,rollback被自动触发。数据库端当然也没被插进数据(被回滚掉了)。
{
"account_from" : "a",
"account_to" : "c",
"transfer_money" : 100
}
我们可以通过上述的例子看到,seata把分布式事务的锁可以定义为最最小业务原子操作,这使得本来冗长的事务锁的开销可以尽量的小,尽快的释放原子操作从而加速了分布式事物处理的效率。
Seata通过数据一致性、尽可能少破坏业务代码、高性能这三者关系中进行了一个取舍,它付的代价就是使用netty通讯实现了异步消息回调+spring aop,这个对服务器的硬件要求很高。当服务器的硬件如果跟不上的话,你会发现部署一个seata简直是要了你的老命了,很多网上的网友也都说过,我部署了一个seata比原来竟然慢了8倍。这倒不是说这个框架不好,只是它的开销会比较大。当然,在现今硬件越来越廉价的情况下,要保证数据的最终一致完整性,总要有适当的付出的。