分布式事务Seata实践入门

1 前言

现在应用基本上都是分布式部署,那么针对分布式事务问题,也有对应的解决方案。经过简单的调研,最后选择了阿里的 Seata 组件,来实现分布式事务。

Seata是2019年1月份,蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务,为用户打造一站式的分布式解决方案。

官网地址: http://seata.io/
文档: https://seata.io/zh-cn/docs/overview/what-is-seata.html

2 Seata 模式选择

Seata给我们提供了四种不同的分布式事务解决方案:

  • XA模式
    强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入

  • TCC模式
    最终一致的分阶段事务模式,有业务侵入

  • AT模式
    最终一致的分阶段事务模式,无业务侵入,也是Seata的默认模式

  • SAGA模式
    长事务模式,有业务侵入

综合业务量,可用性,以及集成的成本,最终选择了无业务入侵的 AT模式。

基于现有的Spring Boot版本,Spring Cloud版本,Spring Cloud Alibaba版本以及Mybatis Plus动态数据源版本,最后选择seata的1.5.2版本。

Spring Version 5.2.15.RELEASE
Spring Boot Version 2.3.12.RELEASE
Spring Cloud Version Hoxton.SR12
Spring Cloud Alibaba Version 2.2.9.RELEASE

3 Seata Server端搭建

Seata分TC、TM和RM三个角色,TC(Server端)为单独服务端部署,TMRM(Client端)由业务系统集成。

笔者采用file作为服务的注册中心,数据存储采用的db,数据源采用的hikari

3.1 下载Server源码包

seata-server-1.5.2.zip

可以使用本地部署,也可以使用 Docker部署(忽略该步骤)

3.2 建表

全局事务会话信息由3块内容构成,全局事务–>分支事务–>全局锁,对应表global_tablebranch_tablelock_table

创建seata数据库,注意:表结构字符集,需调整为 utf8mb4

