SpringCloud~分布式事务~Seata~AT模式
目录
1 概述
对于单体服务来说,我们只需要在方法上加上@Transactional注解就可以保证事务的顺利进行,但是对于我们的分布式来说,它涉及到多个服务,服务之间的相互调用没有一个统一的感知,导致了我们某个服务出错了,其他服务的功能有时依然正常进行。所以需要引入分布式事务来保证我们的事务 ,某个服务出错了,相应服务间的调用能够正常进行回滚操作。
seata有好几种模式(AT模式、TCC模式、Saga模式和XA模式),本篇文章讲述seata框架下的AT模式
2 AT模式
Seata开源了AT模式。AT模式是一种无侵入式的分布式解决方案。可以看做是对TCC或者二阶段提交模型的一种优化,解决了TCC模式中的代码侵入,编码复杂等问题。
在AT模式下,用户只需关注自己的业务sql,用户的业务sql作为第一阶段,Seata框架会自动生成事务的二阶段的提交和回滚操作。
2.1 基本原理
流程图:

可以看出,AT模式和之前说过了TCC模式很相似,都是分为了两个阶段。
第一阶段:执行本地事务,并返回执行结果
第二阶段:根据第一阶段的结果,判断二阶段的做法:提交还是回滚
但与TCC模式有很大不同的是,第二阶段的操作完全不需要我们去操作,Seata自己就可以实现了。换句话说:引用的AT模式后,我们写的代码和之前的本地事务一样,完全不需要手动的去处理分布式事务。
既然AT模式实现了代码的无侵入性,那么是如何实现在第二阶段是自动提交还是自动回滚呢?
如何实现自动回滚或提交操作的?
第一阶段:
在第一阶段。Seata回拦截业务sql,首先解析sql语义,找到业务sql要更新的业务数据,在业务sql更新前,将其保存为"before image",然后执行业务sql,更新业务数据之后,再将其保存为"aftr image",最后获取全局行锁,提交事务。以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性。

二阶段
1)提交:二阶段如果是提交的话,因为“业务sql”在第一阶段已经提交到数据库,所以Seata框架只需要将第一阶段保存的快照数据和行锁删掉,完成数据清理即可。
2)回滚:二阶段如果回滚,Seata就需要回滚一阶段已经执行的业务sql,还原业务数据。回滚数据就是用“before image”还原业务数据;但在还原前需要校验脏写,对比数据库当前业务数据和“after image”,如果两份数据完全一样说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要人工手动处理。
举例:
业务sql:update storage set count = count -1 where id = 1;
此时如果想要执行上述的一个业务sql
在第一阶段时,
- seata会拦截当前sql,
- sql执行前,找到所对应的数据库表,将id = 1 的这条数据记录下来,生成一个快照“before image”
- sql执行后,将执行后对应的数据库记录记录下来,生成一个快照“after image”
- 生成行锁,提交记录
在第二阶段时,
- 看对应的事务是否全部提交成功,如果是将第一阶段保存的快照数据和行锁删掉,完成数据清理
- 如果有事务提交失败,根据before image还原业务数据,还原之前需要拿记录数据和当前数据进行比对,防止脏数据
2.2 相关角色
Seata中有三大模块,分别是TM、RM和TC.其中TM和RM是作为Seata的客户端与业务系统紧密联系在一起,TC作为Seata的服务器独立部署。
TC-事务协调者:维护全局和分支事务的状态,驱动全局事务提交和回滚
TM-事务管理器:定义全局事务范围:开始全局事务,提交或回滚全局事务
RM-资源管理器:管理分支事务处的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交和回滚

