Seata的AT模式
概念
AT模式是一种无侵入的分布式事务解决方案,在 AT 模式下,用户只需关注自己的“业务 SQL”,用户的 “业务 SQL” 作为一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作。
前提
- 基于支持本地 ACID 事务的关系型数据库。
- Java 应用,通过 JDBC 访问数据库。
整体机制
两阶段提交协议的演变:
- 一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
- 二阶段:
- 提交异步化,非常快速地完成。
- 回滚通过一阶段的回滚日志进行反向补偿。
一阶段
在一阶段中,Seata会拦截“业务SQL“,首先解析SQL语义,找到要更新的业务数据,在数据被更新前,保存下来"undo",然后执行”业务SQL“更新数据,更新之后再次保存数据”redo“,最后生成行锁,这些操作都在本地数据库事务内完成,这样保证了一阶段的原子性。
二阶段
相对一阶段,二阶段比较简单,负责整体的回滚和提交,如果之前的一阶段中有本地事务没有通过,那么就执行全局回滚,否在执行全局提交,回滚用到的就是一阶段记录的"Undo Log",通过回滚记录生成反向更新SQL并执行,以完成分支的回滚。当然事务完成后会释放所有资源和删除所有日志。
图解
具体实现
实现代码
- 首先新建两个服务,一个订单服务seata-order-8001, 一个库存服务seata-stock-8002
需要引入OpenFeign和Mybatis等相关依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<version>3.1.1</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.27</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.11</version>
</dependency>
<!-- spring-cloud-loadbalancer-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-loadbalancer</artifactId>
<version>3.1.1</version>
</dependency>
seata-order-8001服务:
- pom文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.example</groupId>
<artifactId>SeataTestProject</artifactId>
<version>1.0-SNAPSHOT</version>
<!-- <relativePath/> <!– lookup parent from repository –>-->
</parent>
<groupId>com.seata</groupId>
<artifactId>seata-order-8001</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>seata-order-8001</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-netflix-ribbon</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Seata依赖 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
</dependencies>
</project>
- 启动类需要加上注解@EnableDiscoveryClient和@EnableFeignClients
-
配置文件application.yml
server: port: 8001 spring: application: name: seata-order cloud: nacos: discovery: server-addr: localhost:8848 alibaba: seata: tx-service-group: default_tx_group # 事务组名称 datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/seata-order?characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=GMT%2B8 username: root password: 123456 type: com.alibaba.druid.pool.DruidDataSource seata: tx-service-group: default_tx_group # 事务组名称,要和服务端对应(Nacos中的配置) service: vgroup-mapping: default_tx_group: default # key是事务组名称 value要和服务端的机房名称保持一致 config: type: nacos nacos: server-addr: 127.0.0.1:8848 group: "SEATA_GROUP" namespace: "" dataId: "seataServer.properties" username: "nacos" password: "nacos" registry: type: nacos nacos: application: seata-server server-addr: 127.0.0.1:8848 group: "SEATA_GROUP" namespace: "" username: "nacos" password: "nacos"
注意:配置项tx-service-group需对应服务端指的是Nacos中的配置,因为在上一章节,我们把整个Seata服务端相对应的所有配置都上传到了Nacos中
注意这个配置:default_tx_group: default # key是事务组名称 value要和服务端的机房名称保持一致
这个配置的defaut,也需要和Nacos中的配置保持对应,同时还需要和seata-server的conf文件夹中的registry.conf文件中的配置保持一致
- 代码
package com.seata.seataOrder8001.client;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PutMapping;
import java.util.Map;
@FeignClient("seata-stock")
public interface StockClient {
@PutMapping("/changeStock-at")
String changeStockAt();
}
package com.seata.seataOrder8001.controller;
import com.seata.seataOrder8001.service.OrderService;
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.RestController;
import java.util.HashMap;
import java.util.Map;
@RestController
public class OrderController {
@Autowired
private OrderService orderService;
@GetMapping("/createOrder-at")
@GlobalTransactional// 开启分布式事务
public String createAt(){
orderService.createAt();
return "生成订单";
}
}
package com.seata.seataOrder8001.service;
public interface OrderService {
/**
* AT
*/
void createAt();
}
package com.seata.seataOrder8001.service.impl;
import com.seata.seataOrder8001.client.StockClient;
import com.seata.seataOrder8001.mapper.OrderMapper;
import com.seata.seataOrder8001.service.OrderService;
import io.seata.core.context.RootContext;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Map;
@Service
public class OrderServiceImpl implements OrderService {
@Resource
private OrderMapper orderMapper;
@Resource
private StockClient stockClient;
@Override
public void createAt() {
System.out.println("AT------------------> xid = " + RootContext.getXID());
// 减库存
stockClient.changeStock();
// 添加异常, 测试时此处添加断点
int i = 1/0;
// 创建订单
orderMapper.create();
}
}
package com.seata.seataOrder8001.mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OrderMapper {
@Insert("insert into tb_order (count) values (1)")
void create();
}
seata-stock-8002服务:
- pom文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.example</groupId>
<artifactId>SeataTestProject</artifactId>
<version>1.0-SNAPSHOT</version>
<!-- <relativePath/> <!– lookup parent from repository –>-->
</parent>
<groupId>com.seata</groupId>
<artifactId>seata-stock-8002</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>seata-stock-8002</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-netflix-ribbon</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Seata依赖 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
</dependencies>
</project>
- 启动类需要加上注解@EnableDiscoveryClient
- 配置文件application.yml
server:
port: 8002
spring:
application:
name: seata-stock
cloud:
nacos:
discovery:
server-addr: localhost:8848
alibaba:
seata:
tx-service-group: default_tx_group # 事务组名称
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/seata-stock?characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=GMT%2B8
username: root
password: 123456
type: com.alibaba.druid.pool.DruidDataSource
seata:
tx-service-group: default_tx_group # 事务组名称,要和服务端对应
service:
vgroup-mapping:
default_tx_group: default # key是事务组名称 value要和服务端的机房名称保持一致
disable-global-transaction: false
seata:
registry:
type: nacos
nacos:
application: seata-server
server-addr: 127.0.0.1:8848
group: "SEATA_GROUP"
namespace: ""
username: "nacos"
password: "nacos"
- 代码
package com.seata.seataStock8002.controller;
import com.seata.seataStock8002.service.StockService;
import com.seata.seataStock8002.service.StockServiceTcc;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.Map;
@RestController
public class StockController {
@Resource
private StockService stockService;
@Resource
private StockServiceTcc stockServiceTcc;
@PutMapping("/changeStock-at")
public String changeStockAt() {
stockService.changeStockAt();
return "库存减1";
}
}
package com.seata.seataStock8002.service;
public interface StockService {
void changeStockAt();
}
package com.seata.seataStock8002.service.impl;
import com.seata.seataStock8002.mapper.StockMapper;
import com.seata.seataStock8002.service.StockService;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@Service
public class StockServiceImpl implements StockService {
@Resource
private StockMapper stockMapper;
@Override
public void changeStockAt() {
stockMapper.subStock();
}
}
package com.seata.seataStock8002.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface StockMapper {
@Update("update tb_stock set count = count - 1 where goods_id = 1")
void subStock();
}
数据库设计
- 新建两个数据库:seata-order和seata-stock
seata-order数据库中的表为:
CREATE TABLE `tb_order` (
`goods_id` bigint NOT NULL AUTO_INCREMENT,
`count` int NULL DEFAULT NULL,
PRIMARY KEY (`goods_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '订单表' ROW_FORMAT = Dynamic;
-- 注意此处0.7.0+ 增加字段 context,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,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
seata-stock数据库中的表为:
CREATE TABLE `tb_stock` (
`goods_id` bigint NOT NULL,
`count` int NULL DEFAULT NULL,
`money` decimal(11, 2) NULL DEFAULT NULL,
PRIMARY KEY (`goods_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '库存表' ROW_FORMAT = Dynamic;
-- 注意此处0.7.0+ 增加字段 context, 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,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
-- 数据
INSERT INTO `tb_stock` VALUES (1, 100, 50.00);
具体操作
-
首先,先将Nacos 运行起来,再将Seata-Server运行起来
Nacos启动命令(standalone代表着单机模式运行,非集群模式):
startup.cmd -m standalone
启动Seata-Server的方式非常简单,直接双击此文件即可: seata-server-1.4.2\bin\seata-server.bat
- 然后,将服务seata-order-8001和 seata-stock-8002运行起来,跑起来后可以在Seata-Server上看到
-
这个时候我们进行访问Order的REST接口:http://localhost:8001/createOrder-at,我们就会发现此时已经解决了分布式事务问题,远程调用失败,seata-stock库中的库存表tb_stock的库存数量count没有减少,seata-order库中的订单表tb_order中订单记录也没有新增
- 此时我们用debug模式将8001服务跑起来, 然后访问接口:http://localhost:8001/createOrder-at,程序会卡在断点处,此时我们来查看seata-stock库中的undo_log表和tb_stock表,此时我们会发现,库存表tb_stock的库存数量count确实减少了,但是在undo_log表中出现了快照,记录了当前修改前的数据,这个数据就是用于回滚的数据
-
此时我们放行断点,发现seata-stock库中库存表tb_stock的库存数量count恢复为100了,回滚生效,undo_log表中用于回滚的临时数据也会被删除
seata-order库中的订单表tb_order中订单记录也没有新增,此时我们就验证了AT事务的执行过程