情景说明
项目上有涉及账务订单的模块需要添加分布式事务,了解到seata的AT模式代码侵入量比较小,所以研究了一下,但是跟着网上各种教程资料走了一遍发现还是会有不少的坑,特此分享一下踩坑记录。
环境
由于项目组已经有了现成的一套k8s开发环境,这里我准备直接在本地windows下部署seata,然后在原工程结构上添加seata,也是为了后续更好地在项目上集成。
部署
安装部署seata版本1.4.2 ,下载地址;
下载后目录应该如下
这时候我们需要修改config目录里面两个文件file.conf和registry.conf
file.conf修改点如下,主要是自定义的事务组,修改db模式以及数据库连接信息,这边我是新建了一个数据库seata,里面需要添加三张表,建表语句如果压缩包里面没有可以上官网找,我这里直接贴出来
CREATE TABLE IF NOT EXISTS `global_table`
(
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`status` TINYINT NOT NULL,
`application_id` VARCHAR(32),
`transaction_service_group` VARCHAR(32),
`transaction_name` VARCHAR(128),
`timeout` INT,
`begin_time` BIGINT,
`application_data` VARCHAR(2000),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`xid`),
KEY `idx_gmt_modified_status` (`gmt_modified`, `status`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
CREATE TABLE IF NOT EXISTS `branch_table`
(
`branch_id` BIGINT NOT NULL,
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`resource_group_id` VARCHAR(32),
`resource_id` VARCHAR(256),
`branch_type` VARCHAR(8),
`status` TINYINT,
`client_id` VARCHAR(64),
`application_data` VARCHAR(2000),
`gmt_create` DATETIME(6),
`gmt_modified` DATETIME(6),
PRIMARY KEY (`branch_id`),
KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
CREATE TABLE IF NOT EXISTS `lock_table`
(
`row_key` VARCHAR(128) NOT NULL,
`xid` VARCHAR(96),
`transaction_id` BIGINT,
`branch_id` BIGINT NOT NULL,
`resource_id` VARCHAR(256),
`table_name` VARCHAR(32),
`pk` VARCHAR(36),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`row_key`),
KEY `idx_branch_id` (`branch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
registry.conf修改点主要也是模式启用nacos,然后加上自己的nacos连接信息,其中注意group和namespace要与你的nacos一致,因为seata的配置项太多了,我这里新建了一个group单独放seata的配置项,下面一步就是将seata配置信息导入到nacos config配置中心
config.txt和nacos-config.sh修改点,config.txt就是seata各种详细的配置,执行 nacos-config.sh 即可将这些配置导入到nacos,这样就不需要将file.conf和registry.conf放到我们的项目中了,需要什么配置就直接从nacos中读取。
笔者下载的1.4.2版本conf文件夹内没有config.txt和nacos-config.sh文件,所以要单独下载放入,下面有获取地址
nacos-config.sh下载地址
config.txt下载地址
config.txt修改项这里完整贴出来,注意中文注释需要去掉,否则运行可能会报错,我这里是为了解释说明
transport.type=TCP
transport.server=NIO
transport.heartbeat=true
transport.enableClientBatchSendRequest=true
transport.threadFactory.bossThreadPrefix=NettyBoss
transport.threadFactory.workerThreadPrefix=NettyServerNIOWorker
transport.threadFactory.serverExecutorThreadPrefix=NettyServerBizHandler
transport.threadFactory.shareBossWorker=false
transport.threadFactory.clientSelectorThreadPrefix=NettyClientSelector
transport.threadFactory.clientSelectorThreadSize=1
transport.threadFactory.clientWorkerThreadPrefix=NettyClientWorkerThread
transport.threadFactory.bossThreadSize=1
transport.threadFactory.workerThreadSize=default
transport.shutdown.wait=3
# 这里是nacos的配置组,非常重要!
service.vgroupMapping.my_test_tx_group=default
service.default.grouplist=127.0.0.1:8091
service.enableDegrade=false
service.disableGlobalTransaction=false
client.rm.asyncCommitBufferLimit=10000
client.rm.lock.retryInterval=10
client.rm.lock.retryTimes=30
client.rm.lock.retryPolicyBranchRollbackOnConflict=true
client.rm.reportRetryCount=5
client.rm.tableMetaCheckEnable=false
client.rm.tableMetaCheckerInterval=60000
client.rm.sqlParserType=druid
client.rm.reportSuccessEnable=false
client.rm.sagaBranchRegisterEnable=false
client.rm.tccActionInterceptorOrder=-2147482648
client.tm.commitRetryCount=5
client.tm.rollbackRetryCount=5
client.tm.defaultGlobalTransactionTimeout=60000
client.tm.degradeCheck=false
client.tm.degradeCheckAllowTimes=10
client.tm.degradeCheckPeriod=2000
client.tm.interceptorOrder=-2147482648
store.mode=file
store.lock.mode=file
store.session.mode=file
store.publicKey=
store.file.dir=file_store/data
store.file.maxBranchSessionSize=16384
store.file.maxGlobalSessionSize=512
store.file.fileWriteBufferCacheSize=16384
store.file.flushDiskMode=async
store.file.sessionReloadReadSize=100
# 以下是db连接信息,需要注意
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.cj.jdbc.Driver
store.db.url=jdbc:mysql://172.16.4.25:30726/seata?useUnicode=true&characterEncoding=utf8&useSSL=false&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true&allowMultiQueries=true
store.db.user=seata
store.db.password=seata123456
store.db.minConn=5
store.db.maxConn=30
store.db.globalTable=global_table
store.db.branchTable=branch_table
store.db.distributedLockTable=distributed_lock
store.db.queryLimit=100
store.db.lockTable=lock_table
store.db.maxWait=5000
store.redis.mode=single
store.redis.single.host=127.0.0.1
store.redis.single.port=6379
store.redis.maxConn=10
store.redis.minConn=1
store.redis.maxTotal=100
store.redis.database=0
store.redis.password=
store.redis.queryLimit=100
server.recovery.committingRetryPeriod=1000
server.recovery.asynCommittingRetryPeriod=1000
server.recovery.rollbackingRetryPeriod=1000
server.recovery.timeoutRetryPeriod=1000
server.maxCommitRetryTimeout=-1
# 这里是回滚重试时间,上面rollbackingRetryPeriod是间隔时间,我这里设置重试3秒,防止数据库出现脏写无限重试
server.maxRollbackRetryTimeout=3s
server.rollbackRetryTimeoutUnlockEnable=false
server.distributedLockExpireTime=10000
client.undo.dataValidation=true
client.undo.logSerialization=jackson
client.undo.onlyCareUpdateColumns=true
server.undo.logSaveDays=7
server.undo.logDeletePeriod=86400000
client.undo.logTable=undo_log
client.undo.compress.enable=true
client.undo.compress.type=zip
client.undo.compress.threshold=64k
log.exceptionRate=100
transport.serialization=seata
transport.compressor=none
metrics.enabled=false
metrics.registryType=compact
metrics.exporterList=prometheus
metrics.exporterPrometheusPort=9898
config.txt改好了之后就可以将配置项导入nacos了,我这里直接用的git bash执行nacos-config.sh,在git bash界面输入:
sh nacos-config.sh -h 172.16.4.27 -p 30090 -g local_dev_seata -t d51d2105-3a11-4f34-a7ed-f841ec5c2174 -u nacos -w nacos
注:命令解析:-h -p 指定nacos的端口地址;-g 指定配置的分组,注意,是配置的分组;-t 指定命名空间id; -u -w指定nacos的用户名和密码,同样,这里开启了nacos注册和配置认证的才需要指定。
当然也可以直接修改nacos-config.sh脚本,成功后nacos配置项如图:
配置导入到nacos以后就可以直接启动seata了,找到bin目录下的seata-server.bat,直接管理员身份运行即可
成功启动可以在nacos服务列表中看到我们的seata服务
实战(多链路调用)
微服务集成seata,这里我用到四个服务,与前台交互的BFF服务,订单服务,库存服务,账务服务,为了验证在实际项目上的运用,我这里直接拿了项目组已有的bff模块以及其他三个中心服务模块,在三个业务库中分别新建了三张表t_order,t_storage,t_account,代表订单表,库存表和账务表,注意想要实现数据回滚,需要在对应的三个业务库中添加日志回滚表undo_log,如图,建表语句如果压缩包里面没有可以上官网找,我这里还是直接贴出来
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int(11) NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
`ext` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;
seata的代码侵入:在我们部署好seata服务,创建好业务库后,下一步就是代码层面了,相信准备使用或者想要使用seata的朋友对seata已经有所了解,本人对源码也没有研究太深就不班门弄斧了,这里主要介绍seata AT模式相关的代码配置与依赖,为什么是AT模式?毕竟免费而且代码侵入量很少。
- pom
相关服务需要添加seata依赖:<!-- seata--> <dependency> <groupId>io.seata</groupId> <artifactId>seata-all</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>io.seata</groupId> <artifactId>seata-spring-boot-starter</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-seata</artifactId> <version>2.2.0.RELEASE</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.1.10</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.1.10</version> </dependency>
- Application
bff端启动类添加数据源代理,禁止 SpringBoot 自动注入数据源配置。@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class, DruidDataSourceAutoConfigure.class})
- yml
相关服务的yml中添加seata连接信息spring: cloud: refresh: refreshable: none alibaba: seata: enabled: true enable-auto-data-source-proxy: true tx-service-group: my_test_tx_group registry: type: nacos nacos: application: seata-server server-addr: http://172.16.4.27:30090 username: nacos password: nacos config: type: nacos nacos: server-addr: http://172.16.4.27:30090 group: local_dev_seata username: nacos password: nacos namespace: bda53f33-65b6-401b-8f95-d2338f079d4d service: vgroup-mapping: my_test_tx_group: default disable_global_transaction: false client: rm: report-success-enable: false
目前项目组持久层框架用的是jpa,三个业务库的单表增删改查代码这里就不赘述了,bff调用各个业务中心的代码这里贴一下:
@GlobalTransactional(rollbackFor = Exception.class)
public void create(TOrderTrans order) throws Exception {
// String xid = GlobalTransactionContext.getCurrentOrCreate().getXid();
// order.setXid(xid);
TOrderTrans tOrderTrans = order;
// 1 新建订单
logger.info("----->开始新建订单");
String url1 = centerProperties.getGatewayServiceUrl() + centerProperties.getOrderLogicServiceUrl()
+ "/tOrder/createOrder";
ResponseEntity<JsonData> tOrderTransResponse =
restTemplateUtils.post(url1, JSON.toJSON(order), JsonData.class);
if (tOrderTransResponse == null || !JsonEnum.SUCCESS.code().equals(tOrderTransResponse.getBody().getCode())) {
logger.debug("新建订单失败,失败原因:{}", tOrderTransResponse.getBody().getMsg());
} else {
tOrderTrans = JSON.parseObject(JSON.toJSONString(tOrderTransResponse.getBody().getData()), TOrderTrans.class);
}
// 2 扣减库存
logger.info("----->订单微服务开始调用库存,做扣减Count");
String url2 = centerProperties.getGatewayServiceUrl() + centerProperties.getFinanceLogicServiceUrl()
+ "/tStorage/decrease";
TStorageTrans tStorageTrans = new TStorageTrans();
tStorageTrans.setId(order.getProductId());
tStorageTrans.setResidue(order.getCount());
restTemplateUtils.post(url2, JSON.toJSON(tStorageTrans), JsonData.class);
logger.info("----->订单微服务开始调用库存,做扣减End");
// 3 扣减账户
logger.info("----->订单微服务开始调用账户,做扣减Money");
String url3 = centerProperties.getGatewayServiceUrl() + centerProperties.getAccountsLogicServiceUrl()
+ "/tAccount/decrease";
TAccountTrans tAccountTrans = new TAccountTrans();
tAccountTrans.setId(order.getProductId());
tAccountTrans.setResidue(order.getCount());
restTemplateUtils.post(url3, JSON.toJSON(tAccountTrans), JsonData.class);
logger.info("----->订单微服务开始调用账户,做扣减End");
// 4 修改订单状态,从0到1,1代表已完成
logger.info("----->修改订单状态开始");
String url4 = centerProperties.getGatewayServiceUrl() + centerProperties.getOrderLogicServiceUrl()
+ "/tOrder/updateStatus";
tOrderTrans.setStatus("1");
restTemplateUtils.post(url4, JSON.toJSON(tOrderTrans), JsonData.class);
logger.info("----->下订单结束");
throw new Exception("测试失败");
}
抛开业务代码不谈,重点其实就是 @GlobalTransactional 注解,在bff服务中service方法加上这个注解其实就是开启全局事务。
再说一下业务逻辑,这里测试的是下订单->减库存->扣余额->改订单状态的场景,因为笔者这里的业务中心服务又分逻辑服务和数据服务(对应logic-service和db-service),这就导致服务调用可能是多链路的,以上流程正常应该是
这种业务场景其实代码写到这里已经可以实现分布式事务回滚了,bff调用订单、库存以及账务服务时,以订单为例,首先业务表插入一条数据:
同时订单库的undo_log表中会记录一条快照信息,分别是订单表变更前(before images)和变更后(after image)的快照,事务结束正常就会释放,异常就会根据快照回滚,我这里是打了个断点来截取快照信息。
实际上由于业务中心逻辑层数据层的分割,调用链可能会是
甚至于可能是这种
针对这些多链路调用服务的情况,笔者亲测除了BFF层发生异常事务会回滚,其他logic或者db服务即使发生异常影响的也只是它自己的业务数据,其他模块数据依旧,另外在logic层多次调用logic以及db服务时,如果其中一次调用失败,其他调用的数据也不会回滚,这就违背了我们一开始在BFF层service方法加全局事务的初衷。其实这就是全局事务,事务管理器TM(Transaction Manager)需要在全局事务外层捕获全局异常,所以调用链内部的异常需要抛到最外层统一处理。总结起来就是A->B->C->…服务调用,虽然我们的GlobalTransactional全局事务注解只是加在A的service方法上,但是在BC服务中的异常我们需要主动抛出来,然后在A的service判断BC等服务是否成功调用,不成功则手动throw,注意,如果使用try catch的话,需要在catch块中手动添加代码回滚。
GlobalTransactionContext.reload(RootContext.getXID()).rollback();
踩坑总结
- 启动报错 这边添加完代码启动服务会有报错,原因是工程中已经存在一个叫做serviceProperties的bean,是项目上用来配置各个服务地址的,而seata的jar包里也存在一个重名的io.seata.spring.boot.autoconfigure.properties.file.ServiceProperties,这里我们直接将自己工程里面的serviceProperties重命名就好了。
Caused by: org.springframework.context.annotation.ConflictingBeanDefinitionException: Annotation-specified bean name 'serviceProperties' for bean class [io.seata.spring.boot.autoconfigure.properties.file.ServiceProperties] conflicts with existing, non-compatible bean definition of same name and class [com.XXX.center.accounts.logic.constant.ServiceProperties]
-
db数据未回滚
第一种情况是多链路调用链内部的异常需要抛到最外层统一处理,或者异常被catch住了,这种修改一下业务层异常处理逻辑或者添加代码手动回滚即可;
第二种情况就是配置问题了,公司内部的组件如果实现了WebMvcConfigurer接口或者继承了WebMvcConfigurationSupport会导致XID无法正常传递,就会导致分支事务无法注册,分支事务就会不受TC管理。
/**
* 添加SeataHandlerInterceptor拦截器
*/
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SeataHandlerInterceptor()).addPathPatterns(new String[]{"/**"});
}
以我的工程为例,公司logic服务存在一个JsonConfig实现了WebMvcConfigurer接口,导致全局事务回滚时,logic层数据会不会滚,解决方法也很简单,参考com.alibaba.cloud.seata.web.SeataHandlerInterceptorConfiguration#addInterceptors将xid传递拦截器加入即可
由于笔者持久层框架用的是jpa,也是遇到了另一种情况:逻辑层直接调用jpa的通用的数据访问控制层(如下图方式2),这种也无法回滚,但是这种调用的本质还是请求db的微服务,然后调用jpa通用的数据访问控制层(AbstractRepositoryRestController),那就在db的aop层去传递xid即可。
解决方案:添加切面前置通知 @Before (前置通知, 在方法执行之前执行),手动绑定xid
@Before(value = "log()")
public void before(JoinPoint joinPoint) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
String xid = RootContext.getXID();
String rpcXid = request.getHeader("TX_XID");
// if (logger.isDebugEnabled()) {
// logger.debug("xid in RootContext {} xid in RpcContext {}", xid, rpcXid);
// }
if (xid == null && rpcXid != null) {
RootContext.bind(rpcXid);
// if (logger.isDebugEnabled()) {
// logger.debug("bind {} to RootContext", rpcXid);
// }
}
}
}
至此,seata的部署、启动、集成以及多链路调用各种情景测试回滚完毕。