其中TC是一个独立的服务,需要单独引进,而TM和RM则需要引入jar包就可以。
3 代码实战
3.1 SpringCloud
注:为了方便,此实战代码是直接网上找来的,亲测可用
demo介绍:springcloud搭建,eureka为注册中心,包含了三个微服务(库存服务,订单服务,账户服务)
场景介绍;下单时,订单服务调用账户服务和库存服务,这样就会产生跨服务和跨服务员的分布式事务问题。
简单介绍下服务间的相互调用:
订单服务:
controller层:
@RestController
@RequestMapping("order")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<Long> createOrder(Order order){
Long orderId = orderService.create(order);
return ResponseEntity.status(HttpStatus.CREATED).body(orderId);
}
}
service实现层(service省略):
@Slf4j
@Service
public class OrderServiceImpl implements OrderService {
private final AccountClient accountClient;
private final StorageClient storageClient;
private final OrderMapper orderMapper;
public OrderServiceImpl(AccountClient accountClient, StorageClient storageClient, OrderMapper orderMapper) {
this.accountClient = accountClient;
this.storageClient = storageClient;
this.orderMapper = orderMapper;
}
@Override
@Transactional
public Long create(Order order) {
// 创建订单
orderMapper.insert(order);
try {
// 扣库存
storageClient.deduct(order.getCommodityCode(), order.getCount());
// 扣款
accountClient.debit(order.getUserId(), order.getMoney());
} catch (FeignException e) {
log.error("下单失败,原因:{}", e.contentUTF8());
throw new RuntimeException(e.contentUTF8());
}
return order.getId();
}
}
下订单时,扣减库存和金额。
库存服务:
controller层:
@RestController
@RequestMapping("storage")
public class StorageController {
private final StorageService storageService;
public StorageController(StorageService storageService) {
this.storageService = storageService;
}
/**
* 扣减库存
* @param code 商品编号
* @param count 要扣减的数量
* @return 无
*/
@PutMapping("/{code}/{count}")
public ResponseEntity<Void> deduct(@PathVariable("code") String code,@PathVariable("count") Integer count){
storageService.deduct(code, count);
return ResponseEntity.noContent().build();
}
}
service实现层(service省略):
@Slf4j
@Service
public class StorageServiceImpl implements StorageService {
@Autowired
private StorageMapper storageMapper;
@Transactional
@Override
public void deduct(String commodityCode, int count) {
log.info("开始扣减库存");
try {
storageMapper.deduct(commodityCode, count);
} catch (Exception e) {
throw new RuntimeException("扣减库存失败,可能是库存不足!");
}
log.info("扣减库存成功");
}
}
mapper:
public interface StorageMapper extends BaseMapper<Storage> {
@Update("update storage_tbl set `count` = `count` - #{count} where commodity_code = #{code}")
int deduct(@Param("code") String commodityCode, @Param("count") int count);
}
账户服务:
controller层:
@RestController
@RequestMapping("account")
public class AccountController {
private final AccountService accountService;
public AccountController(AccountService accountService) {
this.accountService = accountService;
}
@PutMapping("/{userId}/{money}")
public ResponseEntity<Void> debit(@PathVariable("userId") String userId, @PathVariable("money") Integer money){
accountService.debit(userId, money);
return ResponseEntity.noContent().build();
}
}
service实现层(service省略):
@Slf4j
@Service
public class AccountServiceImpl implements AccountService {
private final AccountMapper accountMapper;
public AccountServiceImpl(AccountMapper accountMapper) {
this.accountMapper = accountMapper;
}
@Override
@Transactional
public void debit(String userId, int money) {
log.info("开始扣款");
try {
accountMapper.debit(userId, money);
} catch (Exception e) {
throw new RuntimeException("扣款失败,可能是余额不足!");
}
log.info("扣款成功");
}
}
mapper:
public interface AccountMapper extends BaseMapper<Account> {
@Update("update account_tbl set money = money - ${money} where user_id = #{userId}")
int debit(@Param("userId") String userId, @Param("money") int money);
}
数据库表结构:

账户表:账户金额1000
![]()
订单表:没有记录

库存表:库存为10

undo_log日志表:

没有引入分布式事务前
1)正常的情况:
调用一次服务:扣减库存2,扣减金额100

调用成功:
订单表生成一条记录:

账户正常扣减:扣减100还有900

库存正常扣减:扣减2还有8

2)错误的情况:
调用一次服务:扣减库存2,扣减金额1000(由于我们的金额还剩900,所以金额扣减1000肯定是失败的)

相应的订单没有生成成功:

