在上一篇中,我们大致讲述了TCC事务的来源以及执行原理,并使用seata提供的解决方案完成了一个简单案例的整合与代码演示,本篇我们将采用Hmily的方式实现TCC事务的解决方案与演示
有一个银行转账的场景,用户A需要向用户B转1块钱,如果大家使用的是同一个数据库,就不存在分布式事务的问题,现实中大家都各自使用自己的库,就产生了分布式事务
可以理解为,两个账户分别在不同的银行(用户A在bank1、用户B在bank2),bank1、bank2是两个微服务。交易过程是,用户A给 用户B转账指定金额
对于上述交易步骤,要么一起成功,要么一起失败,必须是一个整体性事务
微服务及数据库的关系 :
1、创建数据库
创建hmily数据库,用于存储hmily框架记录的数据
CREATE DATABASE `hmily` CHARACTER SET 'utf8' COLLATE 'utf8_general_ci';
创建bank1库,并导入以下表结构和数据(包含张三账户)
CREATE DATABASE `bank1` CHARACTER SET 'utf8' COLLATE 'utf8_general_ci';
DROP TABLE IF EXISTS `account_info`; 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;
创建bank2库,并导入以下表结构和数据(包含用户B账户),和上述表结构保持一致,建表语句不再罗列
CREATE DATABASE `bank2` CHARACTER SET 'utf8' COLLATE 'utf8_general_ci';
2、创建Hmily数据表
这个和seata在AT模式使用中类似,每个库需要加入一张undo_log的表,框架通过这个表可以进行数据回滚,同样,hmily要能够完成分布式环境下事务解决方案,也需要依赖一些操作过程中的数据日志表,即每个数据库都创建try、confirm、cancel三张日志表,将下面的建表语句分别在bank1和bank2库中执行一下即可
CREATE TABLE `local_try_log` ( `tx_no` varchar(64) NOT NULL COMMENT '事务id', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`tx_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 CREATE TABLE `local_confirm_log` ( `tx_no` varchar(64) NOT NULL COMMENT '事务id', `create_time` datetime DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8 CREATE TABLE `local_cancel_log` ( `tx_no` varchar(64) NOT NULL COMMENT '事务id', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`tx_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8
说明:Hmily的引入不需要向seata那样单独开启一个seata-server服务,而是以jar包依赖的形式被各个使用的模块依赖即可,关于Hmily的原理,我们在上一篇做过简单介绍,主要如下:
在正式开始编码之前,我们需要了解使用hmily处理分布式事务过程中,可能产生的问题以及注意点,大体来说,主要有以下3点
其实以上3点,就是我们在使用过程中,从代码的业务逻辑处理上面要面临的和需要解决的3个注意点,我们知道TCC对应着程序执行的3个阶段,try,confirm,cancel,对应一个具体的操作,比如上述从用户A转账1块钱到用户B的账户中,程序中对应的3个阶段的业务逻辑是如何执行的呢?
对于用户A来说,转账的操作发生在try阶段,从数据库层面讲,就是在try阶段完成从用户A扣款,而commit阶段,只要try没有发生异常,commit不需要做任何操作,最后调用远程rpc方法的时候,如果成功直接返回,如果失败,执行cancel操作
而对于用户B来说,try阶段不需要做任何事,因为用户B的操作是被动的,如果在commit阶段执行成功,直接返回,但是如果在commit阶段执行失败,就返回,本事务回滚
上面是正常的一个分布式事务的执行过程,但是在出现异常的时候,就可能引发上述3种问题了
还以上面用户A给用户B转账为例
方案1:
账户A
try:
检查余额是否够30元 扣减30元
confirm:
空
cancel:
增加30元
账户B
try:
增加30元
confirm:
空
cancel:
减少30元
方案1说明:
问题分析
解决思路:
优化方案:
账户A
try:
try幂等校验
try悬挂处理
检查余额是否够30元
扣减30元
confirm:
空
cancel:
cancel幂等校验
cancel空回滚处理
增加可用余额30元
账户B
try:
空
confirm:
confirm幂等校验
正式增加30元
cancel:
空
基本上,以上就是我们在程序中代码逻辑的思路,下面开始代码的事项过程
父工程pom依赖
UTF-8
UTF-8
1.8
org.springframework.boot
spring-boot-dependencies
2.1.3.RELEASE
pom
import
org.springframework.cloud
spring-cloud-dependencies
Greenwich.RELEASE
pom
import
io.springfox
springfox-swagger2
2.9.2
io.springfox
springfox-swagger-ui
2.9.2
org.projectlombok
lombok
1.18.0
javax.servlet
javax.servlet-api
3.1.0
provided
javax.interceptor
javax.interceptor-api
1.2
mysql
mysql-connector-java
5.1.47
org.mybatis.spring.boot
mybatis-spring-boot-starter
2.0.0
com.alibaba
druid-spring-boot-starter
1.1.16
org.apache.rocketmq
rocketmq-spring-boot-starter
2.0.2
commons-lang
commons-lang
2.6
org.springframework.boot
spring-boot-maven-plugin
搭建eureka-server
1、pom依赖
org.springframework.cloud
spring-cloud-starter-netflix-eureka-server
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-actuator
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-test
test
2、配置文件application.yml
server:
port: 7001
spring:
application:
name: cloud-eureka-server
eureka:
instance:
# eureka服务端的实例名称
# 单机 hostname: localhost
hostname: localhost
# Eureka客户端向服务端发送心跳的时间间隔,单位为秒(默认是30秒)
lease-renewal-interval-in-seconds: 1
# Eureka服务端在收到最后一次心跳后等待时间上限 ,单位为秒(默认是90秒),超时剔除服务
lease-expiration-duration-in-seconds: 2
server:
# 禁用自我保护,保证不可用服务被及时删除
enable-self-preservation: true
eviction-interval-timer-in-ms: 2000
client:
# false表示不向注册中心注册自己
register-with-eureka: false
# false表示自己端就是注册中心,我的职责就是维护服务实例,并不需要检索服务
fetch-registry: false
service-url:
# 设置与Eureka Server交互的地址查询服务和注册服务都需要依赖这个地址
defaultZone: http://localhost:7001/eureka/
3、启动类
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApp {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApp.class,args);
}
}
启动之后,浏览器输入:http://localhost:7001即可
bank1服务搭建
1、pom依赖
org.dromara
hmily-springcloud
2.0.6-RELEASE
zookeeper
org.apache.zookeeper
disruptor
com.lmax
com.lmax
disruptor
3.4.2
org.springframework.cloud
spring-cloud-starter-openfeign
org.springframework.cloud
spring-cloud-starter-netflix-hystrix
org.springframework.cloud
spring-cloud-starter-netflix-eureka-client
com.netflix.hystrix
hystrix-javanica
org.springframework.retry
spring-retry
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-actuator
org.mybatis.spring.boot
mybatis-spring-boot-starter
com.alibaba
druid-spring-boot-starter
mysql
mysql-connector-java
org.projectlombok
lombok
2、配置文件application.yml
spring:
application:
name: tcc-bank1
main:
allow-bean-definition-overriding: true
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
server:
servlet:
context-path: /bank1
eureka:
client:
register-with-eureka: true
fetch-registry: true
service-url:
defaultZone: http://localhost:7001/eureka
management:
endpoints:
web:
exposure:
include: refresh,health,info,env
feign:
hystrix:
enabled: true
compression:
request:
enabled: true # 配置请求GZIP压缩
mime-types: ["text/xml","application/xml","application/json"] # 配置压缩支持的MIME TYPE
min-request-size: 2048 # 配置压缩数据大小的下限
response:
enabled: true # 配置响应GZIP压缩
hystrix:
command:
default:
execution:
isolation:
thread:
timeoutInMilliseconds: 1000 # 设置熔断超时时间 default 1000
timeout:
enabled: true # 打开超时熔断功能 default true
ribbon:
ConnectTimeout: 600 # 设置连接超时时间 default 2000
ReadTimeout: 6000 # 设置读取超时时间 default 5000
OkToRetryOnAllOperations: true # 对所有操作请求都进行重试 default false
MaxAutoRetriesNextServer: 2 # 切换实例的重试次数 default 1
MaxAutoRetries: 1 # 对当前实例的重试次数 default 0
datasource:
ds0:
url: jdbc:mysql://106.15.37.145:3306/bank1?useUnicode=true
username: root
password: root
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.jdbc.Driver
initialSize: 5
minIdle: 5
maxActive: 20
maxWait: 60000
timeBetweenEvictionRunsMillis: 60000
minEvictableIdleTimeMillis: 300000
validationQuery: SELECT user()
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
poolPreparedStatements: true
connection-properties: druid.stat.mergeSql:true;druid.stat.slowSqlMillis:5000
org:
dromara:
hmily :
serializer : kryo
recoverDelayTime : 30
retryMax : 30
scheduledDelay : 30
scheduledThreadMax : 10
repositorySupport : db
started: true
hmilyDbConfig :
driverClassName : com.mysql.jdbc.Driver
url : jdbc:mysql://106.15.37.145:3306/hmily?useUnicode=true
username : root
password : root
logging:
level:
root: info
org.springframework.web: info
org.apache.ibatis: info
org.dromara.hmily.bonuspoint: debug
org.dromara.hmily.lottery: debug
org.dromara.hmily: debug
io.netty: info
cn.itcast.wanxintx.seatademo.bank2: debug
使用hmily框架,需要使用代理事务的数据源替代,因此需要增加一个数据源的配置类
@Configuration
@EnableAspectJAutoProxy(proxyTargetClass=true)
public class DatabaseConfiguration {
private final ApplicationContext applicationContext;
@Autowired
private Environment env;
public DatabaseConfiguration(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Bean
@ConfigurationProperties(prefix = "spring.datasource.ds0")
public DruidDataSource ds0() {
DruidDataSource druidDataSource = new DruidDataSource();
return druidDataSource;
}
@Bean
public HmilyTransactionBootstrap hmilyTransactionBootstrap(HmilyInitService hmilyInitService){
HmilyTransactionBootstrap hmilyTransactionBootstrap = new HmilyTransactionBootstrap(hmilyInitService);
hmilyTransactionBootstrap.setSerializer(env.getProperty("org.dromara.hmily.serializer"));
hmilyTransactionBootstrap.setRecoverDelayTime(Integer.parseInt(env.getProperty("org.dromara.hmily.recoverDelayTime")));
hmilyTransactionBootstrap.setRetryMax(Integer.parseInt(env.getProperty("org.dromara.hmily.retryMax")));
hmilyTransactionBootstrap.setScheduledDelay(Integer.parseInt(env.getProperty("org.dromara.hmily.scheduledDelay")));
hmilyTransactionBootstrap.setScheduledThreadMax(Integer.parseInt(env.getProperty("org.dromara.hmily.scheduledThreadMax")));
hmilyTransactionBootstrap.setRepositorySupport(env.getProperty("org.dromara.hmily.repositorySupport"));
hmilyTransactionBootstrap.setStarted(Boolean.parseBoolean(env.getProperty("org.dromara.hmily.started")));
HmilyDbConfig hmilyDbConfig = new HmilyDbConfig();
hmilyDbConfig.setDriverClassName(env.getProperty("org.dromara.hmily.hmilyDbConfig.driverClassName"));
hmilyDbConfig.setUrl(env.getProperty("org.dromara.hmily.hmilyDbConfig.url"));
hmilyDbConfig.setUsername(env.getProperty("org.dromara.hmily.hmilyDbConfig.username"));
hmilyDbConfig.setPassword(env.getProperty("org.dromara.hmily.hmilyDbConfig.password"));
hmilyTransactionBootstrap.setHmilyDbConfig(hmilyDbConfig);
return hmilyTransactionBootstrap;
}
}
下面我们就主要的模块和代码进行说明
非关键性代码直接贴出,可通过注释查看
AccountInfoDao
@Mapper
@Component
public interface AccountInfoDao {
@Update("update account_info set account_balance=account_balance - #{amount} where account_balance>=#{amount} and account_no=#{accountNo} ")
int subtractAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);
@Update("update account_info set account_balance=account_balance + #{amount} where account_no=#{accountNo} ")
int addAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);
/**
* 增加某分支事务try执行记录
* @param localTradeNo 本地事务编号
* @return
*/
@Insert("insert into local_try_log values(#{txNo},now());")
int addTry(String localTradeNo);
@Insert("insert into local_confirm_log values(#{txNo},now());")
int addConfirm(String localTradeNo);
@Insert("insert into local_cancel_log values(#{txNo},now());")
int addCancel(String localTradeNo);
/**
* 查询分支事务try是否已执行
* @param localTradeNo 本地事务编号
* @return
*/
@Select("select count(1) from local_try_log where tx_no = #{txNo} ")
int isExistTry(String localTradeNo);
/**
* 查询分支事务confirm是否已执行
* @param localTradeNo 本地事务编号
* @return
*/
@Select("select count(1) from local_confirm_log where tx_no = #{txNo} ")
int isExistConfirm(String localTradeNo);
/**
* 查询分支事务cancel是否已执行
* @param localTradeNo 本地事务编号
* @return
*/
@Select("select count(1) from local_cancel_log where tx_no = #{txNo} ")
int isExistCancel(String localTradeNo);
}
Bank2Client
@FeignClient(value="TCC-DEMO-BANK2",fallback=Bank2ClientFallback.class)
public interface Bank2Client {
//远程调用用户B微服务
@GetMapping("/bank2/transfer")
@Hmily
public Boolean transfer(@RequestParam("amount") Double amount);
}
Bank2ClientFallback
@Component
public class Bank2ClientFallback implements Bank2Client {
public Boolean transfer(Double amount) {
return false;
}
}
AccountInfoServiceImpl
@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {
@Autowired
AccountInfoDao accountInfoDao;
@Autowired
Bank2Client bank2Client;
// 账户扣款,就是tcc的try方法
/**
* try幂等校验
* try悬挂处理
* 检查余额是够扣减金额
* 扣减金额
* @param accountNo
* @param amount
*/
@Transactional
//只要标记@Hmily就是try方法,在注解中指定confirm、cancel两个方法的名字
@Hmily(confirmMethod = "commit", cancelMethod = "rollback")
public void updateAccountBalance(String accountNo, Double amount) {
//获取全局事务id
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank1 try begin 开始执行...xid:{}", transId);
//幂等判断 判断local_try_log表中是否有try日志记录,如果有则不再执行
if (accountInfoDao.isExistTry(transId) > 0) {
log.info("bank1 try 已经执行,无需重复执行,xid:{}", transId);
return;
}
//try悬挂处理,如果cancel、confirm有一个已经执行了,try不再执行
if (accountInfoDao.isExistConfirm(transId) > 0 || accountInfoDao.isExistCancel(transId) > 0) {
log.info("bank1 try悬挂处理 cancel或confirm已经执行,不允许执行try,xid:{}", transId);
return;
}
//扣减金额
if (accountInfoDao.subtractAccountBalance(accountNo, amount) <= 0) {
//扣减失败
throw new RuntimeException("bank1 try 扣减金额失败,xid:{}" + transId);
}
//插入try执行记录,用于幂等判断
accountInfoDao.addTry(transId);
//远程调用李四,转账
if (!bank2Client.transfer(amount)) {
throw new RuntimeException("bank1 远程调用李四微服务失败,xid:{}" + transId);
}
if (amount == 2) {
throw new RuntimeException("人为制造异常,xid:{}" + transId);
}
log.info("bank1 try end 结束执行...xid:{}", transId);
}
//confirm方法
@Transactional
public void commit(String accountNo, Double amount) {
//获取全局事务id
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank1 confirm begin 开始执行...xid:{},accountNo:{},amount:{}", transId, accountNo, amount);
}
/**
* cancel方法
* cancel幂等校验
* cancel空回滚处理
* 增加可用余额
*
* @param accountNo
* @param amount
*/
@Transactional
public void rollback(String accountNo, Double amount) {
//获取全局事务id
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank1 cancel begin 开始执行...xid:{}", transId);
// cancel幂等校验
if (accountInfoDao.isExistCancel(transId) > 0) {
log.info("bank1 cancel 已经执行,无需重复执行,xid:{}", transId);
return;
}
//cancel空回滚处理,如果try没有执行,cancel不允许执行
if (accountInfoDao.isExistTry(transId) <= 0) {
log.info("bank1 空回滚处理,try没有执行,不允许cancel执行,xid:{}", transId);
return;
}
// 增加可用余额
accountInfoDao.addAccountBalance(accountNo, amount);
//插入一条cancel的执行记录
accountInfoDao.addCancel(transId);
log.info("bank1 cancel end 结束执行...xid:{}", transId);
}
}
bank1提供一个对外调用的controller,方便后面进行测试
@RestController
public class Bank1Controller {
@Autowired
AccountInfoService accountInfoService;
@GetMapping("/transfer")
public Boolean transfer(@RequestParam("amount") Double amount) {
this.accountInfoService.updateAccountBalance("1", amount);
return true;
}
}
启动类
@SpringBootApplication(exclude = MongoAutoConfiguration.class)
@EnableDiscoveryClient
@EnableAspectJAutoProxy
@ComponentScan({"com.congge","org.dromara.hmily"})
@EnableFeignClients(basePackages = {"com.congge.feign"})
public class Bank1App {
public static void main(String[] args) {
SpringApplication.run(Bank1App.class,args);
}
}
然后启动bank1服务,在eureka中可以看到已经注册进去了一个服务
bank2工程代码
由于基本上和bank1的代码大部分都类似,下面只贴出关键性的服务事项层的代码
1、application.yml中,修改数据库连接信息即可,以及端口号
2、配置类参考上述bank1基本类似
AccountInfoDao
@Component
@Mapper
public interface AccountInfoDao {
@Update("update account_info set account_balance=account_balance + #{amount} where account_no=#{accountNo} ")
int addAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);
/**
* 增加某分支事务try执行记录
* @param localTradeNo 本地事务编号
* @return
*/
@Insert("insert into local_try_log values(#{txNo},now());")
int addTry(String localTradeNo);
@Insert("insert into local_confirm_log values(#{txNo},now());")
int addConfirm(String localTradeNo);
@Insert("insert into local_cancel_log values(#{txNo},now());")
int addCancel(String localTradeNo);
/**
* 查询分支事务try是否已执行
* @param localTradeNo 本地事务编号
* @return
*/
@Select("select count(1) from local_try_log where tx_no = #{txNo} ")
int isExistTry(String localTradeNo);
/**
* 查询分支事务confirm是否已执行
* @param localTradeNo 本地事务编号
* @return
*/
@Select("select count(1) from local_confirm_log where tx_no = #{txNo} ")
int isExistConfirm(String localTradeNo);
/**
* 查询分支事务cancel是否已执行
* @param localTradeNo 本地事务编号
* @return
*/
@Select("select count(1) from local_cancel_log where tx_no = #{txNo} ")
int isExistCancel(String localTradeNo);
}
AccountInfoServiceImpl服务实现类
@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {
@Autowired
AccountInfoDao accountInfoDao;
@Hmily(confirmMethod="confirmMethod", cancelMethod="cancelMethod")
public void updateAccountBalance(String accountNo, Double amount) {
//获取全局事务id
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank2 try begin 开始执行...xid:{}",transId);
}
/**
* confirm方法
* confirm幂等校验
* 正式增加金额
* @param accountNo
* @param amount
*/
@Transactional
public void confirmMethod(String accountNo, Double amount){
//获取全局事务id
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank2 confirm begin 开始执行...xid:{}",transId);
if(accountInfoDao.isExistConfirm(transId)>0){
log.info("bank2 confirm 已经执行,无需重复执行...xid:{}",transId);
return ;
}
//增加金额
accountInfoDao.addAccountBalance(accountNo,amount);
//增加一条confirm日志,用于幂等
accountInfoDao.addConfirm(transId);
log.info("bank2 confirm end 结束执行...xid:{}",transId);
}
/**
* @param accountNo
* @param amount
*/
public void cancelMethod(String accountNo, Double amount){
//获取全局事务id
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank2 cancel begin 开始执行...xid:{}",transId);
}
}
暴露出去的接口Bank2Controller
@RestController
public class Bank2Controller {
@Autowired
AccountInfoService accountInfoService;
@GetMapping("/transfer")
public Boolean transfer(@RequestParam("amount") Double amount) {
this.accountInfoService.updateAccountBalance("2", amount);
return true;
}
}
启动类
@SpringBootApplication(exclude = MongoAutoConfiguration.class)
@EnableDiscoveryClient
@EnableAspectJAutoProxy
@ComponentScan({"com.congge","org.dromara.hmily"})
public class Bank2App {
public static void main(String[] args) {
SpringApplication.run(Bank2App.class,args);
}
}
最后启动bank2服务
测试
首先在数据库中,我们将用户A的账户初始化一条数据,用户B的数据为0
1、正常测试
浏览器输入:http://localhost:7002/bank1/transfer?amount=1 ,即在bank1中进行调用
执行成功,然后我们去控制台查看输出日志帮助我们更好的理解执行过程
同时可以看到,在程序启动的时候,初始化到hmily库下面的2张表,数据为空,即在执行成功的情况下数据最后被清理了
2、异常测试
1、用户B事务失败,用户A事务回滚成功
浏览器输入:http://localhost:7002/bank1/transfer?amount=10000,很明显,用户A 账户上面并没有这么多的余额,通过bank1的输入日志可以看到
同时用户A和B的账户并没有发生异常
2、用户A事务失败,用户B分支事务回滚成功
我们在代码里面人为制造一个异常,该异常产生在RPC调用之后
浏览器输入:http://localhost:7002/bank1/transfer?amount=2
然后我们去数据库观察一下记录,验证一下结果,可以发现用户A的账户记录依然是999,B的账户还是1,说明bank2的事务回滚成功了
还有更多的异常,有兴趣的同学可以继续验证,比如超时异常等,本篇篇幅较长,需要耐心观看!本篇到此结束最后感谢观看!