1 前言
现在应用基本上都是分布式部署,那么针对分布式事务问题,也有对应的解决方案。经过简单的调研,最后选择了阿里的 Seata
组件,来实现分布式事务。
Seata是2019年1月份,蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务,为用户打造一站式的分布式解决方案。
官网地址: http://seata.io/
文档: https://seata.io/zh-cn/docs/overview/what-is-seata.html
2 Seata 模式选择
Seata给我们提供了四种不同的分布式事务解决方案:
-
XA模式
强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入 -
TCC模式
最终一致的分阶段事务模式,有业务侵入 -
AT模式
最终一致的分阶段事务模式,无业务侵入,也是Seata的默认模式 -
SAGA模式
长事务模式,有业务侵入
综合业务量,可用性,以及集成的成本,最终选择了无业务入侵的 AT
模式。
基于现有的Spring Boot版本,Spring Cloud版本,Spring Cloud Alibaba版本以及Mybatis Plus动态数据源版本,最后选择seata的1.5.2
版本。
Spring Version | 5.2.15.RELEASE |
---|---|
Spring Boot Version | 2.3.12.RELEASE |
Spring Cloud Version | Hoxton.SR12 |
Spring Cloud Alibaba Version | 2.2.9.RELEASE |
3 Seata Server端搭建
Seata分TC、TM和RM三个角色,TC(Server端)
为单独服务端部署,TM
和RM(Client端)
由业务系统集成。
笔者采用file
作为服务的注册中心,数据存储采用的db
,数据源采用的hikari
3.1 下载Server源码包
seata-server-1.5.2.zip
可以使用本地部署,也可以使用 Docker部署(忽略该步骤)
3.2 建表
全局事务会话信息由3块内容构成,全局事务–>分支事务–>全局锁,对应表global_table
、branch_table
、lock_table
创建seata
数据库,注意:表结构字符集,需调整为 utf8mb4
-- -------------------------------- The script used when storeMode is 'db' --------------------------------
-- the table to store GlobalSession data
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_status_gmt_modified` (`status` , `gmt_modified`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
-- the table to store BranchSession data
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;
-- the table to store lock data
CREATE TABLE IF NOT EXISTS `lock_table`
(
`row_key` VARCHAR(128) NOT NULL,
`xid` VARCHAR(128),
`transaction_id` BIGINT,
`branch_id` BIGINT NOT NULL,
`resource_id` VARCHAR(256),
`table_name` VARCHAR(32),
`pk` VARCHAR(36),
`status` TINYINT NOT NULL DEFAULT '0' COMMENT '0:locked ,1:rollbacking',
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`row_key`),
KEY `idx_status` (`status`),
KEY `idx_branch_id` (`branch_id`),
KEY `idx_xid_and_branch_id` (`xid` , `branch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
CREATE TABLE IF NOT EXISTS `distributed_lock`
(
`lock_key` CHAR(20) NOT NULL,
`lock_value` VARCHAR(20) NOT NULL,
`expire` BIGINT,
primary key (`lock_key`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('AsyncCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryRollbacking', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('TxTimeoutCheck', ' ', 0);
3.3 Server配置
完整的配置实例如下:
server:
port: 6689
spring:
application:
name: seata-server
logging:
config: classpath:logback-spring.xml
file:
path: ${user.home}/logs/seata
# extend:
# logstash-appender:
# destination: 127.0.0.1:4560
# kafka-appender:
# bootstrap-servers: 127.0.0.1:9092
# topic: logback_to_logstash
console:
user:
username: seata
password: seata
seata:
config:
# support: nacos 、 consul 、 apollo 、 zk 、 etcd3
type: file
registry:
# support: nacos 、 eureka 、 redis 、 zk 、 consul 、 etcd3 、 sofa
type: file
# file field
preferred-networks: 30.240.*
server:
#If not configured, the default is '${server.port} + 1000'
#service-port: 8091
max-commit-retry-timeout: -1
max-rollback-retry-timeout: -1
rollback-retry-timeout-unlock-enable: false
enable-check-auth: true
enable-parallel-request-handle: true
retry-dead-threshold: 130000
xaer-nota-retry-timeout: 60000
recovery:
handle-all-session-period: 1000
undo:
log-save-days: 7
log-delete-period: 86400000
session:
#branch async remove queue size
branch-async-queue-size: 5000
#enable to asynchronous remove branchSession
enable-branch-async-remove: false
store:
# support: file 、 db 、 redis
mode: db
session:
mode: db
lock:
mode: db
#io.netty.handler.codec.TooLongFrameException: Adjusted frame length exceeds 8388608: 1345270062 - discarded
file:
dir: sessionStore
max-branch-session-size: 16384
max-global-session-size: 512
file-write-buffer-cache-size: 16384
session-reload-read-size: 100
flush-disk-mode: async
db:
branchTable: branch_table
# hikari or druid
datasource: hikari
dbType: mysql
distributedLockTable: distributed_lock
globalTable: global_table
lockTable: lock_table
maxConn: 30
maxWait: 5000
minConn: 5
queryLimit: 100
driverClassName: com.mysql.cj.jdbc.Driver
url: xxx/seata?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&allowMultiQueries=true
user: xxx
password: xxx
metrics:
enabled: false
registry-type: compact
exporter-list: prometheus
exporter-prometheus-port: 9898
transport:
rpc-tc-request-timeout: 30000
enable-tc-server-batch-send-response: false
shutdown:
wait: 3
thread-factory:
boss-thread-prefix: NettyBoss
worker-thread-prefix: NettyServerNIOWorker
boss-thread-size: 1
security:
secretKey: SeataSecretKey0c382ef121d778043159209298fd40bf5335jlkjlj53454j4jfdjfggj
tokenValidityInMilliseconds: 1800000
ignore:
urls: /,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.ico,/console-fe/public/**,/api/v1/auth/login
3.4 启动Seata Server
seata-server.sh 脚本启动即可,启动成功如下:
4 Seata Client 集成
在需要使用分布式事务的服务中,都需要如下操作,注意:只有主入口需要@GlobalTransactional
注解
4.1 添加undo_log回滚记录表
所有的客户端,都需要添加该表,另外,回滚操作成功后,该表会被清空,所以每次看都是空的
CREATE TABLE `undo_log`
(
`branch_id` bigint(20) NOT NULL COMMENT 'branch transaction id',
`xid` varchar(128) 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(6) NOT NULL COMMENT 'create datetime',
`log_modified` datetime(6) NOT NULL COMMENT 'modify datetime',
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='AT transaction mode undo table';
4.2 添加依赖
<!--seata client config-->
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.5.2</version>
<exclusions>
<exclusion>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<version>2.2.9.RELEASE</version>
<exclusions>
<exclusion>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot-starter</artifactId>
<version>3.4.1</version>
</dependency>
4.3 Seata Client config
关于事务分组,详细请参考:https://seata.io/zh-cn/docs/user/txgroup/transaction-group.html
server:
port: 6687
spring:
datasource:
dynamic:
# 设置默认的数据源或者数据源组,默认值即为master
primary: master
# 严格匹配数据源,默认false. true未匹配到指定数据源时抛异常,false使用默认数据源
strict: false
# 默认false非懒启动,系统加载到数据源立即初始化连接池
lazy: false
#开启seata代理,开启后默认每个数据源都代理,如果某个数据源不需要代理可单独关闭
seata: true
#支持XA及AT模式,默认AT
seata-mode: AT
# 全局hikariCP参数,所有值和默认保持一致(现已支持的参数如下)
hikari:
catalog:
# 数据库连接超时时间,默认60秒,即 60000
connection-timeout: 60000
validation-timeout:
#空闲连接存活最大时间,默认 600000(10分钟)
idle-timeout: 600000
leak-detection-threshold:
max-lifetime:
#连接池最大连接数,默认是10
max-pool-size: 10
#最小空闲连接数量
min-idle: 10
initialization-fail-timeout:
connection-init-sql:
connection-test-query:
dataSource-class-name:
dataSource-jndi-name:
schema:
transaction-isolation-name:
# 此属性控制从池返回的连接的默认自动提交行为,默认值:true
is-auto-commit: true
is-read-only: false
is-isolate-internal-queries:
is-register-mbeans:
is-allow-pool-suspension:
data-source-properties:
health-check-properties:
datasource:
master:
seata: false
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: xxx?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&allowMultiQueries=true
username: xxx
password: xxx
order:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: xxx/seata_order?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&allowMultiQueries=true
username: xxx
password: xxx
account:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: xxx/seata_account?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&allowMultiQueries=true
username: xxx
password: xxx
product:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: xxx/seata_product?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&allowMultiQueries=true
username: xxx
password: xxx
seata:
enabled: true
application-id: ${spring.application.name}
tx-service-group: smile_tx_group
#一定要是false,组件内部开启seata后会自动使用DataSourceProxy来包装DataSource
enable-auto-data-source-proxy: false
service:
vgroup-mapping:
# Transaction routing rules configuration, only for the client,The key corresponds to the value of tx service group above
# Specify the transaction grouping to cluster mapping relationship (the cluster name on the right side of the equal sign needs to be consistent with the cluster registered by Seata Server)
smile_tx_group: default
grouplist:
#seata-server地址仅file注册中心需要(这里要与server监听的端口一致)
default: 127.0.0.1:7689
config:
type: file
registry:
type: file
client:
rm:
# 1.5.2版本仅支持druid和antlr,这里虽然使用了hikari数据源,解析sql使用的druid,不冲突也不影响结果,详情可参考源码:io.seata.sqlparser.SqlParserType
sqlParserType: druid
5 代码实践
这里采用常见的示例举例
5.1 正常下单
服务A代码示例:
/**
* 正常下单
*/
@PostMapping("/placeOrder")
public String placeOrder(@Validated @RequestBody PlaceOrderRequest request) {
orderService.placeOrder(request);
return "下单成功";
}
/**
* 下单
*
* @param placeOrderRequest 订单请求参数
*/
void placeOrder(PlaceOrderRequest placeOrderRequest);
@DS("order")
@Override
@Transactional
@GlobalTransactional
@SuppressWarnings("all")
public void placeOrder(PlaceOrderRequest request) {
log.info("当前的XID为: {}", RootContext.getXID());
//业务执行省略。。。
}
5.2 下单业务异常,事务回滚
服务A代码示例:
/**
* 下单-添加操作日志失败回滚
*/
@PostMapping("/placeOrderFail")
public String placeOrderFail(@Validated @RequestBody PlaceOrderRequest request) {
orderService.placeOrderFail(request);
return "下单成功,操作日志记录失败回滚";
}
/**
* <p>
* place order fail
* <p/>
*
* @param placeOrderRequest
* @return void
* @Date 2023/4/15 14:22
*/
void placeOrderFail(PlaceOrderRequest placeOrderRequest);
/**
* <p>
* place order fail
* <p/>
*
* @param placeOrderRequest
* @return void
* @Date 2023/4/15 14:22
*/
@DS("order")
@Override
@Transactional
@GlobalTransactional
@SuppressWarnings("all")
public void placeOrderFail(PlaceOrderRequest request) {
log.info("placeOrderFail xid: {}", RootContext.getXID());
//下单、减库存、处理账户余额
this.placeOrder(request);
ApiLogger apiLogger = new ApiLogger();
apiLogger.setUserId(request.getUserId());
apiLogger.setBizId(String.valueOf(request.getUserId() + new Random().nextInt(100)));
//场景一:另一个分布式服务,执行业务异常(下游分布式服务报错,导致上游所有服务回滚)
feignClient.insertApiLoggerInfo(apiLogger);
//场景二:当前业务执行异常,回滚本事务,同时回滚另一个feignClient分布式事务(上游报错,导致下游事务回滚)
int i = 1 / 0;
}
另一个分布式服务B代码示例:
@Transactional(propagation = Propagation.REQUIRES_NEW)
@PostMapping("/insert")
public ObjectRestResponse<String> insertApiLoggerInfo(@RequestBody ApiLogger apiLogger) {
log.info("insertApiLoggerInfo xid: {}", RootContext.getXID());
apiLoggerService.save(apiLogger);
//int i = 1 / 0;
return ObjectRestResponse.success("insert api logger info success");
}
5.3 启动Seata Client
启动分布式服务即可,出现以下内容,说明客户端启动成功
回滚成功时,可以看到以下内容:
6 踩坑总结
分布式事务组件,其实国内稳定的版本还不是很多,能业务自己实现最终一致性最好,否则才考虑使用Seata组件。笔者踩了好多坑,这里记录下最大的坑
-
报错1:
### SQL: INSERT INTO p_order ( user_id, product_id, status, amount ) VALUES ( ?, ?, ?, ? )### Cause: java.sql.SQLException: io.seata.common.loader.EnhancedServiceNotFoundException: not found service provider for : io.seata.rm.datasource.sql.struct.TableMetaCache ; uncategorized SQLException; SQL state [null]; error code [0]; io.seata.common.loader.EnhancedServiceNotFoundException: not found service provider for : io.seata.rm.datasource.sql.struct.TableMetaCache; nested exception is java.sql.SQLException: io.seata.common.loader.EnhancedServiceNotFoundException: not found service provider for : io.seata.rm.datasource.sql.struct.TableMetaCache
io.seata.rm.datasource.sql.struct.TableMetaCache这个错误,太容易出现了,看了好多的issue,最终都没有解决,最后只能看源码
这里list集合判空,不是很严谨,本来应该加载资源,最后没加载,导致报错。当然,这个是访问时报的错,其实,在启动类的时候也会加载一次,所以整体上不会出问题的,出问题了,说明大概是jar版本依赖不兼容导致的。
-
报错2
io.seata.rm.datasource.sql.struct.cache.MysqlTableMetaCache java.lang.NoClassDefFoundError: io.seata.rm.datasource.sql.struct.cache.AbstractTableMetaCache ServiceLoader$InnerEnhancedServiceLoader : Load [io.seata.rm.datasource.sql.struct.cache.MysqlTableMetaCache] class fail. com/github/benmanes/caffeine/cache/Caffeine has been compiled by a more recent version of the Java Runtime (class file version 55.0), this version of the Java Runtime only recognizes class file versions up to 52.0
当出现
java.lang.NoClassDefFoundError
时,应该第一想到是不是跟其他组件版本不兼容,笔者的是跟一个缓存的组件caffeine
产生了问题,根据github官网的描述,对于jdk11 以上的jdk版本请使用3.1.x,否则使用2.9.x,For Java 11 or above, use 3.1.x otherwise use 2.9.x
,将基础组件的版本,从3.0.0降到了2.9.3,该问题得到了解决
基础入门实践,就先写到这里,后面再简单分享下原理,以及把注册的方式,由file升级为nacos形式,敬请期待~
写博客是为了记住自己容易忘记的东西,另外也是对自己工作的总结,希望尽自己的努力,做到更好,大家一起努力进步!
如果有什么问题,欢迎大家一起探讨,代码如有问题,欢迎各位大神指正!
给自己的梦想添加一双翅膀,让它可以在天空中自由自在的飞翔!