五、案例实战
1、业务说明
业务说明:
1、用户向Order服务发起下订单的请求;
2、Order服务收到请求后,开始创建订单;
3、Order服务向Storage库存服务发起请求,减去商品库存;
4、Order服务向Account账户服务发起请求,减少账户余额;
5、全部执行成功,则成功创建订单。
2、项目结构
说明:
1、account工程是账户服务,用户管理账户余额。
2、db-init工程用户初始化项目表结构和数据。
3、easy-id-generator工程用来生成全局唯一的订单id,用来控制创建订单方法的幂等性。
4、eureka-server工程是采用eureka作为注册中心。
5、order工程是订单服务,用来新建订单。
6、order-parent是整合微服务项目的公共父依赖,类似spring-boot-starter-parent。
7、storage工程是库存服务,用来管理商品库存。
3、maven依赖
spring cloud和spring boot的版本对应关系:
spring cloud、spring boot、spring cloud Alibaba(spring-cloud-starter-alibaba-seata)三者的版本对应关系。
和seata之间的版本对应关系:官方地址
注意:
spring-cloud-starter-alibaba-seata归属与Spring Cloud Alibaba体系,两者版本保持一致。
版本说明:
spring-cloud-starter-alibaba-seata 2.1.0内嵌seata-all 0.7.1,2.1.1内嵌seata-all 0.9.0,2.2.0内嵌seata-spring-boot-starter 1.0.0, 2.2.1内嵌seata-spring-boot-starter 1.1.0。
其中seata-spring-boot-starter的核心是包含一个对应的seata-all依赖,两者的版本保持一致。
引用的时候主要需要保证spring-cloud-starter-alibaba-seata和当前项目的spring cloud的版本保持一致,
seata的版本可以通过exclusion排除默认依赖后,升级成较新的依赖。这样就做到了spring cloud版本和seate版本的解耦。
spring-cloud-starter-alibaba-seata推荐依赖配置方式
<dependency> <groupId>io.seata</groupId> <artifactId>seata-spring-boot-starter</artifactId> <version>最新版</version> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId> <version>2.2.1.RELEASE</version> <exclusions> <exclusion> <groupId>io.seata</groupId> <artifactId>seata-spring-boot-starter</artifactId> </exclusion> </exclusions> </dependency>
说明:
spring-cloud-starter-alibaba-seata的版本选择与项目依赖的spring cloud相匹配的版本。通过exclusion排除默认依赖的seata,然后引入自己想要的seata版本,实现spring cloud的版本和seata的依赖版本解耦。
注意spring-cloud-starter-alibaba-seata 2.1.0内嵌seata-all 0.7.1,2.1.1内嵌seata-all 0.9.0,2.2.0内嵌seata-spring-boot-starter 1.0.0, 2.2.1内嵌seata-spring-boot-starter 1.1.0。
所以如果是spring-cloud-starter-alibaba-seata 2.1.x的版本,依赖的是seata-all ,所以推荐如下配置:
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-seata</artifactId> <version>${spring-cloud-alibaba-seata.version}</version> <exclusions> <exclusion> <artifactId>seata-all</artifactId> <groupId>io.seata</groupId> </exclusion> </exclusions> </dependency> <dependency> <groupId>io.seata</groupId> <artifactId>seata-all</artifactId> <version>${seata.version}</version> </dependency>
order-parent父级项目的核心依赖:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.2.6.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>cn.tedu</groupId> <artifactId>order-parent</artifactId> <version>1.0-SNAPSHOT</version> <packaging>pom</packaging> <name>order-parent</name> <properties> <mybatis-plus.version>3.3.2</mybatis-plus.version> <druid-spring-boot-starter.version>1.1.23</druid-spring-boot-starter.version> <seata.version>1.3.0</seata.version> <spring-cloud-alibaba-seata.version>2.0.0.RELEASE</spring-cloud-alibaba-seata.version> <spring-cloud.version>Hoxton.SR6</spring-cloud.version> <skipTests>true</skipTests> </properties> <dependencies> <!-- 打开 seata 依赖 --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-seata</artifactId> <version>${spring-cloud-alibaba-seata.version}</version> <exclusions> <exclusion> <artifactId>seata-all</artifactId> <groupId>io.seata</groupId> </exclusion> </exclusions> </dependency> <dependency> <groupId>io.seata</groupId> <artifactId>seata-all</artifactId> <version>${seata.version}</version> </dependency> …… </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
4、属性配置
(1)、conf文件方式
针对spring-cloud-starter-alibaba-seata 2.1.x的版本,由于依赖的是seata-all,没有整合更丰富的seata配置,所以一般采用的是属性文件 + conf配置文件的方式进行配置:
属性文件配置如下:
spring.cloud.alibaba.seata.tx-service-group=order_tx_group
说明:
通过设置spring.cloud.alibaba.seata.tx-service-group属性设置事务服务的分组名称。
file.conf和registry.conf文件都可以在seata按照文件下找到。其中file.conf用来配置网络、服务端server以及客户端的相关属性。
registry.conf用来配置seata server注册的注册中心,以及属性文件保存的配置中心。
file.conf核心配置:
service { #transaction service group mapping # order_tx_group 与 yml 中的 “tx-service-group: order_tx_group” 配置一致 # “seata-server” 与 TC 服务器的注册名一致 # order_tx_group名称需要和属性文件中配置的spring.cloud.alibaba.seata.tx-service-group的值相匹配 # vgroupMapping属性是为了配置 vgroupMapping.order_tx_group = "seata-server" #only support when registry.type=file, please don't set multiple addresses # seata-server的名称需要和上面的vgroupMapping.order_tx_group的值相匹配。 seata-server.grouplist = "127.0.0.1:8091" #degrade, current not support enableDegrade = false #disable seata disableGlobalTransaction = false }
注意:
1、vgroupMapping.order_tx_group中的order_tx_group需要和属性文件中配置的spring.cloud.alibaba.seata.tx-service-group的值相匹配
2、 seata-server.grouplist中的seata-server.需要和vgroupMapping.order_tx_group的值匹配
核心的目的就是指定seata-server的服务分组名称,以及seata-server服务对应的服务器ip地址。
registry.conf核心配置:
这里由于测试,且我的seata-server服务采用的默认file方式部署的,所以这里的registry.conf也都是采用的默认的,没有做相关修改。实际使用中可以根据情况和需求,修改成自己对应的配置中心和注册中心。
registry { # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa type = "file" file { name = "file.conf" } } config { # file、nacos 、apollo、zk、consul、etcd3、springCloudConfig type = "file" file { name = "file.conf" } }
(2)、属性文件配置方式
针对spring-cloud-starter-alibaba-seata 2.2.x以后的版本,由于依赖的是seata-spring-boot-starter,整合了更丰富的seata配置,所以可以省去conf文件,将seata相关数据都在属性文件中进行配置。
application.yml属性配置:
seata: enabled: true tx-service-group: order_tx_group service: vgroup-mapping: order_tx_group: seata-server grouplist: seata-server: 127.0.0.1:8091
注意:
1、注意属性文件配置中map类型属性配置的对应关系。
2、注意几个属性之间的关联关系。
(3)、补充说明
事务分组说明
事务分组是什么?
1.事务分组是seata的资源逻辑,类似于服务实例。在file.conf中的my_test_tx_group就是一个事务分组。
2.通过事务分组如何找到后端集群?
首先程序中配置了事务分组(GlobalTransactionScanner 构造方法的txServiceGroup参数),程序会通过用户配置的配置中心去寻找service.vgroup_mapping.事务分组配置项,取得配置项的值就是TC集群的名称。拿到集群名称程序通过一定的前后缀+集群名称去构造服务名,各配置中心的服务名实现不同。拿到服务名去相应的注册中心去拉取相应服务名的服务列表,获得后端真实的TC服务列表。
3.为什么这么设计,不直接取服务名?
这里多了一层获取事务分组到映射集群的配置。这样设计后,事务分组可以作为资源的逻辑隔离单位,当发生故障时可以快速failover。
关于grouplist问题说明下
1.什么时候会用到file.conf中的default.grouplist?
当registry.type=file时会用到,其他时候不读。
2.default.grouplist的值列表是否可以配置多个?
可以配置多个,配置多个意味着集群,但当store.mode=file时,会报错。原因是在file存储模式下未提供本地文件的同步,所以需要使用store.mode=db,通过db来共享TC集群间数据
3.是否推荐使用default.grouplist?
不推荐,如问题1,当registry.type=file时会用到,也就是说这里用的不是真正的注册中心,不具体服务的健康检查机制当tc不可用时无法自动剔除列表,推荐使用nacos 、eureka、redis、zk、consul、etcd3、sofa。registry.type=file或config.type=file 设计的初衷是让用户再不依赖第三方注册中心或配置中心的前提下,通过直连的方式,快速验证seata服务。
5、TCC事务代码
(1)、创建订单核心代码
@GlobalTransactional @Override public void create(Order order) { // 从全局唯一id发号器获得id Long orderId = easyIdGeneratorClient.nextId("order_business"); order.setId(orderId); String xid = RootContext.getXID(); log.info("New Transaction Begins: " + xid); // orderMapper.create(order); // 这里修改成调用 TCC 第一节端方法 orderTccAction.prepareCreateOrder( null, order.getId(), order.getUserId(), order.getProductId(), order.getCount(), order.getMoney()); // 修改库存 storageClient.decrease(order.getProductId(), order.getCount()); // 修改账户余额 accountClient.decrease(order.getUserId(), order.getMoney()); }
说明:
1、通过添加 @GlobalTransactional注解,开启seata全局分布式事务。
2、通过生成全局的订单id控制事务的幂等性。
(2)、OrderTccAction
@LocalTCC public interface OrderTccAction { /* 第一阶段的方法 通过注解指定第二阶段的两个方法名 BusinessActionContext 上下文对象,用来在两个阶段之间传递数据 @BusinessActionContextParameter 注解的参数数据会被存入 BusinessActionContext */ @TwoPhaseBusinessAction(name = "orderTccAction", commitMethod = "commit", rollbackMethod = "rollback") boolean prepareCreateOrder(BusinessActionContext businessActionContext, @BusinessActionContextParameter(paramName = "orderId") Long orderId, @BusinessActionContextParameter(paramName = "userId") Long userId, @BusinessActionContextParameter(paramName = "productId") Long productId, @BusinessActionContextParameter(paramName = "count") Integer count, @BusinessActionContextParameter(paramName = "money") BigDecimal money); // 第二阶段 - 提交 boolean commit(BusinessActionContext businessActionContext); // 第二阶段 - 回滚 boolean rollback(BusinessActionContext businessActionContext); }
说明:
1、通过在接口上添加@LocalTCC注解,声明本地TCC事务控制接口
2、通过@TwoPhaseBusinessAction注解,声明Try、Confirm、Cancel三个阶段对应的具体方法。
(3)、OrderTccActionImpl
@Component @Slf4j public class OrderTccActionImpl implements OrderTccAction { @Autowired private OrderMapper orderMapper; @Transactional(rollbackFor = Exception.class) @Override public boolean prepareCreateOrder(BusinessActionContext businessActionContext, Long orderId, Long userId, Long productId, Integer count, BigDecimal money) { log.info("创建 order 第一阶段,预留资源 - "+businessActionContext.getXid()); //因为orderId是唯一的,不能重复执行,满足幂等性, 创建状态为0(创建中)的订单 Order order = new Order(orderId, userId, productId, count, money, 0); orderMapper.create(order); //模拟异常 /* if (Math.random() < 0.9999) { throw new RuntimeException("模拟try阶段出现 异常"); }*/ //事务成功,保存一个标识,供第二阶段进行判断 ResultHolder.setResult(getClass(), businessActionContext.getXid(), "p"); return true; } @Transactional(rollbackFor = Exception.class) @Override public boolean commit(BusinessActionContext businessActionContext) { log.info("创建 order 第二阶段提交,修改订单状态1 - "+businessActionContext.getXid()); // 防止幂等性,如果commit阶段重复执行则直接返回 if (ResultHolder.getResult(getClass(), businessActionContext.getXid()) == null) { return true; } //Long orderId = (Long) businessActionContext.getActionContext("orderId"); long orderId = Long.parseLong(businessActionContext.getActionContext("orderId").toString()); //确认提交,将订单状态修改为1(创建完成) orderMapper.updateStatus(orderId, 1); //提交成功是删除标识 ResultHolder.removeResult(getClass(), businessActionContext.getXid()); return true; } @Transactional(rollbackFor = Exception.class) @Override public boolean rollback(BusinessActionContext businessActionContext) { log.info("创建 order 第二阶段回滚,删除订单 - "+businessActionContext.getXid()); //第一阶段没有完成的情况下,不必执行回滚(空回滚处理) //因为第一阶段有本地事务,事务失败时已经进行了回滚。 //如果这里第一阶段成功,而其他全局事务参与者失败,这里会执行回滚 //幂等性控制:如果重复执行回滚则直接返回 if (ResultHolder.getResult(getClass(), businessActionContext.getXid()) == null) { return true; } //创建识别,执行Cancel操作,删除临时订单 //Long orderId = (Long) businessActionContext.getActionContext("orderId"); long orderId = Long.parseLong(businessActionContext.getActionContext("orderId").toString()); orderMapper.deleteById(orderId); //回滚结束时,删除标识 ResultHolder.removeResult(getClass(), businessActionContext.getXid()); return true; } }
说明:
注意TCC具体的方法实现中,对幂等、空回滚、悬挂等问题的解决。
核心逻辑解说:
1.prepareCreateOrder方法对应try阶段,会根据唯一订单号创建临时状态订单,并在当前类下注入事务id
2.commit对应confirm阶段,首先查看当前类是否和事务ID(businessActionContext.getXid())有关联,没有关联就直接返回true,不进行真实的提交逻辑。如果关联了事务id,则修改订单状态为已完成,并移除关联的事务ID。
3.rollback方法对应Cancel阶段,也是首先判断当前类下是否与businessActionContext.getXid()相关联,没有关联就直接返回true防止出现空回滚。如果有关联,则执行回滚操作,根据订单id删除临时状态的订单记录。
4.注意TCC的3个实现方法都添加了@Transactional注解开启了事务控制,保证本地分支事务的ACID特性。
(4)、订单表说明
CREATE TABLE `order` ( `id` bigint(11) NOT NULL, `user_id` bigint(11) DEFAULT NULL COMMENT '用户id', `product_id` bigint(11) DEFAULT NULL COMMENT '产品id', `count` int(11) DEFAULT NULL COMMENT '数量', `money` decimal(11,0) DEFAULT NULL COMMENT '金额', `status` int(1) DEFAULT NULL COMMENT '订单状态:0:创建中;1:已完结', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
说明:
在TCC模式下,由于需要资源预留,一般都会在事务参与的表中添加一个资源预留字段。
在订单表中的呈现是新增了订单状态字段,记录创建中状态的订单。
(5)、order服务项目结构
说明:
1、远程调用都采用feign调用,统一存放在feign目录下
2、tcc相关的接口,统一放在tcc目录下
3、由于order服务采用的是spring-cloud-starter-alibaba-seata 2.2.1.RELEASE版本,seata-spring-boot-starter为1.4.1版本,order中采用的是属性配置项设置的seata相关属性,省略了file.conf和registry.conf文件。
(6)、account服务核心代码
@Component @Slf4j public class AccountTccActionImpl implements AccountTccAction { @Autowired private AccountMapper accountMapper; @Transactional(rollbackFor = Exception.class) @Override public boolean prepareDecreaseAccount(BusinessActionContext businessActionContext, Long userId, BigDecimal money) { log.info("减少账户金额,第一阶段锁定金额,userId="+userId+", money="+money); //剩余可用金额 Account account = accountMapper.selectById(userId); if (account.getResidue().compareTo(money) < 0) { throw new RuntimeException("账户金额不足"); } /* * 冻结可用金额 余额-money 冻结+money */ accountMapper.updateFrozen(userId, account.getResidue().subtract(money), account.getFrozen().add(money)); //模拟异常 if (Math.random() < 0.3) { throw new RuntimeException("模拟异常"); } //保存标识 ResultHolder.setResult(getClass(), businessActionContext.getXid(), "p"); return true; } /** * Confirm 方法一定要在 Try 方法之后执行。因此,Confirm 方法只需要关注重复提交的问题。 * @param businessActionContext * @return */ @Transactional(rollbackFor = Exception.class) @Override public boolean commit(BusinessActionContext businessActionContext) { long userId = Long.parseLong(businessActionContext.getActionContext("userId").toString()); BigDecimal money = new BigDecimal(businessActionContext.getActionContext("money").toString()); log.info("减少账户金额,第二阶段,提交,userId="+userId+", money="+money); //防止重复提交,确认try方法已经执行 if (ResultHolder.getResult(getClass(), businessActionContext.getXid()) == null) { return true; } accountMapper.updateFrozenToUsed(userId, money); //删除标识 ResultHolder.removeResult(getClass(), businessActionContext.getXid()); return true; } @Transactional(rollbackFor = Exception.class) @Override public boolean rollback(BusinessActionContext businessActionContext) { long userId = Long.parseLong(businessActionContext.getActionContext("userId").toString()); BigDecimal money = new BigDecimal(businessActionContext.getActionContext("money").toString()); //防止重复提交,确保try方法已经执行,防止空回滚 if (ResultHolder.getResult(getClass(), businessActionContext.getXid()) == null) { return true; } log.info("减少账户金额,第二阶段,回滚,userId="+userId+", money="+money); accountMapper.updateFrozenToResidue(userId, money); //删除标识 ResultHolder.removeResult(getClass(), businessActionContext.getXid()); return true; } }
说明:
账户金额扣减TCC逻辑的3个阶段说明
1.prepareDecreaseAccount方法对应Try阶段,主要是执行资源预留,将要扣减的金额先保留到冻结金额中。
2.commit方法对应Confirm阶段,事务提交过程,主要负责将冻结的金额真正扣除。
3.rollback方法对应Cancel阶段,事务回滚,主要负责将冻结的金额解冻返回到账户的余额中。
(7)、account表
CREATE TABLE `account` ( `id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT 'id', `user_id` bigint(11) DEFAULT NULL COMMENT '用户id', `total` decimal(10,0) DEFAULT NULL COMMENT '总额度', `used` decimal(10,0) DEFAULT NULL COMMENT '已用余额', `residue` decimal(10,0) DEFAULT '0' COMMENT '剩余可用额度', `frozen` decimal(10,0) DEFAULT '0' COMMENT 'TCC事务锁定的金额', PRIMARY KEY (`id`), UNIQUE KEY `user_id` (`user_id`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
说明:
主要是新增了frozen字段,用来保存暂时冻结的金额。
6、测试
1.先启动db-init服务,初始化数据库
2.再启动eureka-server服务,作为注册中心
3.然后启动其他服务。
发起创建订单请求:http://localhost:8083/create?userId=1&productId=1&count=10&money=100
、
7、异常模拟
分别在TCC的各个阶段添加模拟的异常,看程序的执行情况。
//模拟try阶段异常 if (Math.random() < 0.9999) { throw new RuntimeException("模拟try阶段出现异常"); } if (Math.random() < 0.9999) { throw new RuntimeException("模拟commit阶段异常"); } if (Math.random() < 0.9999) { throw new RuntimeException("模拟cancel阶段异常"); }
说明:
事务的执行顺序是:创建订单——》修改库存——》减账户余额,
创建订单的try阶段出现异常,会触发创建订单TCC操作中的Cancel操作进行空回滚;
修改库存的try阶段出现异常,会触发创建订单TCC操作中的Cancel操作以及修改库存TCC操作中的Cancel操作进行空回滚;
减账户余额的try阶段出现异常,会触发创建订单TCC操作中的Cancel操作以及扣减库存的TCC操作中的Cancel操作,减账户余额的TCC操作中的Cancel操作进行空回滚。
Confirm操作出现异常后,会不停的重试,直到执行成功。
Cancel操作出现异常后,也会不停重试,直到执行成功。
(针对这里Confirm、Cancel的操作建议加入重试次数,失败一定操作后停止,记录相关记录,后面人工介入处理)。
8、seata tcc模式实战源码
总结
1、理解TCC模式的底层逻辑:核心是将一个完整的事务分成了2个阶段,一阶段是所有事务参与者RM都注册到TC事务协调器,并发起分支事务请求进行资源预留,然后主动向TC汇报执行结果。二阶段TC事务协调器会根据收到的所有RM一阶段分支事务的执行结果来判断让RM继续执行提交操作还是回滚操作。
2、知道TCC模式和AT模式差别以及相互之间的优势。
3、实现TCC需要自己实现prepare、commit、rollback方法,并考虑空回滚、幂等、悬挂等问题。
4、知道怎么使用Seata框架实现 TCC事务。
5、需要在相关表中添加字段,用来保存预留资源。
很多人对分布式事务都心存畏惧,一是工作中接触的机会少,二是网上可以参考的实际可用的案例真的太少,大多数小伙伴都只是背了下各种分布式事务的相关实现方案的理论,而没有实际实现经验。
推荐大家都自己动手实战一番,做到心中有数,遇事不慌,而不是空谈理论。