-- -------------------------------- The script used when storeMode is 'db' --------------------------------
-- the table to store GlobalSession data
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_and_branch_id` (`xid` , `branch_id`)
) 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);

3.3 Server配置

完整的配置实例如下:

server:
  port: 6689

spring:
  application:
    name: seata-server

logging:
  config: classpath:logback-spring.xml
  file:
    path: ${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
    type: file
  registry:
    # support: nacos 、 eureka 、 redis 、 zk  、 consul 、 etcd3 、 sofa
    type: file
    # file field
    preferred-networks: 30.240.*
  server:
    #If not configured, the default is '${server.port} + 1000'
    #service-port: 8091
    max-commit-retry-timeout: -1
    max-rollback-retry-timeout: -1
    rollback-retry-timeout-unlock-enable: false
    enable-check-auth: true
    enable-parallel-request-handle: true
    retry-dead-threshold: 130000
    xaer-nota-retry-timeout: 60000
    recovery:
      handle-all-session-period: 1000
    undo:
      log-save-days: 7
      log-delete-period: 86400000
    session:
      #branch async remove queue size
      branch-async-queue-size: 5000
      #enable to asynchronous remove branchSession
      enable-branch-async-remove: false
  store:
    # support: file 、 db 、 redis
    mode: db
    session:
      mode: db
    lock:
      mode: db
    #io.netty.handler.codec.TooLongFrameException: Adjusted frame length exceeds 8388608: 1345270062 - discarded
    file:
      dir: sessionStore
      max-branch-session-size: 16384
      max-global-session-size: 512
      file-write-buffer-cache-size: 16384
      session-reload-read-size: 100
      flush-disk-mode: async
    db:
      branchTable: branch_table
      # hikari or druid
      datasource: hikari
      dbType: mysql
      distributedLockTable: distributed_lock
      globalTable: global_table
      lockTable: lock_table
      maxConn: 30
      maxWait: 5000
      minConn: 5
      queryLimit: 100
      driverClassName: com.mysql.cj.jdbc.Driver
      url: xxx/seata?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&allowMultiQueries=true
      user: xxx
      password: xxx

  metrics:
    enabled: false
    registry-type: compact
    exporter-list: prometheus
    exporter-prometheus-port: 9898
  transport:
    rpc-tc-request-timeout: 30000
    enable-tc-server-batch-send-response: false
    shutdown:
      wait: 3
    thread-factory:
      boss-thread-prefix: NettyBoss
      worker-thread-prefix: NettyServerNIOWorker
      boss-thread-size: 1
  security:
    secretKey: SeataSecretKey0c382ef121d778043159209298fd40bf5335jlkjlj53454j4jfdjfggj
    tokenValidityInMilliseconds: 1800000
    ignore:
      urls: /,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.ico,/console-fe/public/**,/api/v1/auth/login

3.4 启动Seata Server

seata-server.sh 脚本启动即可,启动成功如下:
分布式事务Seata实践入门_第1张图片

4 Seata Client 集成

在需要使用分布式事务的服务中,都需要如下操作,注意:只有主入口需要@GlobalTransactional注解

4.1 添加undo_log回滚记录表

所有的客户端,都需要添加该表,另外,回滚操作成功后,该表会被清空,所以每次看都是空的

CREATE TABLE `undo_log`
(
    `branch_id`     bigint(20)   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
  DEFAULT CHARSET = utf8mb4 COMMENT ='AT transaction mode undo table';

4.2 添加依赖


    <dependency>
          <groupId>io.seatagroupId>
          <artifactId>seata-spring-boot-starterartifactId>
          <version>1.5.2version>
          <exclusions>
              <exclusion>
                  <groupId>com.alibabagroupId>
                  <artifactId>druidartifactId>
              exclusion>
          exclusions>
      dependency>
      <dependency>
          <groupId>com.alibaba.cloudgroupId>
          <artifactId>spring-cloud-starter-alibaba-seataartifactId>
          <version>2.2.9.RELEASEversion>
          <exclusions>
              <exclusion>
                  <groupId>io.seatagroupId>
                  <artifactId>seata-spring-boot-starterartifactId>
              exclusion>
          exclusions>
      dependency>
      <dependency>
          <groupId>com.baomidougroupId>
          <artifactId>dynamic-datasource-spring-boot-starterartifactId>
          <version>3.4.1version>
      dependency>

4.3 Seata Client config

关于事务分组,详细请参考:https://seata.io/zh-cn/docs/user/txgroup/transaction-group.html

server:
  port: 6687

spring:
  datasource:
    dynamic:
      # 设置默认的数据源或者数据源组,默认值即为master
      primary: master
      # 严格匹配数据源,默认false. true未匹配到指定数据源时抛异常,false使用默认数据源
      strict: false
      # 默认false非懒启动,系统加载到数据源立即初始化连接池
      lazy: false
      #开启seata代理,开启后默认每个数据源都代理,如果某个数据源不需要代理可单独关闭
      seata: true
      #支持XA及AT模式,默认AT
      seata-mode: AT
      # 全局hikariCP参数,所有值和默认保持一致(现已支持的参数如下)
      hikari:
        catalog:
        # 数据库连接超时时间,默认60秒,即 60000
        connection-timeout: 60000
        validation-timeout:
        #空闲连接存活最大时间,默认 600000(10分钟)
        idle-timeout: 600000
        leak-detection-threshold:
        max-lifetime:
        #连接池最大连接数,默认是10
        max-pool-size: 10
        #最小空闲连接数量
        min-idle: 10
        initialization-fail-timeout:
        connection-init-sql:
        connection-test-query:
        dataSource-class-name:
        dataSource-jndi-name:
        schema:
        transaction-isolation-name:
        # 此属性控制从池返回的连接的默认自动提交行为,默认值:true
        is-auto-commit: true
        is-read-only: false
        is-isolate-internal-queries:
        is-register-mbeans:
        is-allow-pool-suspension:
        data-source-properties:
        health-check-properties:
      datasource:
        master:
          seata: false
          type: com.zaxxer.hikari.HikariDataSource
          driver-class-name: com.mysql.cj.jdbc.Driver
          url: xxx?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&allowMultiQueries=true
          username: xxx
          password: xxx
        order:
          type: com.zaxxer.hikari.HikariDataSource
          driver-class-name: com.mysql.cj.jdbc.Driver
          url: xxx/seata_order?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&allowMultiQueries=true
          username: xxx
          password: xxx
        account:
          type: com.zaxxer.hikari.HikariDataSource
          driver-class-name: com.mysql.cj.jdbc.Driver
          url: xxx/seata_account?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&allowMultiQueries=true
          username: xxx
          password: xxx
        product:
          type: com.zaxxer.hikari.HikariDataSource
          driver-class-name: com.mysql.cj.jdbc.Driver
          url: xxx/seata_product?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&allowMultiQueries=true
          username: xxx
          password: xxx


seata:
  enabled: true
  application-id: ${spring.application.name}
  tx-service-group: smile_tx_group
  #一定要是false,组件内部开启seata后会自动使用DataSourceProxy来包装DataSource
  enable-auto-data-source-proxy: false
  service:
    vgroup-mapping:
      # Transaction routing rules configuration, only for the client,The key corresponds to the value of tx service group above
      # Specify the transaction grouping to cluster mapping relationship (the cluster name on the right side of the equal sign needs to be consistent with the cluster registered by Seata Server)
      smile_tx_group: default
    grouplist:
      #seata-server地址仅file注册中心需要(这里要与server监听的端口一致)
      default: 127.0.0.1:7689
  config:
    type: file
  registry:
    type: file
  client:
    rm:
      # 1.5.2版本仅支持druid和antlr,这里虽然使用了hikari数据源,解析sql使用的druid,不冲突也不影响结果,详情可参考源码:io.seata.sqlparser.SqlParserType
      sqlParserType: druid

5 代码实践

这里采用常见的示例举例

5.1 正常下单

服务A代码示例:

/**
     * 正常下单
     */
    @PostMapping("/placeOrder")
    public String placeOrder(@Validated @RequestBody PlaceOrderRequest request) {
        orderService.placeOrder(request);
        return "下单成功";
    }

/**
     * 下单
     *
     * @param placeOrderRequest 订单请求参数
     */
    void placeOrder(PlaceOrderRequest placeOrderRequest);

	@DS("order")
    @Override
    @Transactional
    @GlobalTransactional
    @SuppressWarnings("all")
    public void placeOrder(PlaceOrderRequest request) {
    	log.info("当前的XID为: {}", RootContext.getXID());
        //业务执行省略。。。
    }

5.2 下单业务异常,事务回滚

服务A代码示例:

/**
     * 下单-添加操作日志失败回滚
     */
    @PostMapping("/placeOrderFail")
    public String placeOrderFail(@Validated @RequestBody PlaceOrderRequest request) {
        orderService.placeOrderFail(request);
        return "下单成功,操作日志记录失败回滚";
    }

/**
     * 

* place order fail *

* * @param placeOrderRequest * @return void * @Date 2023/4/15 14:22 */ void placeOrderFail(PlaceOrderRequest placeOrderRequest); /** *

* place order fail *

* * @param placeOrderRequest * @return void * @Date 2023/4/15 14:22 */ @DS("order") @Override @Transactional @GlobalTransactional @SuppressWarnings("all") public void placeOrderFail(PlaceOrderRequest request) { log.info("placeOrderFail xid: {}", RootContext.getXID()); //下单、减库存、处理账户余额 this.placeOrder(request); ApiLogger apiLogger = new ApiLogger(); apiLogger.setUserId(request.getUserId()); apiLogger.setBizId(String.valueOf(request.getUserId() + new Random().nextInt(100))); //场景一:另一个分布式服务,执行业务异常(下游分布式服务报错,导致上游所有服务回滚) feignClient.insertApiLoggerInfo(apiLogger); //场景二:当前业务执行异常,回滚本事务,同时回滚另一个feignClient分布式事务(上游报错,导致下游事务回滚) int i = 1 / 0; }

另一个分布式服务B代码示例:

	@Transactional(propagation = Propagation.REQUIRES_NEW)
    @PostMapping("/insert")
    public ObjectRestResponse<String> insertApiLoggerInfo(@RequestBody ApiLogger apiLogger) {
        log.info("insertApiLoggerInfo xid: {}", RootContext.getXID());
        apiLoggerService.save(apiLogger);
        //int i = 1 / 0;
        return ObjectRestResponse.success("insert api logger info success");
    }

5.3 启动Seata Client

启动分布式服务即可,出现以下内容,说明客户端启动成功

分布式事务Seata实践入门_第2张图片

回滚成功时,可以看到以下内容:
分布式事务Seata实践入门_第3张图片

6 踩坑总结

分布式事务组件,其实国内稳定的版本还不是很多,能业务自己实现最终一致性最好,否则才考虑使用Seata组件。笔者踩了好多坑,这里记录下最大的坑

  • 报错1:
    ### SQL: INSERT INTO p_order ( user_id, product_id, status, amount ) VALUES ( ?, ?, ?, ? )### Cause: java.sql.SQLException: io.seata.common.loader.EnhancedServiceNotFoundException: not found service provider for : io.seata.rm.datasource.sql.struct.TableMetaCache ; uncategorized SQLException; SQL state [null]; error code [0]; io.seata.common.loader.EnhancedServiceNotFoundException: not found service provider for : io.seata.rm.datasource.sql.struct.TableMetaCache; nested exception is java.sql.SQLException: io.seata.common.loader.EnhancedServiceNotFoundException: not found service provider for : io.seata.rm.datasource.sql.struct.TableMetaCache

    io.seata.rm.datasource.sql.struct.TableMetaCache这个错误,太容易出现了,看了好多的issue,最终都没有解决,最后只能看源码
    分布式事务Seata实践入门_第4张图片
    分布式事务Seata实践入门_第5张图片
    这里list集合判空,不是很严谨,本来应该加载资源,最后没加载,导致报错。

    当然,这个是访问时报的错,其实,在启动类的时候也会加载一次,所以整体上不会出问题的,出问题了,说明大概是jar版本依赖不兼容导致的。

  • 报错2
    io.seata.rm.datasource.sql.struct.cache.MysqlTableMetaCache java.lang.NoClassDefFoundError: io.seata.rm.datasource.sql.struct.cache.AbstractTableMetaCache ServiceLoader$InnerEnhancedServiceLoader : Load [io.seata.rm.datasource.sql.struct.cache.MysqlTableMetaCache] class fail. com/github/benmanes/caffeine/cache/Caffeine has been compiled by a more recent version of the Java Runtime (class file version 55.0), this version of the Java Runtime only recognizes class file versions up to 52.0

    当出现java.lang.NoClassDefFoundError时,应该第一想到是不是跟其他组件版本不兼容,笔者的是跟一个缓存的组件caffeine产生了问题,根据github官网的描述,对于jdk11 以上的jdk版本请使用3.1.x,否则使用2.9.x,For Java 11 or above, use 3.1.x otherwise use 2.9.x,将基础组件的版本,从3.0.0降到了2.9.3,该问题得到了解决

基础入门实践,就先写到这里,后面再简单分享下原理,以及把注册的方式,由file升级为nacos形式,敬请期待~

写博客是为了记住自己容易忘记的东西,另外也是对自己工作的总结,希望尽自己的努力,做到更好,大家一起努力进步!

如果有什么问题,欢迎大家一起探讨,代码如有问题,欢迎各位大神指正!

给自己的梦想添加一双翅膀,让它可以在天空中自由自在的飞翔!

你可能感兴趣的:(分布式专题,微服务系列,分布式,事务)