一、背景
目前公司很多应用因为历史原因,一个应用访问多个数据库进行插入和更新操作,这就可能产生数据一致性问题,同时应用如果跨服务的调用也可能会产生事务问题。
目前应用是采用dynamic-datasource-spring-boot-starter做多数据源控制的。而seata是一款开源的分布式事务框架。我们了解到dynamic-datasource-spring-boot-starter的新版本已经支持基于seata的分布式事务了,而官网的例子基本都是标准的单数据源的整合,下面我们分别对dynamic-datasource-spring-boot-starter、seata以及它们的整合的进行功能使用实践。
二、dynamic-datasource
dynamic-datasource-spring-boot-starter 是一个基于springboot的快速集成多数据源的启动器。相关的特性如下,更多的信息可以查看github官网。我们这里主要用到了它对seata数据源的支持和动态切换数据源的特性。
官网地址:https://github.com/baomidou/dynamic-datasource-spring-boot-starter
支持 数据源分组 ,适用于多种场景 纯粹多库 读写分离 一主多从混合模式。
支持数据库敏感配置信息加密 ENC()。
支持每个数据库独立初始化表结构schema和数据库database。
支持无数据源启动,支持懒加载数据源(需要的时候再创建连接)。
支持自定义注解 ,需继承DS(3.2.0+)。
提供并简化对Druid,HikariCp,BeeCp,Dbcp2的快速集成。
提供对Mybatis-Plus,Quartz,ShardingJdbc,P6sy,Jndi等组件的集成方案。
提供自定义数据源来源 方案(如全从数据库加载)。
提供项目启动后动态增加移除数据源方案。
提供Mybatis环境下的 纯读写分离方案。
提供使用 spel动态参数 解析数据源方案。内置spel,session,header,支持自定义。
支持多层数据源嵌套切换 。(ServiceA >>> ServiceB >>> ServiceC)。
提供基于seata的分布式事务方案。
提供本地多数据源事务方案。
三、seata介绍
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata主打AT模式。AT模式的机制如下:
一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
二阶段:提交异步化,非常快速地完成。回滚通过一阶段的回滚日志进行反向补偿。
四、动手实践
在实际使用上面的框架过程中,会遇到各式各样的场景,遇到各种问题。实践出真知,下面我们模拟简单的业务实际动手来检测下他们的功能吧!
4.1 业务流程图
如上图所示,我们模拟一个下单的业务,主要的业务流程:
postman使用http请求下单服务
下单服务访问订单库产生下单记录、访问信用分库校验和扣减信用分
访问库存库,扣减库存
如果下单成功,最终会返回true
4.2 准备环境
数据库:seata_storage(库存数据库)、seata_order(订单数据库)、seata_credit(信用分数据库)、seata(seata数据库)
应用:order-service(下单服务)、storage-service(库存服务)、seata tc(分布式事务协调器)
配置中心: Nacos
seata服务端:本次实践的seata版本为v1.4.2
4.2.1 seata的安装
具体的安装步骤见官网:http://seata.io/zh-cn/docs/ops/deploy-guide-beginner.html
下载tc包,https://github.com/seata/seata/releases/tag/v1.4.2
tc包解压后,修改register.conf,进行nacos地址,名称空间等配置。
nacos配置项,可以将通过脚本:https://github.com/seata/seata/tree/1.4.2/script/config-center/nacos自动上传修改;也可以自行在nacos上手动添加配置项;该配置项主要为seata集群数据库地址和一些开关等;
创建seata数据库,sql脚本见:https://github.com/seata/seata/tree/1.4.2/script/server/db
启动seata服务端,使用解压包中的seataserver.sh启动,可在nacos查看启动的seata集群
踩坑:nacos如果使用带有权限的版本,密码不要带有特殊字符,否则在启动时会一直报403错误,因为从seata的服务端request到nacos时,密码特殊字符被转义传递从而导致错误。
4.2.2 业务应用的配置订单应用(接入了两个数据库):
##########服务注册
spring.cloud.nacos.discovery.server-addr=localhost:8848
spring.cloud.nacos.discovery.username=nacos_test
spring.cloud.nacos.discovery.password=nacos_test
##########seata相关
seata.enabled=true
seata.tx-service-group=my_test_tx_group
seata.enable-auto-data-source-proxy=false
seata.config.type=nacos
seata.config.nacos.data-id=seataServer.properties
seata.config.nacos.server-addr=localhost:8848
seata.config.nacos.application=seata-server
seata.config.nacos.namespace=7322f40d-74de-4679-ad4c-c3dff499cd98
seata.config.nacos.group=SEATA_GROUP
seata.config.nacos.username=seata
seata.config.nacos.password=seata
seata.registry.type=nacos
seata.registry.nacos.server-addr=localhost:8848
seata.registry.nacos.namespace=7322f40d-74de-4679-ad4c-c3dff499cd98
seata.registry.nacos.group=SEATA_GROUP
seata.registry.nacos.username=seata
seata.registry.nacos.password=seata
logging.level.io.seata = debug
########数据源1(主数据源,订单库)
spring.datasource.dynamic.primary=master
spring.datasource.dynamic.seata=true
spring.datasource.dynamic.datasource.master.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.dynamic.datasource.master.url=jdbc:mysql://localhost:3306/seata_order?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.dynamic.datasource.master.username=seata
spring.datasource.dynamic.datasource.master.password=seata
#########数据源2(信用分库)
spring.datasource.dynamic.datasource.credit.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.dynamic.datasource.credit.url=jdbc:mysql://localhost:3306/seata_credit?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.dynamic.datasource.credit.username=seata
spring.datasource.dynamic.datasource.credit.password=seata
库存应用(一个数据库):
##########服务注册
spring.cloud.nacos.discovery.server-addr=localhost:8848
spring.cloud.nacos.discovery.username=nacos_test
spring.cloud.nacos.discovery.password=nacos_test
####seata相关
seata.enabled=true seata.tx-service-group=my_test_tx_group
seata.enable-auto-data-source-proxy=false
seata.config.type=nacos
seata.config.nacos.data-id=seataServer.properties
seata.config.nacos.server-addr=localhost:8848
seata.config.nacos.application=seata-server
seata.config.nacos.namespace=7322f40d-74de-4679-ad4c-c3dff499cd98
seata.config.nacos.group=SEATA_GROUP
seata.config.nacos.username=seata
seata.config.nacos.password=seata
seata.registry.type=nacos
seata.registry.nacos.server-addr=localhost:8848
seata.registry.nacos.namespace=7322f40d-74de-4679-ad4c-c3dff499cd98
seata.registry.nacos.group=SEATA_GROUP
seata.registry.nacos.username=seata
seata.registry.nacos.password=seata
logging.level.io.seata = debug
######数据源
spring.datasource.dynamic.primary=master
spring.datasource.dynamic.seata=true
spring.datasource.dynamic.datasource.master.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.dynamic.datasource.master.url=jdbc:mysql://localhost:3306/seata_storage?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.dynamic.datasource.master.username=seata
spring.datasource.dynamic.datasource.master.password=seata
pom依赖:
dynamic-datasource-spring-boot-starter
com.baomidou
3.2.1
com.alibaba.cloud
spring-cloud-starter-alibaba-seata
io.seata
seata-spring-boot-starter
io.seata
seata-spring-boot-starter
1.4.2
4.3 开始验证
下面我们分多种情况来验证这种多数据源的事务是否有效,再加上SEATA来控制事务会产生什么效果。 4.3.1 同应用多库多表调用逻辑为:订单服务向订单库插入订单记录,接着向信用分库扣除信用分,暂时不访问库存服务1)订单controller的代码:
@RequestMapping("/orderByCredit")
public Boolean orderByCredit(String userId, @Nullable String requestId, int count) {
//①下单,并产生下单日志
orderService.onlyOrder(userId, "product-1", requestId, count);
//② 访问信用库,校验并扣减信用分
creditService.checkHasCredit(userId, count);
return true;
}
2)使用多数据源@DS注解访问credit库修改数据
@DS("credit")
public void checkHasCredit(String userId, Integer val){
Credit credit = creditDAO.selectByPrimaryKey(userId);
if (credit == null) {
throw new RuntimeException("无信用,无法购买");
}
credit.setCredit(credit.getCredit() - val);
creditDAO.updateByPrimaryKey(credit);
if (credit.getCredit() <= 0) {
throw new RuntimeException("信用分不足,无法购买");
}
}
3)使用postman请求使得,新增订单记录成功,并操作第二个数据库信用分不足报错,该情况按照预期,事务不会生效,订单库插入数据成功,接口也报错了。
注:订单库订单表插入成功,订单日志失败也不会回滚4)加上spring的@Transactional注解,下单请求
@RequestMapping("/orderByCredit")
@Transactional
public Boolean orderByCredit(String userId, @Nullable String requestId, int count) {
//①下单,并产生下单日志
orderService.onlyOrder(userId, "product-1", requestId, count);
//② 访问信用库,校验并扣减信用分
creditService.checkHasCredit(userId, count);
return true;
}
这种情况产生找不到表的报错,操作②的@DS("credit")注解失效,没有按照预期访问credit库,访问到操作①的库了,但是操作①插入的数据回滚了。
注:订单库订单表插入成功,订单日志插入失败也会回滚。
小结: 所以在这里我们稍微总结下,在一个方法中,如果操作了多个数据源,不能在外面使用@Transactional来进行事务控制,会使得@DB注解切换数据源不生效,产生报错,但是不报错的部分事务有效,因为还没提交。如何来对这种操作多数据源的情况进行事务控制呢,那就需要使用分布式事务SEATA5)加上seata的@GlobalTransactional注解,下单请求
@RequestMapping("/orderByCredit")
@GlobalTransactional
public Boolean orderByCredit(String userId, @Nullable String requestId, int count) {
//① 下单,并产生下单日志
orderService.onlyOrder(userId, "product-1", requestId, count);
//② 访问信用库,校验并扣减信用分
creditService.checkHasCredit(userId, count);
return true;
}
在seata可用的情况下,步骤①,步骤②任意的地方出现错误,均可以事务回滚。
6)同时加上seata的@GlobalTransactional注解和Spring的@Transactional,下单请求 @Transactional会导致切换数据库异常。
4.3.2 跨应用多库多表
调用逻辑为:
订单服务向订单库插入订单记录,接着向信用分库扣除信用分,远程调用库存服务扣减库存。
1)下订单,使得扣减库存报错
订单服务
@RequestMapping("/orderByCredit")
@GlobalTransactional
public Boolean orderByCredit(String userId, @Nullable String requestId,@Nullable String requestId2, int count) {
//① 下单,并产生下单日志
orderService.onlyOrder(userId, "product-1", requestId, count);
//② 访问直连信用库,校验并扣减信用分
creditService.checkHasCredit(userId, count);
//③ 调用库存服务,扣减库存
storageFeignClient.deductFlow("product-1", count, requestId2);
return true;
}
库存服务
public void deductFlow(String commodityCode, int count, String requestId) throws InterruptedException {
Storage storage = this.deduct(commodityCode, count);
StorageFlow storageFlow = new StorageFlow();
storageFlow.setStorageId(storage.getId());
storageFlow.setFlowId(StringUtils.isEmpty(requestId) ? String.valueOf(System.currentTimeMillis()): requestId);
storageFlowDAO.insert(storageFlow);
}
库存服务中的任意地方报错,调用链的数据库操作均会回滚,@GlobalTransactional注解会将seata申请的xid通过request传播下去,被调用的服务如果接入seata将会组成一个完整的分布式事务。
4.3.3 跨服务调用吃掉异常
1)如下代码所示,下单服务访问了自身应用的数据库,feign远程调用库存服务,但是catch了异常。
@RequestMapping("/orderByCredit")
@GlobalTransactional
public Boolean orderByCredit(String userId, @Nullable String requestId,@Nullable String requestId2, int count) {
//① 下单,并产生下单日志
orderService.onlyOrder(userId, "product-1", requestId, count);
//② 访问直连信用库,校验并扣减信用分
creditService.checkHasCredit(userId, count);
try {
//③ 调用库存服务,扣减库存
storageFeignClient.deductFlow("product-1", count, requestId2);
} catch (Exception e){
e.printStackTrace();
}
}
2)使得接口请求,①、②步骤正常请求,③步骤出现内部数据库主键异常错误。查看结果,全局事务最终commit了,说明seata没有感知异常。
五、总结
在需要分布式事务的方法入口使用@GlobalTransactional即可实现分布式事务
多数据源不能在方法外层加@Transactional,这样会导致切换库异常;可直接使用@GlobalTransactional达到事务效果,因为本来这就是分布式事务场景。
全局事务最终决议是由全局事务入口应用(TM)决定的,即使下游事务节点发生了异常,只要TM没有catch到异常,就不会全局回滚。即决议是由TM发出的,并不是有些文档所说由TC决议的。
分析:tm决议简单快速,性能相对tc来说,tc压力被各个tm所分散
跨服务调用需要将全局事务id传递下去,seata已经封装了相关代码,需要依赖相关的包,如spring-cloud-starter-alibaba-seata
关注IT巅峰技术,私信作者,获取以下2021全球架构师峰会PDF资料。