由于账户金额不够,所以账户没有进行扣减:

库存却进行了扣减(扣减2还有6)

这样明显是不对的,既然账户的金额没有进行扣减的话,库存也不应该进行扣减。而且对应的方法上也是加了@Transactional事务的,可见没有起作用。

3.2 搭建TCServer
GitHub地址:https://github.com/seata/seata/releases
将下载下来的压缩包解压下来之后:

找到里面的registry.conf和file.conf进行配置
3.2.1 配置registry.conf
打开文件,发现里面大致分为两个部分。一个registry中心和一个config中心
注册中心(registry)中要将我们TC服务注册到eureka上去(看是选择哪一个注册中心,并指定类型type)
type = "eureka"
eureka {
serviceUrl = "http://localhost:8761/eureka"
application = "seata_tc_server"
weight = "1"
}

配置中心(config)需要配置我们的配置文件在哪个位置(因为此次的微服务没有搭建配置能中心,所以这次直接用file表示)

3.2.2 配置file.conf
file.conf是用来存储我们执行后的数据的,一种是文件的形式,一种是数据库的形式,这里我们本地采用file的形式
## transaction log store, only used in seata-server
store {
## store mode: file、db
mode = "file"
## file store property
file {
## store location dir
dir = "sessionStore"
# branch session size , if exceeded first try compress lockkey, still exceeded throws exceptions
maxBranchSessionSize = 16384
# globe session size , if exceeded throws exceptions
maxGlobalSessionSize = 512
# file buffer size , if exceeded allocate new buffer
fileWriteBufferCacheSize = 16384
# when recover batch read size
sessionReloadReadSize = 100
# async, sync
flushDiskMode = async
}
## database store property
db {
## the implement of javax.sql.DataSource, such as DruidDataSource(druid)/BasicDataSource(dbcp) etc.
datasource = "dbcp"
## mysql/oracle/h2/oceanbase etc.
dbType = "mysql"
driverClassName = "com.mysql.jdbc.Driver"
url = "jdbc:mysql://127.0.0.1:3306/seata"
user = "mysql"
password = "mysql"
minConn = 1
maxConn = 10
globalTable = "global_table"
branchTable = "branch_table"
lockTable = "lock_table"
queryLimit = 100
}
}
3.3 集成seata
1)在项目文件中引入相关依赖:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-seata</artifactId>
</dependency>
<dependency>
<artifactId>seata-all</artifactId>
<groupId>io.seata</groupId>
</dependency>
2)添加配置文件(application.yml)
spring:
cloud:
alibaba:
seata:
tx-service-group: test_tx_group
3)在项目文件路径下配置file.conf和registry.conf
注意:这里的file.conf与上面的file.conf不太一样,这里是配置TM和RM的,上面那个是配置TC的。
registry.conf:
registry {
# file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
type = "eureka"
nacos {
serverAddr = "localhost"
namespace = ""
cluster = "default"
}
eureka {
serviceUrl = "http://localhost:8761/eureka"
application = "seata_tc_server"
weight = "1"
}
redis {
serverAddr = "localhost:6379"
db = "0"
password = ""
cluster = "default"
timeout = "0"
}
zk {
cluster = "default"
serverAddr = "127.0.0.1:2181"
session.timeout = 6000
connect.timeout = 2000
username = ""
password = ""
}
consul {
cluster = "default"
serverAddr = "127.0.0.1:8500"
}
etcd3 {
cluster = "default"
serverAddr = "http://localhost:2379"
}
sofa {
serverAddr = "127.0.0.1:9603"
application = "default"
region = "DEFAULT_ZONE"
datacenter = "DefaultDataCenter"
cluster = "default"
group = "SEATA_GROUP"
addressWaitTime = "3000"
}
file {
name = "file.conf"
}
}
config {
# file、nacos 、apollo、zk、consul、etcd3、springCloudConfig
type = "file"
nacos {
serverAddr = "localhost"
namespace = ""
group = "SEATA_GROUP"
}
consul {
serverAddr = "127.0.0.1:8500"
}
apollo {
app.id = "seata-server"
apollo.meta = "http://192.168.1.204:8801"
namespace = "application"
}
zk {
serverAddr = "127.0.0.1:2181"
session.timeout = 6000
connect.timeout = 2000
username = ""
password = ""
}
etcd3 {
serverAddr = "http://localhost:2379"
}
file {
name = "file.conf"
}
}
file.conf:
transport {
# tcp udt unix-domain-socket
type = "TCP"
#NIO NATIVE
server = "NIO"
#enable heartbeat
heartbeat = true
# the client batch send request enable
enableClientBatchSendRequest = true
#thread factory for netty
threadFactory {
bossThreadPrefix = "NettyBoss"
workerThreadPrefix = "NettyServerNIOWorker"
serverExecutorThread-prefix = "NettyServerBizHandler"
shareBossWorker = false
clientSelectorThreadPrefix = "NettyClientSelector"
clientSelectorThreadSize = 1
clientWorkerThreadPrefix = "NettyClientWorkerThread"
# netty boss thread size,will not be used for UDT
bossThreadSize = 1
#auto default pin or 8
workerThreadSize = "default"
}
shutdown {
# when destroy server, wait seconds
wait = 3
}
serialization = "seata"
compressor = "none"
}
service {
vgroup_mapping.test_tx_group = "seata_tc_server" #test_tx_groupyml文件中配置的事务组的名称
#only support when registry.type=file, please don't set multiple addresses
seata_tc_server.grouplist = "127.0.0.1:8091"
#degrade, current not support
enableDegrade = false
#disable seata
disableGlobalTransaction = false
}
client {
rm {
asyncCommitBufferLimit = 10000
lock {
retryInterval = 10
retryTimes = 30
retryPolicyBranchRollbackOnConflict = true
}
reportRetryCount = 5
tableMetaCheckEnable = false
reportSuccessEnable = false
}
tm {
commitRetryCount = 5
rollbackRetryCount = 5
}
undo {
dataValidation = true
logSerialization = "jackson"
logTable = "undo_log"
}
log {
exceptionRate = 100
}
}
4)标记事务的范围(就是在所在 的方法上加上@GlobalTransactional注解)

