简介
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。
官网地址:
https://seata.apache.org/
https://github.com/apache/incubator-seata/
{
"branchId": 641789253,
"undoItems": [{
"afterImage": {
"rows": [{
"fields": [{
"name": "id",
"type": 4,
"value": 1
}, {
"name": "name",
"type": 12,
"value": "GTS"
}, {
"name": "since",
"type": 12,
"value": "2014"
}]
}],
"tableName": "product"
},
"beforeImage": {
"rows": [{
"fields": [{
"name": "id",
"type": 4,
"value": 1
}, {
"name": "name",
"type": 12,
"value": "TXC"
}, {
"name": "since",
"type": 12,
"value": "2014"
}]
}],
"tableName": "product"
},
"sqlType": "UPDATE"
}],
"xid": "xid:xxx"
}
单实例多数据源
为简化代码示例使用2个库实现跨库转账逻辑,使用多数据源实现:
create database db1;
use db1;
create table t_user(
id int primary key auto_increment,
name char(32) not null,
amount int not null default 0
);
insert into t_user(name,amount) values('jack',1000);
create database db2;
use db2;
create table t_user(
id int primary key auto_increment,
name char(32) not null,
amount int not null default 0
);
insert into t_user(name,amount) values('rose',2000);
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
server:
port: 9010
spring:
application:
name: svc-test-seata
datasource:
dynamic:
primary: db1
datasource:
db1:
url: jdbc:mysql://xxx:3306/db1?useSSL=false&characterEncoding=UTF-8
username: xxx
password: xxx
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.jdbc.Driver
hikari:
maximum-pool-size: 50
minimum-idle: 20
db2:
url: jdbc:mysql://xxx:3306/db2?useSSL=false&characterEncoding=UTF-8
username: xxx
password: xxx
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.jdbc.Driver
hikari:
maximum-pool-size: 50
minimum-idle: 20
mybatis-plus:
configuration:
cache-enabled: false
local-cache-scope: statement
@Data
@TableName("t_user")
public class User {
@TableId
private Long id;
private String name;
private Integer amount;
}
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
public interface DB1User extends IService<User> {
boolean updateById(User user);
}
@Service
@DS("db1")
public class DB1UserImpl extends ServiceImpl<UserMapper, User> implements DB1User {
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateById(User user) {
return this.baseMapper.updateById(user) > 0;
}
}
public interface DB2User extends IService<User> {
boolean updateById(User user);
}
@Service
@DS("db2")
public class DB2UserImpl extends ServiceImpl<UserMapper, User> implements DB2User {
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateById(User user) {
return this.baseMapper.updateById(user) > 0;
}
}
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private DB1User db1User;
@Autowired
private DB2User db2User;
@GetMapping
public Object queryAll() throws Exception {
List<User> users = new ArrayList<>();
users.addAll(db1User.list());
users.addAll(db2User.list());
Result result = Result.ok(users);
return result;
}
@GetMapping("/transfer")
public Object transfer() throws Exception {
User user1 = db1User.getById(1);
User user2 = db2User.getById(1);
user1.setAmount(user1.getAmount() - 100);
user2.setAmount(user2.getAmount() + 100);
db1User.updateById(user1);
int a = 10 / 0;
db2User.updateById(user2);
return Result.ok(queryAll());
}
}
访问 http://localhost:9010/user/transfer 实现跨库转账将发现数据不一致的情况。
Seata单节点
db1、db2 两个库中都创建Seata所需的undo_log表:
CREATE TABLE IF NOT EXISTS `undo_log`(
`branch_id` BIGINT 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`)
);
ALTER TABLE `undo_log` ADD INDEX `ix_log_created` (`log_created`);
Usage: sh seata-server.sh(for linux and mac) or cmd seata-server.bat(for windows) [options]
Options:
--host, -h
The address is expose to registration center and other service can access seata-server via this ip.
Default: 0.0.0.0
--port, -p
The port to listen.
Default: 8091
--storeMode, -m
log store mode : file、db
Default: file
--help
e.g.
# file模式是为了快速搭建验证demo
sh seata-server.sh -p 8091 -h 127.0.0.1 -m file
pom.xml 添加seata依赖(seata-spring-boot-starter 依赖非常老的seata 库会导致启动失败):
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.3.0</version>
<exclusions>
<exclusion>
<groupId>io.seata</groupId>
<artifactId>seata-all</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-all</artifactId>
<version>1.3.0</version>
</dependency>
application.yaml
server:
port: 9010
spring:
application:
name: svc-test-seata
datasource:
dynamic:
primary: db1
datasource:
db1:
url: jdbc:mysql://xxx:3306/db1?useSSL=false&characterEncoding=UTF-8
username: xxx
password: xxx
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.jdbc.Driver
hikari:
maximum-pool-size: 50
minimum-idle: 20
db2:
url: jdbc:mysql://xxx:3306/db2?useSSL=false&characterEncoding=UTF-8
username: xxx
password: xxx
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.jdbc.Driver
hikari:
maximum-pool-size: 50
minimum-idle: 20
seata: true
seata-mode: at # 事务模式为AT模式
mybatis-plus:
configuration:
cache-enabled: false
local-cache-scope: statement
logging:
level:
root: info
com.test.seata.mapper: trace
seata:
enable-auto-data-source-proxy: false
application-id: ${spring.application.name}
tx-service-group: my-test-tx-group # 配置事务组
service:
vgroup-mapping:
my-test-tx-group: default # 配置事务组关联的TC集群名
grouplist:
default: xxx:8091 # Seata 服务器地址信
config:
type: file
registry:
type: file
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private DB1User db1User;
@Autowired
private DB2User db2User;
@GetMapping
public Object queryAll() throws Exception {
List<User> users = new ArrayList<>();
users.addAll(db1User.list());
users.addAll(db2User.list());
Result result = Result.ok(users);
return result;
}
@GetMapping("/transfer")
@GlobalTransactional
public Object transfer() throws Exception {
User user1 = db1User.getById(1);
User user2 = db2User.getById(1);
user1.setAmount(user1.getAmount() - 100);
user2.setAmount(user2.getAmount() + 100);
db1User.updateById(user1);
int a = 10 / 0;
db2User.updateById(user2);
return Result.ok(queryAll());
}
}
- 断点验证AT模式:
db1库中的数据被减少100,undo_log 表中记录了数据:
放行断点后数据达到一致性:
- 事务回滚时会读取数据库最新数据同undo_log表中数据对比,不相等则回滚失败(需要人工介入):
- XA事务模式:
修改事务模式为XA模式(需要使用支持XA的驱动):
server:
port: 9010
spring:
application:
name: svc-test-seata
datasource:
dynamic:
primary: db1
datasource:
db1:
url: jdbc:mysql://xxx:3306/db1?useSSL=false&characterEncoding=UTF-8
username: xxx
password: xxx
type: com.alibaba.druid.pool.xa.DruidXADataSource # 使用XA数据源
driver-class-name: com.mysql.jdbc.Driver
hikari:
maximum-pool-size: 50
minimum-idle: 20
db2:
url: jdbc:mysql://xxx:3306/db2?useSSL=false&characterEncoding=UTF-8
username: xxx
password: xxx
type: com.alibaba.druid.pool.xa.DruidXADataSource # 使用XA的数据源
driver-class-name: com.mysql.jdbc.Driver
hikari:
maximum-pool-size: 50
minimum-idle: 20
seata: true
seata-mode: xa # 修改为XA模式
mybatis-plus:
configuration:
cache-enabled: false
local-cache-scope: statement
logging:
level:
root: info
com.test.seata.mapper: trace
seata:
enable-auto-data-source-proxy: false
application-id: ${spring.application.name}
tx-service-group: my-test-tx-group
service:
vgroup-mapping:
my-test-tx-group: default
grouplist:
default: xxx:8091
config:
type: file
registry:
type: file
t_user表加锁阻塞(XA事务中该记录已经处于锁定状态):
异常后事务回滚,数据处于一致性:
事务组 & TC高可用
- 修改Seata conf/registry.conf 注册中心、配置中心,参照说明如下:
registry {
# file, nacos, eureka, redis, zk, consul, etcd3, sofa
type = "nacos" ---------------> Use Nacos as the registry center
nacos {
application = "seata-server" ---------------> Specify the service name registered in Nacos registry center
group = "SEATA_GROUP" ---------------> Specify the group name registered in Nacos registry center
serverAddr = "localhost" ---------------> Nacos registry center IP:port
namespace = "" ---------------> Nacos namespace ID, "" represents the reserved public namespace in Nacos, users should not configure namespace = "public"
cluster = "default" ---------------> Specify the cluster name registered in Nacos registry center
}
}
config {
# file, nacos, apollo, zk, consul, etcd3
type = "nacos" ------------> Use Nacos as the configuration center
nacos {
serverAddr = "localhost" ---------------> Nacos registry center IP:port
namespace = ""
group = "SEATA_GROUP" ---------------> Nacos configuration center group name
}
}
- 上传配置信息到 Nacos:
参考说明 https://github.com/apache/incubator-seata/tree/2.x/script/config-center,有详细的操作说明:
上传成功后查看nacos 配置信息如下:
修改配置中心数据库连接配置:
config.txt 文件配置的事务组是default_tx_group,如果application.yaml 配置文件中的事务组不是default_tx_group则会导致失败:
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`)
);
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`)
);
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` (`xid`)
);
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`)
);
- 启动seata后可以看到数据库连接成功:
多个seata服务成功注册到nacos注册中心:
pom.xml 配置:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>alibaba-common</artifactId>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.3.0</version>
<exclusions>
<exclusion>
<groupId>io.seata</groupId>
<artifactId>seata-all</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-all</artifactId>
<version>1.3.0</version>
</dependency>
<!-- 增加seata客户端为了读取nacos中配置信息,否则会启动失败 -->
<dependency>
<groupId>com.alibaba.nacos</groupId>
<artifactId>nacos-client</artifactId>
<version>1.3.0</version>
</dependency>
</dependencies>
application.yaml:
server:
port: 9010
spring:
application:
name: svc-test-seata
datasource:
dynamic:
primary: db1
datasource:
db1:
url: jdbc:mysql://xxx:3306/db1?useSSL=false&characterEncoding=UTF-8
username: xxx
password: xxx
type: com.alibaba.druid.pool.xa.DruidXADataSource
driver-class-name: com.mysql.jdbc.Driver
hikari:
maximum-pool-size: 50
minimum-idle: 20
db2:
url: jdbc:mysql://xxx:3306/db2?useSSL=false&characterEncoding=UTF-8
username: xxx
password: xxx
type: com.alibaba.druid.pool.xa.DruidXADataSource
driver-class-name: com.mysql.jdbc.Driver
hikari:
maximum-pool-size: 50
minimum-idle: 20
seata: true
seata-mode: xa
mybatis-plus:
configuration:
cache-enabled: false
local-cache-scope: statement
logging:
level:
root: info
com.test.seata.mapper: trace
seata:
enable-auto-data-source-proxy: false
application-id: ${spring.application.name}
tx-service-group: default_tx_group # 同配置中心 service.vgroupMapping.default_tx_group 保持一致,否则会有错误信息
service:
vgroup-mapping:
default_tx_group: default
config:
type: nacos
nacos:
group: SEATA_GROUP
server-addr: xxx:8848
registry:
type: nacos
nacos:
application: seata-server
server-addr: xxx:8848
group: SEATA_GROUP
application.yaml 配置项 seata.service.vgroup-mapping 与配置中心 service.vgroupMapping.default_tx_group 不匹配错误信息如下:
- 数据一致性验证:
进入业务断点后更新数据库数据发现被阻塞,事务回滚后数据处于一致性:
- TC高可用验证
停止业务日志回滚节点TC:
再次访问 http://localhost:9010/user/transfer 发现由另一TC节点处理:
微服务之间XID传递
为方便简化说明使用RestRemplate调用服务实现转账操作。
pom.xml:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>alibaba-common</artifactId>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.3.0</version>
<exclusions>
<exclusion>
<groupId>io.seata</groupId>
<artifactId>seata-all</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-all</artifactId>
<version>1.3.0</version>
</dependency>
<!-- 增加seata客户端为了读取nacos中配置信息,否则会启动失败 -->
<dependency>
<groupId>com.alibaba.nacos</groupId>
<artifactId>nacos-client</artifactId>
<version>1.3.0</version>
</dependency>
<!-- 增加cloud alibaba seata 以传递 xid 到其它服务节点 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
</dependencies>
application.yaml
server:
port: 9010
spring:
application:
name: svc-test-seata
datasource:
dynamic:
primary: db1
datasource:
db1:
url: jdbc:mysql://xxx:3306/db1?useSSL=false&characterEncoding=UTF-8
username: xxx
password: xxx
type: com.alibaba.druid.pool.xa.DruidXADataSource
driver-class-name: com.mysql.jdbc.Driver
hikari:
maximum-pool-size: 50
minimum-idle: 20
db2:
url: jdbc:mysql://xxx:3306/db2?useSSL=false&characterEncoding=UTF-8
username: xxx
password: xxx
type: com.alibaba.druid.pool.xa.DruidXADataSource
driver-class-name: com.mysql.jdbc.Driver
hikari:
maximum-pool-size: 50
minimum-idle: 20
seata: true
seata-mode: xa
mybatis-plus:
configuration:
cache-enabled: false
local-cache-scope: statement
logging:
level:
root: info
com.test.seata.mapper: trace
seata:
enable-auto-data-source-proxy: false
application-id: ${spring.application.name}
tx-service-group: default_tx_group
service:
vgroup-mapping:
default_tx_group: default
config:
type: nacos
nacos:
group: SEATA_GROUP
server-addr: xxx:8848
registry:
type: nacos
nacos:
application: seata-server
server-addr: xxx:8848
group: SEATA_GROUP
@SpringBootApplication(exclude = GlobalTransactionAutoConfiguration.class) 启动类排除GlobalTransactionAutoConfiguration 自动配置:
package com.test.seata;
import com.alibaba.cloud.seata.GlobalTransactionAutoConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;
@SpringBootApplication(exclude = GlobalTransactionAutoConfiguration.class)
public class TestSeataApp {
public static void main(String[] args) {
SpringApplication.run(TestSeataApp.class,args);
}
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
参考文档如下(如果不排除则会导致启动异常):
UserController.java 模拟实现业务逻辑:
package com.test.seata.controller;
import com.alibaba.fastjson.JSON;
import com.example.alibaba.common.dto.Result;
import com.test.seata.domain.pojo.User;
import com.test.seata.service.DB1User;
import com.test.seata.service.DB2User;
import io.seata.spring.annotation.GlobalTransactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private DB1User db1User;
@Autowired
private DB2User db2User;
@Autowired
private RestTemplate restTemplate;
@GetMapping
public Object queryAll() throws Exception {
List<User> users = new ArrayList<>();
users.addAll(db1User.list());
users.addAll(db2User.list());
Result result = Result.ok(users);
return result;
}
@GetMapping("/transfer")
@GlobalTransactional
public Object transfer() throws Exception {
// User user1 = db1User.getById(1);
// User user2 = db2User.getById(1);
//
// user1.setAmount(user1.getAmount() - 100);
// user2.setAmount(user2.getAmount() + 100);
//
// db1User.updateById(user1);
// int a = 10 / 0;
// db2User.updateById(user2);
// 先给db2增加金额,再减去db1中的金额
Result result = restTemplate.getForObject("http://localhost:9011/user/add-amount?dbName=db2&amount=100",Result.class);
User user2 = null;
if (result != null && result.getStatus() == 200) {
user2 = JSON.parseObject(JSON.toJSONString(result.getData()),User.class);
}
int a = 10 / 0;
this.transfer("db1",-100);
return Result.ok(queryAll());
}
@GetMapping("/add-amount")
public Object transfer(@RequestParam String dbName,@RequestParam Integer amount) throws Exception {
if (!Arrays.asList("db1","db2").contains(dbName)) {
return Result.error(null,String.format("非法操作数据库 %s",dbName));
}
User user = null;
if (dbName.equals("db1")) {
user = db1User.getById(1);
user.setAmount(user.getAmount() + amount);
db1User.updateById(user);
} else if (dbName.equals("db2")) {
user = db2User.getById(1);
user.setAmount(user.getAmount() + amount);
db2User.updateById(user);
}
return Result.ok(user);
}
}
- 一致性验证
异常回滚情况:
正常提交:
- TM宕机后TC超时会回滚事务: