分布式事务 Seata 业务系统集成
-
业务系统集成 - Client端(TM,RM) ,使用 seata 0.9 实现分布式事务
-
先参考seata-samples 项目,完成一个 dubbo项目,如:my demo
-
创建storage, account, order 三个服务提供者,和一个 business 消费者
-
下图是添加 seata 分布式事务后的处理流程
-
-
添加pom依赖
<dependency> <groupId>io.seata</groupId> <artifactId>seata-all</artifactId> <version>1.1.0</version> </dependency> <!--1. dubbo 依赖请参见上面 Spring boot 2.1.9 + Dubbo 2.7.3 + Nacos 1.1.4 --> <!--2. druid 依赖和 mybatis 依赖请参见 MySQL.md-->
-
建表: undo_log ,SQL语句在源码的script --> client -->at–> db目录下
-- for AT mode you must to init this sql for you business database. the seata server not need it. CREATE TABLE IF NOT EXISTS `undo_log` ( `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'increment id', `branch_id` BIGINT(20) NOT NULL COMMENT 'branch transaction id', `xid` VARCHAR(100) NOT NULL COMMENT 'global transaction id', `context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization', `rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info', `log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status', `log_created` DATETIME NOT NULL COMMENT 'create datetime', `log_modified` DATETIME NOT NULL COMMENT 'modify datetime', PRIMARY KEY (`id`), UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`) ) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';
-
配置参数:将file.conf和registry.conf复制到 spring boot工程resource 目录下
-
file.conf和registry.conf 文件位置在源码的script --> client -->config 目录下
-
如果以file形式注册,默认配置不用改
-
但是file.conf有一处要根据服务名来修改
vgroupMapping.xxx_tx_group = "default" # xxx表示你的spring.application.name
-
问题:如果忘记复制file.conf和registry.conf 文件,会报错: NotSupportYetException: not support register type: null
-
注意:pom 依赖 seata-all , 不能改成 seata-spring-boot-starter ,否则无法识别 file.conf和registry.conf 文件
-
-
配置数据源代理,支持自动和手动两种配置。
// 开启自动配置:使用注解 @EnableAutoDataSourceProxy 开启数据源代理 // 手动配置:需要将业务数据源放到代理数据源中,另外mybatis的SqlSessionFactory也需要手动配置,将代理数据源写入mybatis的SqlSessionFactory中 @Bean("dataSource") public DataSourceProxy dataSource(DataSource druidDataSource) { return new DataSourceProxy(druidDataSource); } // 将代理数据源写入mybatis的SqlSessionFactory中 @Bean public SqlSessionFactory sqlSessionFactory(DataSourceProxy dataSourceProxy) throws Exception { SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSourceProxy); factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources("classpath*:/mappers/*.xml")); factoryBean.setTransactionFactory(new JdbcTransactionFactory()); return factoryBean.getObject(); }
-
初始化 GlobalTransactionScanner - 全局事务扫描器 , 也支持自动和手动两种
- GlobalTransactionScanner负责执行自动扫描注解包含GlobalTransactional注解的代码逻辑
- GlobalTransactionalInterceptor负责对包含GlobalTransactional注解进行拦截
// 自动配置: 引入seata-spring-boot-starter 即可 // 手动配置 public GlobalTransactionScanner globalTransactionScanner() { String applicationName = this.applicationContext.getEnvironment() .getProperty("spring.application.name"); String txServiceGroup = this.seataProperties.getTxServiceGroup(); if (StringUtils.isEmpty(txServiceGroup)) { txServiceGroup = applicationName + "-fescar-service-group"; this.seataProperties.setTxServiceGroup(txServiceGroup); } return new GlobalTransactionScanner(applicationName, txServiceGroup); }
-
实现xid跨服务传递 , 似乎 1.1.0 版本内部已经实现了xid传递
- Seata上的注解@Activate(group = {Constants.PROVIDER, Constants.CONSUMER}, order = 100),表示dubbo的服务提供方跟消费方都会触发到这个过滤器,所以我们的Seata发起者会产生一个XID的传递
-
在需要分布式事务的方法上添加@GlobalTransactional注解,就这么简单。
import org.springframework.stereotype.Service; @Service public class BusinessServiceImp implements BusinessService { @Reference(version = "0.0.1") private StorageService storageService; @Reference(version = "0.0.1") private OrderService orderService; //通过@GlobalTransactional注解的方法会走拦截器GlobalTransactionalInterceptor的执行逻辑 @GlobalTransactional(timeoutMills = 300000, name = "dubbo-gts-seata-example") @Override public ObjectResponse handleBusiness(BusinessDTO businessDTO) { System.out.println("开始全局事务,XID = " + RootContext.getXID()); ObjectResponse<Object> objectResponse = new ObjectResponse<>(); //1、扣减库存 ObjectResponse response1 = storageService.decreaseStorage(commodityDTO); //2、创建订单 ObjectResponse<OrderDTO> response2 = orderService.createOrder(orderDTO); return objectResponse; } }
-
为什么只要添加一个 @GlobalTransactional注解 就可以了呢?请看RM原理
-
Phase 1 ( 1阶段)
- 当前本地事务是否处于全局事务中(也就判断
ConnectionContext
中的 xid 是否为空) - 如果不处于全局事务中,但是有全局事务锁(即方法标注了
@GlobalLock
注解),则在全局事务锁中提交本地事务。 - 以上情况都不是,则调用
targetConnection
对本地事务进行 commit。 - 如果处于全局事务中,首先创建分支事务,再将
ConnectionContext
中的 UndoLog 写入到undo_log
表中,然后调用targetConnection
对本地事务进行 commit,将 UndoLog 与业务 SQL 一起提交,最后上报分支事务的状态(成功 or 失败),并将ConnectionContext
上下文重置。
- 当前本地事务是否处于全局事务中(也就判断
-
Phase 2 (2阶段)
- 提交
- 回滚
-
-
-
在项目使用 seata-spring-boot-starter 依赖
-
添加 pom 依赖
<dependency> <groupId>io.seata</groupId> <artifactId>seata-spring-boot-starter</artifactId> <version>1.1.0</version> </dependency>
-
删除 file.conf和registry.conf 文件
-
删除 手动配置数据源代理 和 手动 初始化的 GlobalTransactionScanner 相关代码,这些都自动完成了
-
在 properties 文件中添加 seata 配置 , spring 相关配置在源码的script --> client -->spring 目录下
#================seata config======================= seata.enabled=true seata.application-id=dubbo-account-example seata.tx-service-group=my_test_tx_group seata.enable-auto-data-source-proxy=true seata.use-jdk-proxy=false # ----- begin: 不在源码的默认配置里面 , 属于 client和Server的公共配置--------- # 使用 nacos 注册类型,通过nacos 发现 seata-server seata.registry.type=nacos seata.registry.nacos.server-addr=112.74.179.118:8848 seata.registry.nacos.cluster=default seata.registry.nacos.namespace=9a6af877-1cd4-4450-9eff-7ba6252fca8e # 使用 nacos 配置中心 seata.config.type=nacos seata.config.nacos.server-addr=112.74.179.118:8848 seata.config.nacos.namespace=9a6af877-1cd4-4450-9eff-7ba6252fca8e seata.config.nacos.group=SEATA_GROUP # ----- end: 不在源码的默认配置里面 --------- seata.service.vgroup-mapping.my_test_tx_group=default seata.service.enable-degrade=false seata.service.disable-global-transaction=false # 只会在 register.type = file 时,才会被使用,其他类型不会读取 seata.service.grouplist.default=127.0.0.1:6091 seata.client.rm.async-commit-buffer-limit=1000 seata.client.rm.report-retry-count=5 seata.client.rm.table-meta-check-enable=false seata.client.rm.report-success-enable=false seata.client.rm.lock.retry-interval=10 seata.client.rm.lock.retry-times=30 seata.client.rm.lock.retry-policy-branch-rollback-on-conflict=true seata.client.tm.commit-retry-count=5 seata.client.tm.rollback-retry-count=5 seata.client.undo.data-validation=true seata.client.undo.log-serialization=jackson seata.client.undo.log-table=undo_log seata.client.log.exceptionRate=100 seata.transport.type=TCP seata.transport.server=NIO seata.transport.heartbeat=true seata.transport.serialization=seata seata.transport.compressor=none seata.transport.enable-client-batch-send-request=true seata.transport.shutdown.wait=3 seata.transport.thread-factory.boss-thread-prefix=NettyBoss seata.transport.thread-factory.worker-thread-prefix=NettyServerNIOWorker seata.transport.thread-factory.server-executor-thread-prefix=NettyServerBizHandler seata.transport.thread-factory.share-boss-worker=false seata.transport.thread-factory.client-selector-thread-prefix=NettyClientSelector seata.transport.thread-factory.client-selector-thread-size=1 seata.transport.thread-factory.client-worker-thread-prefix=NettyClientWorkerThread seata.transport.thread-factory.worker-thread-size=default seata.transport.thread-factory.boss-thread-size=1
-
注意: 如果要修改 my_test_tx_group ,需要修改两个地方,否则会报错:
no available service 'null' found, please make sure registry config correct
seata.tx-service-group=my_test_tx_group seata.service.vgroup-mapping.my_test_tx_group=default
-
上面关于 seata的配置,也可以放到 nacos 配置中心
-
在nacos 配置中新建一个 nacos-seata-config.properties 配置文件,将上面的那么多配置copy进来
-
启用 配置文件
@SpringBootApplication // 自动加载配置资源 @NacosPropertySource(dataId = "dubbo-demo-api", autoRefreshed = true) @NacosPropertySource(dataId = "nacos-seata-config.properties", autoRefreshed = true) public class NacosConfigApplication { public static void main(String[] args) { SpringApplication.run(NacosConfigApplication.class, args); } }
-
-
-
seata 参数配置介绍
-
关注的配置, 具体说明参见下面
server端 client端 registry.type registry.type config.type config.type store.mode service.vgroupMapping.my_test_tx_group store.db.driverClassName service.default.grouplist store.db.url service.disableGlobalTransaction store.db.user store.db.password -
公共部分
key desc remark transport.serialization client和server通信编解码方式 seata(ByteBuf)、protobuf、kryo、hession,默认seata transport.compressor client和server通信数据压缩方式 none、gzip,默认none transport.heartbeat client和server通信心跳检测开关 默认true开启 registry.type 注册中心类型 默认file,支持file 、nacos 、eureka、redis、zk、consul、etcd3、sofa、custom config.type 配置中心类型 默认file,支持file、nacos 、apollo、zk、consul、etcd3、custom -
Server 端
- store.mode : 事务会话信息存储方式, file本地文件(不支持HA),db数据库(支持HA)
- store.db.driverClassName: db模式数据库驱动
- store.db.url : db模式数据库url , 默认jdbc:mysql://127.0.0.1:3306/seata
-
Client 端
- service.vgroupMapping.my_test_tx_group : my_test_tx_group为分组,配置项值为TC集群名
- service.default.grouplist : 仅注册中心为file时使用
- service.disableGlobalTransaction : 全局事务开关,默认false。false为开启,true为关闭
-
事务分组说明
1.事务分组是什么?
事务分组是seata的资源逻辑,类似于服务实例。在file.conf中的my_test_tx_group就是一个事务分组。
2.通过事务分组如何找到后端集群?
首先程序中配置了事务分组(GlobalTransactionScanner 构造方法的txServiceGroup参数),程序会通过用户配置的配置中心去寻找service.vgroupMapping .事务分组配置项,取得配置项的值就是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服务。
4.seata-spring-boot-starter中的配置为什么是grouplist.default,也就是说和file.conf中的default.grouplist写法刚好颠倒了位置?
由于spring-boot本身配置文件语法的要求,这个地方需要将file.conf中的default.grouplist写成grouplist.default,效果是一样的.
-
-
相关测试和常见问题
-
测试时发现 undo_log 表中 有很多 log_status=1的记录,这是做什么用的?
- 场景 : 分支事务a注册TC后,a的本地事务提交前发生了全局事务回滚
- 说明: log_status=1的是防御性的,是收到全局回滚请求,但是不确定某个事务分支的本地事务是否已经执行完成了,这时事先插入一条branchid相同的数据,插入的假数据成功了,本地事务继续执行就会报主键冲突自动回滚。 假如插入不成功说明表里有数据这个本地事务已经执行完成了,那么取出这条undolog数据做反向回滚操作。
- 结果 : 全局事务回滚成功后,确保 a资源不会Commit成功,因此是正常现象。
-
并发测试 http://localhost:9000/business/buy 接口,可能会出现如下异常
curl -X POST --header 'Content-Type: application/json' --header 'Accept: application/json' -d '{ "amount": 20, "commodityCode": "C201901140001", "count": 2, "name": "水杯", "userId": "1" }' 'http://localhost:9000/business/buy'
// StorageService 报的异常 2020-02-25 13:27:48.036 ERROR 33029 --- [20883-thread-36] .d.r.f.ExceptionFilter$ExceptionListener : [DUBBO] Got unchecked and undeclared exception which called by 192.168.1.6. service: com.example.common.service.StorageService, method: decreaseStorage, exception: org.springframework.jdbc.UncategorizedSQLException: ### Error updating database. Cause: java.sql.SQLException: .... ### SQL: update t_storage set count = count-1 where commodity_code = ? ... Caused by: java.sql.SQLException: io.seata.core.exception.RmTransactionException: Response[ TransactionException[Could not found global transaction xid = 192.168.1.6:6091:2036330807] ] at io.seata.rm.datasource.ConnectionProxy.recognizeLockKeyConflictException(ConnectionProxy.java:153) ~[seata-all-1.1.0.jar:1.1.0] at io.seata.rm.datasource.ConnectionProxy.processGlobalTransactionCommit(ConnectionProxy.java:215) ~[seata-all-1.1.0.jar:1.1.0] at io.seata.rm.datasource.ConnectionProxy.doCommit(ConnectionProxy.java:192) ~[seata-all-1.1.0.jar:1.1.0] at io.seata.rm.datasource.ConnectionProxy$LockRetryPolicy.execute(ConnectionProxy.java:283) ~[seata-all-1.1.0.jar:1.1.0] ... at io.seata.rm.datasource.ConnectionProxy$LockRetryPolicy.doRetryOnLockConflict(ConnectionProxy.java:293) ~[seata-all-1.1.0.jar:1.1.0] ... at io.seata.rm.datasource.PreparedStatementProxy.execute(PreparedStatementProxy.java:54) ~[seata-all-1.1.0.jar:1.1.0] at org.apache.ibatis.executor.statement.PreparedStatementHandler.update(PreparedStatementHandler.java:47) ~[mybatis-3.5.3.jar:3.5.3] ... org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:426) ~[mybatis-spring-2.0.3.jar:2.0.3] ... 52 common frames omitted
// seata server 报的异常 2020-02-25 13:27:46.229 ERROR[ServerHandlerThread_1_500]io.seata.core.exception.AbstractExceptionHandler.exceptionHandleTemplate:120 -Catch TransactionException while do RPC, request: xid=192.168.1.6:6091:2036330807,branchType=AT,resourceId=jdbc:mysql://127.0.0.1:3306/test,lockKey=t_storage:1 io.seata.core.exception.BranchTransactionException: Global lock acquire failed xid = 192.168.1.6:6091:2036330807 branchId = 2036330925 at io.seata.server.transaction.at.ATCore.branchSessionLock(ATCore.java:48) at io.seata.server.coordinator.AbstractCore.lambda$branchRegister$0(AbstractCore.java:77) at io.seata.server.session.GlobalSession.lockAndExecute(GlobalSession.java:616) at io.seata.server.coordinator.AbstractCore.branchRegister(AbstractCore.java:72) at io.seata.server.coordinator.DefaultCore.branchRegister(DefaultCore.java:95) ... 2020-02-25 13:27:46.864 INFO [ServerHandlerThread_1_500]io.seata.server.coordinator.AbstractCore.lambda$branchRegister$0:86 -Successfully register branch xid = 192.168.1.*:6091:2036330816, branchId = 2036330927 2020-02-25 13:27:47.014 INFO [ServerHandlerThread_1_500]io.seata.server.coordinator.DefaultCore.doGlobalRollback:334 -Successfully rollback global, xid = 192.168.1.*:6091:2036330807 2020-02-25 13:27:47.068 ERROR[ServerHandlerThread_1_500]io.seata.core.exception.AbstractExceptionHandler.exceptionHandleTemplate:120 -Catch TransactionException while do RPC, request: xid=192.168.1.*:6091:2036330807,branchType=AT,resourceId=jdbc:mysql://127.0.0.1:3306/test,lockKey=t_storage:1 io.seata.core.exception.GlobalTransactionException: Could not found global transaction xid = 192.168.1.*:6091:2036330807 at ...
-
测试 http://localhost:9000/business/buy 和 http://localhost:8001/account/test_global_lock 接口
curl -X GET http://localhost:8001/account/test_global_lock
全局事务,XID = 192.168.1.*:6091:2036330432 2020-02-25 12:58:29.798 INFO 34142 --- [nio-8001-exec-7] c.e.a.controller.AccountController : testGlobalLock Hi, i got lock, i will do some thing with holding this lock. 2020-02-25 12:58:29.883 ERROR 34142 --- [nio-8001-exec-7] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.transaction.TransactionSystemException: Could not commit JDBC transaction; nested exception is io.seata.rm.datasource.exec.LockConflictException] with root cause io.seata.rm.datasource.exec.LockConflictException: null at io.seata.rm.datasource.ConnectionProxy.checkLock(ConnectionProxy.java:116) ~[seata-all-1.1.0.jar:1.1.0] ... at io.seata.rm.datasource.ConnectionProxy$LockRetryPolicy.execute(ConnectionProxy.java:283) ~[seata-all-1.1.0.jar:1.1.0] at io.seata.rm.datasource.ConnectionProxy.commit(ConnectionProxy.java:179) ~[seata-all-1.1.0.jar:1.1.0] ... at io.seata.spring.annotation.GlobalTransactionalInterceptor.handleGlobalLock(GlobalTransactionalInterceptor.java:92) ~[seata-all-1.1.0.jar:1.1.0] ... at com.sun.proxy.$Proxy85.testGlobalLock(Unknown Source) ~[na:na] at com.example.account.controller.AccountController.testGlobalLock(AccountController.java:23) ~[classes/:na] at 2020-02-25 12:58:30.898 INFO 34142 --- [atch_RMROLE_1_8] i.s.core.rpc.netty.RmMessageListener : onMessage:xid=192.168.1.6:6091:2036330432,branchId=2036330436,branchType=AT,resourceId=jdbc:mysql://127.0.0.1:3306/test,applicationData=null 2020-02-25 12:58:30.916 INFO 34142 --- [atch_RMROLE_1_8] io.seata.rm.AbstractRMHandler : Branch committing: 192.168.1.6:6091:2036330432 2036330436 jdbc:mysql://127.0.0.1:3306/test null 2020-02-25 12:58:30.917 INFO 34142 --- [atch_RMROLE_1_8] io.seata.rm.AbstractRMHandler : Branch commit result: PhaseTwo_Committed
-
-
每次执行insert 、 update 数据库时,RM 都会抛出异常
Unable to commit against JDBC Connection
-
网上搜索到类似的问题,说是:“一开始都很正常,可在一次事务更新的条数增多后,开始出现异常”
- 虽然现象和我们的不大一样,但也得到了启发,查看 seata-server 端,是否也抛出异常
-
查看seata-server日志,发现真正原因是:Caused by: io.seata.common.exception.StoreException: Data truncation: Data too long for column ‘row_key’ at row 1
- 意思是:数据太长了,超出 row_key 列的范围
-
解决方法:将 seata 库的 lock_table 表的主键 row_key 字段的长度从128改成250后,问题就解决了
-
搜索这个问题时,发现作者已经修复这个bug : bugfix: AT mode resourceId(row_key) too long ,但是为什么还抛出异常呢?
-
原因分析:
-
根据修复bug的线索,得知 row_key 字段保存的数据有一部分是 jdbcUrl
-
然后继续查找 seata-server 的源码发现 row_key 字段保存的数据为
// rowkey 由 jdbcUrl(数据库连接字符串,resourceId) + 表名 + 主键 3部分组成 lockDO.setRowKey(getRowKey(rowLock.getResourceId(), rowLock.getTableName(), rowLock.getPk())); // 中间用 "^^^" 分割 protected String getRowKey(String resourceId, String tableName, String pk) { return new StringBuilder().append(resourceId).append(LOCK_SPLIT).append(tableName).append(LOCK_SPLIT).append(pk) .toString(); } LOCK_SPLIT = "^^^";
-
由于项目使用的是阿里云数据库, jdbcUrl 会比较长(70个字符),加上表的主键使用的是UUID也比较长(38个字符),这样留给 表名的长度就很少了(128-70-38=14),也就是说表名不能超过14个字符,如果超过,就会抛出异常:Data too long for column ‘row_key’ at row 1
-
-