5)代理DataSource
因为seata二阶段是通过拦截sql语句,分析语句来指定回滚策略,因此需要对DataSource做代理。所以需要添加一个配置类来对sql进行处理
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import io.seata.rm.datasource.DataSourceProxy;
import org.apache.ibatis.session.SqlSessionFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
/**
* @Author: liubujun
* @Date: 2022/4/16 11:11
*/
@Configuration
public class DataSourceProxyConfig {
@Bean
public SqlSessionFactory sqlSessionFactoryBean(DataSource dataSource) throws Exception {
//订单服务中引入了mybatis-plus 所以要使用mybatisSqlSessionFactoryBean
MybatisSqlSessionFactoryBean sqlSessionFactoryBean = new MybatisSqlSessionFactoryBean();
//代理数据源
sqlSessionFactoryBean.setDataSource(new DataSourceProxy(dataSource));
//生成sqlSessionFactoryBean
return sqlSessionFactoryBean.getObject();
}
}
注意: 以上都是对我们事务的发起者做的配置(也就是我们的order-service),除此之外还需要对账户服务(account-service)和库存服务(storage-service)做一样的配置(@GlobalTransactional不用加,只有事务发起者才需要加)
3.4 seata测试
1)启动TCService:找到seata服务的bin目录,双击bat文件
启动成功如下:
看是否注册成功(访问本地eureka的地址):http://localhost:8761/

2)重启各个微服务
3)postman测试
此时账户中的余额为900:

订单表为一条数据:

库存表库存为6:

发送请求:(扣减账户为1000)此时肯定会报错,表中数据没有变化则说明分布式事务已经起作用

请求发生错误:

查看数据库表中数据:
账户表:

订单表:

库存表:

由此可见,我们利用seata框架下的AT模式就已经解决了分布式的事务问题。不过这里演示的是单机版的,拦截后的结果也应该输入到数据库当中,这里是这接用file文件来演示的。后续会补充。

浙公网安备 33010602011771号