SpringCloudAlibabaSeate处理分布式事务
1、部分面试题
-
微服务boot/cloud做的项目,你不可能只有一个数据库吧?那么多个数据库之间如何处理分布式事务的?
一个场景:在订单支付成功后,交易中心会调用订单中心的服务把订单状态更新,并调用物流中心的服务通知商品发货,同时还要调用积分中心的服务为用户增加相应的积分。如何保障分布式事务一致性,成为了确保订单业务稳定运行的核心诉求之一。
-
阿里巴巴的Seate-AT模式如何做到对业务的无侵入?
-
对于分布式事务问题,你知道的解决方案有哪些?
- 2PC(两阶段提交);
- 3PC(三阶段提交);
- TCC方案,TCC(Try-Confirm-Cancel)又被称补偿事务,类似2PC的柔性分布式解决方案,2PC改良版;
- LocalMessage本地消息表;
- 独立消息微服务+RabbitMQ/KafKa组件,实现可靠消息最终一致性方案;
- 最大努力通知方案;
以上的面试题都指向了一个问题,一次业务操作需要跨多个数据源或者需要跨多个系统进行远程调用,就会产生分布式业务问题,但是关系型数据库提供的能力是基于单机事务的,一旦遇到了分布式事务场景,就需要通过更多其他技术手段来解决。
-
分布式事务之前,是单机单库没这个问题,但是表结构关系从1:1->1:N->N:N;
-
分布式事务之后
单体应用被拆分成微服务应用,原来的三个模块被拆分成三个独立的应用,分别使用三个独立的数据源,业务操作需要调用三个服务来完成。
此时每个服务自己内部的数据一致性由本地事务来保证,但是全局的数据一致性问题没法保证。
2、Seata简介
2.1、基本简介
官网:Apache Seata
GitHub:GitHub
+++++++++Seata(Simple Extensible Autonomous Transaction Architecture)是一款开源的分布式事务解决方案,致力于在微服务架构下提供高性能和简单易用的分布式事务服务。简单可扩展自治事务框架。
2.2、各事务模式
2.2.1、Seata AT 模式(重点使用这个)
AT 模式是 Seata 创新的一种非侵入式的分布式事务解决方案,Seata 在内部做了对数据库操作的代理层,我们使用 Seata AT 模式时,实际上用的是 Seata 自带的数据源代理 DataSourceProxy,Seata 在这层代理中加入了很多逻辑,比如插入回滚 undo_log 日志,检查全局锁等。
两阶段提交协议的演变:
- 一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
- 二阶段:
- 提交异步化,非常快速地完成。
- 回滚通过一阶段的回滚日志进行反向补偿。
2.2.2、Seata TCC 模式
TCC 模式是 Seata 支持的一种由业务方细粒度控制的侵入式分布式事务解决方案,是继 AT 模式后第二种支持的事务模式,最早由蚂蚁金服贡献。其分布式事务模型直接作用于服务层,不依赖底层数据库,可以灵活选择业务资源的锁定粒度,减少资源锁持有时间,可扩展性好,可以说是为独立部署的 SOA 服务而设计的。
2.2.3、Seata Saga 模式
Saga 模式是 SEATA 提供的长事务解决方案,在 Saga 模式中,业务流程中每个参与者都提交本地事务,当出现某一个参与者失败则补偿前面已经成功的参与者,一阶段正向服务和二阶段补偿服务都由业务开发实现。
2.2.4、Seata XA 模式
XA 模式是从 1.2 版本支持的事务模式。XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准。Seata XA 模式是利用事务资源(数据库、消息服务等)对 XA 协议的支持,以 XA 协议的机制来管理分支事务的一种事务模式。
3、Seata工作流程简介
整个分布式事务的管理,就是全局事务ID的传递和变更,要让开发者无感知。
Seata对分布式事务的协调和控制就是一个XID加3个概念(TC+TM+RM)
-
XID是全局事务的唯一标识,它可以在服务的调用链路中传递,绑定到事物的上下文中;
-
TC (Transaction Coordinator)-事务协调者
它就是意义上的Seata,维护全局和分支事务的状态,驱动全局事务提交或回滚。 -
TM (Transaction Manager)-事务管理器
标注全局@GlobalTransactional启动入口动作的微服务模块(比如订单模块),它是事务的发起者,负责定义全局事务的范围,并根据TC维护的全局事务和分支事务状态,做出开始事务、提交事务、回滚事务的决议。
-
RM(Resource Manager)-资源管理器(就是mysql数据库本身,可以是多个RM)
管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
小总结
三个组件相互协作,TC以Seata 服务器(Server)形式独立部署,TM和RM则是以Seata Client的形式集成在微服务中运行。
-
TM 向 TC 申请开启一个全局事务,全局事务创建成功并生成一个全局唯一的 XID;
-
XID 在微服务调用链路的上下文中传播;
-
RM 向 TC 注册分支事务,将其纳入 XID 对应全局事务的管辖;
-
TM 向 TC 发起针对 XID 的全局提交或回滚决议;
-
TC 调度 XID 下管辖的全部分支事务完成提交或回滚请求。
4、Seata-Server2.0.0安装
官网2.0.0版本的下载地址:2.0.0.zip (github.com)
GitHub下载地址:seata-server-2.0.0.zip (github.com)
Seata参数配置详情:参数配置 | Apache Seata
4.1、mysql8.0数据库建库建表
直接拿到官网的mysql.sql文件mysql.sql (github.com)
先创建数据库
CREATE DATABASE seata;
USE seata;
然后再执行sql创建表
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` (`xid`)
) 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);
4.2、更改配置
修改seata-server-2.0.0\conf\application.yml配置文件,修改前要备份一下出厂的配置文件。
我们先看一下自带的配置模板,看一下官方给的配置样例,我们对着改自己的就好。
server:
port: 7091
spring:
application:
name: seata-server
logging:
config: classpath:logback-spring.xml
file:
path: ${log.home:${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
#服务配置中心选择nacos
type: nacos
nacos:
server-addr: 127.0.0.1:8848
namespace:
#后续自己在nacos里面新建,不想新建SEATA_GROUP,就写DEFAULT_GROUP
group: SEATA_GROUP
username: nacos
password: nacos
registry:
# support: nacos, eureka, redis, zk, consul, etcd3, sofa
#服务注册中心选择nacos
type: nacos
nacos:
application: seata-server
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
namespace:
cluster: default
username: nacos
password: nacos
store:
# support: file 、 db 、 redis 、 raft
#这里就是我们参考官网的mysql.sql文件中要的条件db,使用数据库存储
mode: db
db:
datasource: druid
db-type: mysql
#这里我们是8.0版本的驱动换一下
driver-class-name: com.mysql.cj.jdbc.Driver
#这里说明一下,我的MySQL8.0的端口被我改成3307了
url: jdbc:mysql://127.0.0.1:3307/seata?characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true
user: root
password: 123456
min-conn: 10
max-conn: 100
#这里分别对应我们创建的仨表
global-table: global_table
branch-table: branch_table
lock-table: lock_table
distributed-lock-table: distributed_lock
query-limit: 1000
max-wait: 5000
# server:
# service-port: 8091 #If not configured, the default is '${server.port} + 1000'
security:
secretKey: SeataSecretKey0c382ef121d778043159209298fd40bf3850a017
tokenValidityInMilliseconds: 1800000
ignore:
urls: /,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.jpeg,/**/*.ico,/api/v1/auth/login,/metadata/v1/**
测试先启动Nacos
然后启动seata-server,找到seata的bin目录找到seata-server.bat启动
然后刷新一下nacos服务列表
访问seata首页:localhost:7091,账密都是seata
5、Seata案例实战数据库和表准备
我们要准备订单+库存+账户3个业务数据库准备好
业务说明:
- 当用户下单时,会在订单服务中创建一个订单,然后通过远程调用库存服务来扣减下单商品的库存;
- 再通过远程调用账户服务来扣减用户账户里面的余额;
- 最后在订单服务中修改订单状态为已完成;
该操作跨越三个数据库,有两次远程调用,很明显会有分布式事务问题。
由于我们使用的是Seata的AT模式,所以在每一个库中都应创建一个undo_log回滚日志表以便在事务失败或者需要回滚时进行回滚。
undo_log回滚日志表在官网中也有给出
GitHub地址:incubator-seata/script/client/at/db/mysql.sql at master · apache/incubator-seata · GitHub
-- 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`
(
`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`)
) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4 COMMENT ='AT transaction mode undo table';
ALTER TABLE `undo_log` ADD INDEX `ix_log_created` (`log_created`);
5.1、seata_order:存储订单的数据库
执行SQL,建seata_order库+建t_order表+undo_log表
CREATE DATABASE seata_order;
USE seata_order;
CREATE TABLE t_order(
`id` BIGINT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
`user_id` BIGINT(11) DEFAULT NULL COMMENT '用户id',
`product_id` BIGINT(11)DEFAULT NULL COMMENT '产品id',
`count` INT(11) DEFAULT NULL COMMENT '数量',
`money` DECIMAL(11,0) DEFAULT NULL COMMENT '金额',
`status` INT(1) DEFAULT NULL COMMENT '订单状态: 0:创建中; 1:已完结'
)ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
SELECT * FROM t_order;
-- 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`
(
`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`)
) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4 COMMENT ='AT transaction mode undo table';
ALTER TABLE `undo_log` ADD INDEX `ix_log_created` (`log_created`);
5.2、seata_storage:存储库存的数据库
执行SQL,建seata_storage库+建t_storage表+undo_log表
#storage
CREATE DATABASE seata_storage;
USE seata_storage;
CREATE TABLE t_storage(
`id` BIGINT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
`product_id` BIGINT(11) DEFAULT NULL COMMENT '产品id',
`total` INT(11) DEFAULT NULL COMMENT '总库存',
`used` INT(11) DEFAULT NULL COMMENT '已用库存',
`residue` INT(11) DEFAULT NULL COMMENT '剩余库存'
)ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
INSERT INTO t_storage(`id`,`product_id`,`total`,`used`,`residue`)VALUES('1','1','100','0','100');
SELECT * FROM t_storage;
-- 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`
(
`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`)
) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4 COMMENT ='AT transaction mode undo table';
ALTER TABLE `undo_log` ADD INDEX `ix_log_created` (`log_created`);
5.3、seata_account:存诸账户信息的数据库
执行SQL,建seata_account库+建t_account表+undo_log表
#account
create database seata_account;
use seata_account;
CREATE TABLE t_account(
`id` BIGINT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT 'id',
`user_id` BIGINT(11) DEFAULT NULL COMMENT '用户id',
`total` DECIMAL(10,0) DEFAULT NULL COMMENT '总额度',
`used` DECIMAL(10,0) DEFAULT NULL COMMENT '已用余额',
`residue` DECIMAL(10,0) DEFAULT '0' COMMENT '剩余可用额度'
)ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
INSERT INTO t_account(`id`,`user_id`,`total`,`used`,`residue`)VALUES('1','1','1000','0','1000');
SELECT * FROM t_account;
-- 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`
(
`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`)
) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4 COMMENT ='AT transaction mode undo table';
ALTER TABLE `undo_log` ADD INDEX `ix_log_created` (`log_created`);
6、微服务实现
下订单–>减库存–>扣余额->改订单状态
6.1、使用MyBatis一键生成dao层
修改config.properties
# seata_order
jdbc.driverClass = com.mysql.cj.jdbc.Driver
jdbc.url = jdbc:mysql://localhost:3307/seata_order?characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true
jdbc.user = root
jdbc.password =123456
修改generatorConfig.xml
<!-- <table tableName="t_pay" domainObjectName="Pay">-->
<!-- <generatedKey column="id" sqlStatement="JDBC"/>-->
<!-- </table>-->
<!-- seata_order -->
<table tableName="t_order" domainObjectName="Order">
<generatedKey column="id" sqlStatement="JDBC"/>
</table>
刷新maven中mybatis插件
cloud-api-commons新增库存和账户两个Feign接口
StorageFeignApi
@FeignClient(value = "seata-storage-service") //seata-storage-service现在还没有一会建
public interface StorageFeignApi {
//扣减库存
@PostMapping(value = "/storage/decrease")
ResultData decrease(@RequestParam("productId") Long productId, @RequestParam("count") Integer count);
}
AccountFeignApi
@FeignClient(value = "seata-account-service")
public interface AccountFeignApi {
//扣减账户余额
@PostMapping("/account/decrease")
ResultData decrease(@RequestParam("userId") Long userId, @RequestParam("money") Long money);
}
6.2、新建订单Order微服务seata-order-service2001
导入相关依赖
<dependencies>
<!-- nacos -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--alibaba-seata-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
<!--openfeign-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!--loadbalancer-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<!--cloud-api-commons-->
<dependency>
<groupId>com.zm.cloud</groupId>
<artifactId>cloud-api-commons</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!--web + actuator-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!--SpringBoot集成druid连接池-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
</dependency>
<!-- Swagger3 调用方式 http://你的主机IP地址:5555/swagger-ui/index.html -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<!--mybatis和springboot整合-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<!--Mysql数据库驱动8 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!--persistence-->
<dependency>
<groupId>javax.persistence</groupId>
<artifactId>persistence-api</artifactId>
</dependency>
<!--通用Mapper4-->
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper</artifactId>
</dependency>
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>
<!-- fastjson2 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.28</version>
<scope>provided</scope>
</dependency>
<!--test-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
配置application.yml
server:
port: 2001
spring:
application:
name: seata-order-service
cloud:
nacos:
discovery:
server-addr: localhost:8848 #Nacos服务注册中心地址
# ==========applicationName + druid-mysql8 driver===================
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3307/seata_order?characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true
username: root
password: 123456
# ========================mybatis===================
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.zm.cloud.entities
configuration:
map-underscore-to-camel-case: true
# ========================seata===================
seata:
registry:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
namespace: ""
group: SEATA_GROUP
application: seata-server
tx-service-group: default_tx_group # 事务组,由它获得TC服务的集群名称
service:
vgroup-mapping: # 点击源码分析
default_tx_group: default # 事务组与TC服务集群的映射关系
data-source-proxy-mode: AT #不写也是AT
logging:
level:
io:
seata: info
对应说明
主启动类
@SpringBootApplication
@MapperScan("com.zm.cloud.mapper")
@EnableDiscoveryClient
@EnableFeignClients
public class Main2001 {
public static void main(String[] args) {
SpringApplication.run(Main2001.class,args);
}
}
我们把mybatis_generator中自动生成的订单模块dao层的实体类和mapper文件复制过来。
在实体类上打上@ToString注解并实现序列化接口。
@Table(name = "t_order")
@ToString
public class Order implements Serializable
创建OrderService
public interface OrderService {
//创建订单
void createOrder(Order order);
}
创建OrderServiceImp实现类
@Slf4j
@Service
public class OrderServiceImp implements OrderService {
@Resource
private OrderMapper orderMapper;
@Resource
private StorageFeignApi storageFeignApi;//订单微服务通过openfeign调用库存微服务
@Resource
private AccountFeignApi accountFeignApi;//订单微服务通过openfeign调用账户微服务
@Override
public void createOrder(Order order) {
//XID全局事务的检查通过seata的RootContext获取
String xid = RootContext.getXID();
//创建订单
log.info("==================>开始新建订单"+"\t"+"xid_order:" +xid+"\n");
//创建之前有订单状态,0是创建中,1代表创建完成
order.setStatus(0);
//再开始创建订单,拿到返回值进行判断,大于0代表插入一条记录成功
int i = orderMapper.insert(order);
//插入成功之后再获得MySQL的实体对象
Order orderFromDB = null;
if (i>0) {
orderFromDB=orderMapper.selectOne(order);
log.info("-------> 新建订单成功,orderFromDB info: "+orderFromDB+"\n");
//新订单创建成功后开始调用storageFeignApi减少一个库存
log.info("-------> 订单微服务开始调用storageFeignApi减少一个库存"+"\n");
storageFeignApi.decrease(orderFromDB.getProductId(), orderFromDB.getCount());
log.info("-------> 订单微服务开始调用storageFeignApi减少一个库存操作完成!!!");
//新订单创建成功后开始调用accountFeignApi扣用户的钱
log.info("-------> 订单微服务开始调用accountFeignApi扣除账户余额"+"\n");
accountFeignApi.decrease(orderFromDB.getProductId(), orderFromDB.getMoney());
log.info("-------> 订单微服务开始调用accountFeignApi扣除账户余额操作完成!!!");
System.out.println();
//订单完成修改状态为1
log.info("-------> 正在修改订单状态....");
orderFromDB.setStatus(1);
//构建查询条件
Example example = new Example(Order.class);
Example.Criteria criteria = example.createCriteria();
criteria.andEqualTo("userId",orderFromDB.getUserId());
criteria.andEqualTo("status",0);
//使用上面创建的查询条件,orderMapper调用updateByExampleSelective来更新数据库中的订单状态。
//这个方法会找到所有满足条件的订单,将这些订单的status字段更新为orderFromDB中的status值
int updateResult = orderMapper.updateByExampleSelective(orderFromDB, example);
log.info("-------> 修改订单状态完成"+"\t"+updateResult);
log.info("-------> orderFromDB info: "+orderFromDB);
}
System.out.println();
log.info("==================>结束新建订单"+"\t"+"xid_order:" +xid);
}
}
新建一个controller
@RestController
@Slf4j
public class OrderController {
@Resource
private OrderService orderService;
//创建订单
@GetMapping("/order/create")
public ResultData create(Order order){
orderService.createOrder(order);
return ResultData.success(order);
}
}
6.3、新建库存Storage微服务
新建module,seata-storage-service2002
添加相关依赖
<dependencies>
<!-- nacos -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--alibaba-seata-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
<!--openfeign-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!--loadbalancer-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<!--cloud_commons_utils-->
<dependency>
<groupId>com.atguigu.cloud</groupId>
<artifactId>cloud-api-commons</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!--web + actuator-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!--SpringBoot集成druid连接池-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
</dependency>
<!-- Swagger3 调用方式 http://你的主机IP地址:5555/swagger-ui/index.html -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<!--mybatis和springboot整合-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<!--Mysql数据库驱动8 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!--persistence-->
<dependency>
<groupId>javax.persistence</groupId>
<artifactId>persistence-api</artifactId>
</dependency>
<!--通用Mapper4-->
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper</artifactId>
</dependency>
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>
<!-- fastjson2 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.28</version>
<scope>provided</scope>
</dependency>
<!--test-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
配置文件application.yml
server:
port: 2002
spring:
application:
name: seata-storage-service
cloud:
nacos:
discovery:
server-addr: localhost:8848 #Nacos服务注册中心地址
# ==========applicationName + druid-mysql8 driver===================
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/seata_storage?characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true
username: root
password: 123456
# ========================mybatis===================
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.atguigu.cloud.entities
configuration:
map-underscore-to-camel-case: true
# ========================seata===================
seata:
registry:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
namespace: ""
group: SEATA_GROUP
application: seata-server
tx-service-group: default_tx_group # 事务组,由它获得TC服务的集群名称
service:
vgroup-mapping:
default_tx_group: default # 事务组与TC服务集群的映射关系
data-source-proxy-mode: AT
logging:
level:
io:
seata: info
主启动类
@MapperScan("com.zm.cloud.mapper")
@SpringBootApplication
@EnableFeignClients
@EnableDiscoveryClient
public class Main2002 {
public static void main(String[] args) {
SpringApplication.run(Main2002.class,args);
}
}
修改mybatis_generator中的自动生成配置,生成出库存的实体类和mapper文件
修改config.properties切换成库存的数据库seata_storage
# seata_storage
jdbc.driverClass = com.mysql.cj.jdbc.Driver
jdbc.url = jdbc:mysql://localhost:3307/seata_storage?characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true
jdbc.user = root
jdbc.password =123456
修改generatorConfig.xml切换t_storage
<table tableName="t_storage" domainObjectName="Storage">
<generatedKey column="id" sqlStatement="JDBC"/>
</table>
修改完成之后刷新插件
将生成的实体类和mapper文件拷贝到库存微服务中,实体类中打上ToString注解并实现序列化接口。
@Table(name = "t_storage")
@ToString
public class Storage implements Serializable
StorageMapper中添加自己的扣减库存的方法
public interface StorageMapper extends Mapper<Storage> {
//扣减库存
void decrease(@Param("productId") Long productId, @Param("count") Integer count);
}
对应着StorageMapper.xml增加方法的实现
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zm.cloud.mapper.StorageMapper">
<resultMap id="BaseResultMap" type="com.zm.cloud.entities.Storage">
<!--
WARNING - @mbg.generated
-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="product_id" jdbcType="BIGINT" property="productId" />
<result column="total" jdbcType="INTEGER" property="total" />
<result column="used" jdbcType="INTEGER" property="used" />
<result column="residue" jdbcType="INTEGER" property="residue" />
</resultMap>
<update id="decrease">
update t_storage
set
used = used + #{count},
residue = residue - #{count}
where
product_id = #{productId}
</update>
</mapper>
新建StorageService
public interface StorageService {
//扣库存
void decrease(Long productId, Integer count);
}
实现类StorageServiceImpl
@Service
@Slf4j
public class StorageServiceImpl implements StorageService {
@Resource
private StorageMapper storageMapper;
@Override
public void decrease(Long productId, Integer count) {
log.info("------->storage-service中扣减库存开始");
storageMapper.decrease(productId,count);
log.info("------->storage-service中扣减库存结束");
}
}
库存微服务的controller
@RestController
public class StorageController {
@Resource
private StorageService storageService;
//扣库存
@RequestMapping("/storage/decrease")
public ResultData decrease(Long productId, Integer count) {
storageService.decrease(productId, count);
return ResultData.success("扣减库存成功!");
}
}
6.4、新建账户Account微服务
新建模块seata-account-service2003
依赖和上面的库存微服务一样的就不重复了,配置文件开始,application.yml
server:
port: 2003
spring:
application:
name: seata-account-service
cloud:
nacos:
discovery:
server-addr: localhost:8848 #Nacos服务注册中心地址
# ==========applicationName + druid-mysql8 driver===================
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3307/seata_account?characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true
username: root
password: 123456
# ========================mybatis===================
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.zm.cloud.entities
configuration:
map-underscore-to-camel-case: true
# ========================seata===================
seata:
registry:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
namespace: ""
group: SEATA_GROUP
application: seata-server
tx-service-group: default_tx_group # 事务组,由它获得TC服务的集群名称
service:
vgroup-mapping:
default_tx_group: default # 事务组与TC服务集群的映射关系
data-source-proxy-mode: AT
logging:
level:
io:
seata: info
主启动类Main2003
@MapperScan("com.zm.cloud.mapper")
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class Main2003 {
public static void main(String[] args) {
SpringApplication.run(Main2003.class,args);
}
}
修改mybatis_generator中的自动生成配置文件,生成出账户的实体类和mapper文件
修改config.properties切换成库存的数据库seata_account
# seata_account
jdbc.driverClass = com.mysql.cj.jdbc.Driver
jdbc.url = jdbc:mysql://localhost:3307/seata_account?characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true
jdbc.user = root
jdbc.password =123456
修改generatorConfig.xml切换t_account
<!--seata_account-->
<table tableName="t_account" domainObjectName="Account">
<generatedKey column="id" sqlStatement="JDBC"/>
</table>
修改完成,更新一下自动生成,然后将生成的实体类和mapper文件拷贝到Account微服务中。
实体类中打上ToString注解并实现序列化接口。
@Table(name = "t_account")
@ToString
public class Account implements Serializable
AccountMapper中添加扣减余额的方法
public interface AccountMapper extends Mapper<Account> {
//扣减账户余额
void decrease(@Param("userId") Long userId,@Param("money") Long money);
}
在AccountMapper中添加实现
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zm.cloud.mapper.AccountMapper">
<resultMap id="BaseResultMap" type="com.zm.cloud.entities.Account">
<!--
WARNING - @mbg.generated
-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="user_id" jdbcType="BIGINT" property="userId" />
<result column="total" jdbcType="DECIMAL" property="total" />
<result column="used" jdbcType="DECIMAL" property="used" />
<result column="residue" jdbcType="DECIMAL" property="residue" />
</resultMap>
<update id="decrease">
update t_account
set residue = residue - #{money},
used = used + #{money}
where user_id = #{userId}
</update>
</mapper>
AccountService
public interface AccountService {
//扣减余额
void decrease(@Param("userId") Long userId, @Param("money") Long money);
}
接口实现类AccountServiceImpl
@Service
@Slf4j
public class AccountServiceImpl implements AccountService {
@Resource
private AccountMapper accountMapper;
@Override
public void decrease(Long userId, Long money) {
log.info("------->account-service中扣减账户余额开始");
accountMapper.decrease(userId,money);
//myTimeOut();
//int age = 10/0;
log.info("------->account-service中扣减账户余额结束");
}
//我们要模拟事务处理失败要回滚的场景
private static void myTimeOut()
{
try { TimeUnit.SECONDS.sleep(65); } catch (InterruptedException e) { e.printStackTrace(); }
}
}
AccountController
@RestController
public class AccountController {
@Resource
private AccountService accountService;
//扣减账户余额
@RequestMapping("/account/decrease")
ResultData decrease(@RequestParam("userId") Long userId, @RequestParam("money") Long money){
accountService.decrease(userId,money);
return ResultData.success("扣减账户余额成功!");
}
}
6.5、测试
nacos和seata全部启动起来,然后把2001、2002、2003微服务启动起来。
此时我们没有在订单模块添加@GlobalTransactional注解,浏览器请求一个下个订单,代表1号用户花了100块钱买了10个1号产品。
localhost:2001/order/create?userld=1&productld=1&count=10&money=100
然后你就会发现报错了~
这个就跟我们的springboot+springcloud版本太高导致和阿里巴巴Seata不兼容有关了,去改一下boot+cloud的版本。
<!--仅为了整合openfeign + alibaba seata的案例,降低版本处理下-->
<spring.boot.version>3.1.7</spring.boot.version>
<spring.cloud.version>2022.0.4</spring.cloud.version>
我们到数据库中看一下它有订单的插入,但是库存和账户都没有变化。
把这条数据删除后,再重启三个服务,然后再次访问一次
新建成功,查看后台输出情况
再看一下数据库的数据对不对帐。
OK了,目前我们没有添加注解@GlobalTransactional,现在演示我们扣钱过程中如果出现报错,我们的事务会不会进行回滚。
超时异常报错
把之前的那个seata-account-service2003微服务中service实现类里的超时方法注释打开测试一下。
重启2003微服务测试,开始转圈,等待65秒,其实这个时候看数据库订单状态为1
后台报错超时,但是看到它扣款和减少库存是成功的
到数据库中看,库存和金额都是被扣了
by zero错误测试
把刚才的超时方法注掉,把那个除以0的打开,再来一次。
直接报错,再看数据库的数据。
库存减少了,钱也扣了,结果是个未完成订单,这事务压根不回滚。
6.6、添加@GlobalTransactional注解测试
我们保留超时的方法,然后在订单的service实现类中的createOrder方法上打上@GlobalTransactional并为这个全局事务起一个名字zm-transactional-order
@GlobalTransactional(name = "zm-transactional-order",rollbackFor = Exception.class)
@Override
public void createOrder(Order order)
重启服务,还是那个localhost:2001/order/create?userId=1&productId=1&count=10&money=100
然后开seata的控制台,全局事务的信息都有了,全局锁会保证当前的操作只有子事务被允许。
这个时候到数据库看一下,有压根未完成的订单记录,库存也扣除了,账户也扣除了,还有undo_log表的记录。
等到我们的超时时间一到,就会发现上面的所有事务全部都回滚了,订单都没有了,都是添加注解之前的数据。
undo_log也是空的了
查看控制台输出
undo_log这个表无论数据是否正常插入,最后都会自动删除,它就起到一个溯源的作用,它记录上一步的内容,有错误的情况下根据这个内容进行回滚,然后自动删除。
既然错误的已经演示了,那么再来一个正常情况下的。
查看后台输出
6.7、AT模式如何做到对业务的无侵入
seata的整体机制:
两阶段提交协议的演变:
-
一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
在一阶段,Seata会拦截“业务SQL”
-
解析SQL语义,找到“业务SQL”要更新的业务数据,在业务数据被更新之前,将其保存为“before image”;
-
执行“业务SQL”更新业务数据,在业务数据更新之后将其保存为“after image”,最后生成行锁;
以上操作全部在一个数据库事务内完成,这样保证了一阶段的原子性。
-
-
二阶段:
- 提交异步化,非常快速地完成。
- 回滚通过一阶段的回滚日志进行反向补偿。
二阶段分为2种情况
正常提交:
二阶段如是顺利提交的话,因为“业务 SQL”在一阶段已经提交至数据库,所以Seata框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可。
出现异常需要回滚
二阶段如果是回滚的话,Seata 就需要回滚一阶段已经执行的“业务 SQL”,还原业务数据。
回滚方式便是用“before image”还原业务数据;但在还原前要首先要校验脏写,对比“数据库当前业务数据”和 “after image”,
如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。