SpringCloud~分布式事务~Seata~AT模式

目录

1 概述

2 AT模式

2.1 基本原理

2.2  相关角色

3 代码实战

3.1 SpringCloud

 3.2 搭建TCServer

 3.2.1 配置registry.conf

3.2.2 配置file.conf

3.3 集成seata

3.4 seata测试


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.conffile.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文件来演示的。后续会补充。

posted @ 2022-04-18 09:09  小猪不会叫  阅读(176)  评论(0)    收藏  举报  来源