Seata整合SpringCloud

业务场景

项目中业务系统与微服务之间需要同步更新数据库,遇到了分布式事务问题。
在两种场景下会出现数据不一致:第一,当微服务方法成功执行,业务系统遭遇异常时,业务系统回滚,微服务无感知而提交事务;第二,即使整个请求流程中均无异常发生,遇到并发时,由于微服务和业务系统属于各自独立的事务,两边的提交顺序无法保证,也会出现数据不一致。

基于以上业务场景,尝试引入阿里开源的seata框架解决分布式事务问题。

seata简介

seata模型

seata的整个过程模型如下图所示:

seata springcloud alibaba 版本对应_分布式事务


上图中主要有三种角色:

  • TM:事务管理者,用来告诉 TC,全局事务的开始,提交,回滚。
  • TC:事务协调者,即seata-server,维护全局事务和分支事务的状态,通知各RM提交或者回滚。
  • RM:资源管理者,每一个 RM 都会作为一个分支事务注册在 TC,负责分支事务的注册、提交和回滚。

典型的分布式事务周期包括以下步骤:

  • TM向TC请求开启一个全局事务,TC给TM返回一个全局事务的XID;
  • XID在微服务调用链之间传递;
  • RM向TC注册XID下的分支事务;
  • TM根据XID向TC发出提交或者回滚的请求;
  • TC根据XID使RM提交或者回滚。

seata事务模式

seata事务模式分为AT、TCC和Saga模式,默认使用AT模式,本文使用的也是AT模式。

AT模式

1、基于支持本地 ACID 事务的关系型数据库,整体机制是2PC的演变。

一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
二阶段:提交异步化,成功则批量地删除相应回滚日志记录,回滚则通过回滚日志进行反向补偿。

2、写隔离。

1)一阶段本地事务提交前,需要确保先拿到全局锁。
2)拿不到全局锁,不能提交本地事务;拿到全局锁,提交本地事务并插入undo_log记录。
3)拿全局锁的尝试被限制在一定范围内,超出范围将放弃,并根据undo_log记录回滚本地事务,释放本地锁。

3、读隔离。

1)在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted)。
理解:在全局事务提交之前,本地事务会先提交,这时候查询数据,对于本地库是Read Committed,对于全局来说是 Read Uncommitted。

2)如果要求全局的读已提交,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。
TCC模式

不依赖 RM 对分布式事务的支持,而是通过对业务逻辑的分解来实现分布式事务。

1)初步操作 Try:完成所有业务检查,预留必须的业务资源。
2)确认操作 Confirm:真正执行的业务逻辑,不做任何业务检查,只使用 Try 阶段预留的业务资源。因此,只要 Try 操作成功,Confirm 必须能成功。另外,Confirm 操作需满足幂等性,保证一笔分布式事务能且只能成功一次。
3)取消操作 Cancel:释放 Try 阶段预留的业务资源。同样的,Cancel 操作也需要满足幂等性。
SAGA模式

1、seata提供的长事务解决方案,在Saga模式中,业务流程中每个参与者都提交本地事务,当出现某一个参与者失败则补偿前面已经成功的参与者,一阶段正向服务和二阶段补偿服务都由业务开发实现。

2、基于状态机引擎的 Saga 实现。

1、通过状态图来定义服务调用的流程并生成 json 状态语言定义文件。
2、状态图中一个节点可以是调用一个服务,节点可以配置它的补偿节点。
3、状态图 json 由状态机引擎驱动执行,当出现异常时状态引擎反向执行已成功节点对应的补偿节点将事务回滚,异常发生时是否进行补偿也可由用户自定义决定。
4、可以实现服务编排需求,支持单项选择、并发、子流程、参数转换、参数映射、服务执行状态判断、异常捕获等功能。

项目整合

项目使用的是SpringBoot 2.x + SpringCloud,注册中心使用的是zookeeper。

seata-server

1、下载seata,解压。

wget https://github.com/seata/seata/releases/download/v1.1.0/seata-server-1.1.0.tar.gz

2、修改registry.conf。

// 可以选择多种注册中心,由于项目统一使用zk,这里选择zk
registry {
  # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
  type = "zk"
  zk {
    # cluster为server服务的名称/registry/zk下,serverAddr为zk路径
    cluster = "default"
    serverAddr = "127.0.0.1:2181"
    session.timeout = 6000
    connect.timeout = 2000
  }
}

// 配置信息也可以选择多种方式,这里选择文件的方式配置,寻找配置时会找file.conf文件。
// 用zk配置,路径固定在/seata下面,见ZookeeperConfiguration,不太灵活
config {
  # file、nacos 、apollo、zk、consul、etcd3
  type = "file"

  file {
    name = "file.conf"
  }
}

3、使用file.conf,store.mode选择db,修改连接的数据库属性,其他的默认即可。

transport {
  # tcp udt unix-domain-socket
  type = "TCP"
  #NIO NATIVE
  server = "NIO"
  #enable heartbeat
  heartbeat = true
  # the client batch send request enable
  enableClientBatchSendRequest = false
  #thread factory for netty
  threadFactory {
    bossThreadPrefix = "NettyBoss"
    workerThreadPrefix = "NettyServerNIOWorker"
    serverExecutorThreadPrefix = "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"
}

## transaction log store, only used in server side
## global_table、branch_table和lock_table是需要建立的表,存储会话和锁信息,可以给server使用单独的数据库。
store {
  ## store mode: file、db
  mode = "db"
  
  db {
    ## the implement of javax.sql.DataSource, such as DruidDataSource(druid)/BasicDataSource(dbcp) etc.
    datasource = "druid"
    ## mysql/oracle/h2/oceanbase etc.
    dbType = "mysql"
    driverClassName = "com.mysql.jdbc.Driver"
    url = "jdbc:mysql://localhost:3306/seata"
    user = "root"
    password = "root"
    minConn = 1
    maxConn = 10
    globalTable = "global_table" 
    branchTable = "branch_table"
    lockTable = "lock_table"
    queryLimit = 100
  }
}
## server configuration, only used in server side
server {
  recovery {
    #schedule committing retry period in milliseconds
    committingRetryPeriod = 1000
    #schedule asyn committing retry period in milliseconds
    asynCommittingRetryPeriod = 1000
    #schedule rollbacking retry period in milliseconds
    rollbackingRetryPeriod = 1000
    #schedule timeout retry period in milliseconds
    timeoutRetryPeriod = 1000
  }
  undo {
    logSaveDays = 7
    #schedule delete expired undo_log in milliseconds
    logDeletePeriod = 86400000
  }
  #unit ms,s,m,h,d represents milliseconds, seconds, minutes, hours, days, default permanent
  maxCommitRetryTimeout = "-1"
  maxRollbackRetryTimeout = "-1"
  rollbackRetryTimeoutUnlockEnable = false
}

## metrics configuration, only used in server side
metrics {
  enabled = false
  registryType = "compact"
  # multi exporters use comma divided
  exporterList = "prometheus"
  exporterPrometheusPort = 9898
}

4、在数据库中加入三个表。

DROP TABLE IF EXISTS `branch_table`;
CREATE TABLE `branch_table` (
  `branch_id` bigint(20) NOT NULL,
  `xid` varchar(128) NOT NULL,
  `transaction_id` bigint(20) DEFAULT NULL,
  `resource_group_id` varchar(32) DEFAULT NULL,
  `resource_id` varchar(256) DEFAULT NULL,
  `lock_key` varchar(128) DEFAULT NULL,
  `branch_type` varchar(8) DEFAULT NULL,
  `status` tinyint(4) DEFAULT NULL,
  `client_id` varchar(64) DEFAULT NULL,
  `application_data` varchar(2000) DEFAULT NULL,
  `gmt_create` datetime DEFAULT NULL,
  `gmt_modified` datetime DEFAULT NULL,
  PRIMARY KEY (`branch_id`),
  KEY `idx_xid` (`xid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `global_table` (
  `xid` varchar(128) NOT NULL,
  `transaction_id` bigint(20) DEFAULT NULL,
  `status` tinyint(4) NOT NULL,
  `application_id` varchar(64) DEFAULT NULL,
  `transaction_service_group` varchar(64) DEFAULT NULL,
  `transaction_name` varchar(64) DEFAULT NULL,
  `timeout` int(11) DEFAULT NULL,
  `begin_time` bigint(20) DEFAULT NULL,
  `application_data` varchar(2000) DEFAULT NULL,
  `gmt_create` datetime DEFAULT NULL,
  `gmt_modified` datetime DEFAULT NULL,
  PRIMARY KEY (`xid`),
  KEY `idx_gmt_modified_status` (`gmt_modified`,`status`),
  KEY `idx_transaction_id` (`transaction_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `lock_table` (
  `row_key` varchar(128) NOT NULL,
  `xid` varchar(96) DEFAULT NULL,
  `transaction_id` mediumtext,
  `branch_id` mediumtext,
  `resource_id` varchar(256) DEFAULT NULL,
  `table_name` varchar(32) DEFAULT NULL,
  `pk` varchar(32) DEFAULT NULL,
  `gmt_create` datetime DEFAULT NULL,
  `gmt_modified` datetime DEFAULT NULL,
  PRIMARY KEY (`row_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

5、在bin目录下,启动server服务,默认是8091端口。

nohup sh seata-server.sh &

项目中整合seata

官方提供了seata-spring-boot-start包,所有的配置都可以写在application.yml。但是项目中使用之后启动报错,因此使用另外一种方式配置。

1、引入maven依赖。seata-spring-boot-start实际上也是引入了seata-all包,加了自动配置相关类,动态代理获取配置的方法,从项目环境中获取配置。

<dependency>
    <groupId>io.seata</groupId>
    <artifactId>seata-all</artifactId>
    <version>1.1.0</version>
</dependency>

// seata中操作zk的客户端,缺少会报错
<dependency>
    <groupId>com.101tec</groupId>
    <artifactId>zkclient</artifactId>
    <version>0.11</version>
</dependency>

2、在classpath下加入registry.conf和file.conf,registry.conf和上面的一样,file.conf不一样。

# transport是共有部分,保持默认即可
transport {...}


service {
  #test-tx-group是事务分组,default是TC集群的名称,这样设计后,事务分组可以作为资源的逻辑隔离单位
  vgroupMapping.test-tx-group = "default"
  #只有registry.type=file支持,其他情况下不读
  default.grouplist = "127.0.0.1:8091"
  #允许降级
  enableDegrade = false
  #是否禁用seata
  disableGlobalTransaction = false
}

#client transaction configuration, only used in client side
client {
  rm {
    asyncCommitBufferLimit = 10000
    lock {
      retryInterval = 10
      retryTimes = 30
      retryPolicyBranchRollbackOnConflict = true
    }
    reportRetryCount = 5
    tableMetaCheckEnable = false
    reportSuccessEnable = false
    sqlParserType = druid
  }
  tm {
    commitRetryCount = 5
    rollbackRetryCount = 5
  }
  undo {
    dataValidation = true
    logSerialization = "jackson"
    logTable = "undo_log"
  }
  log {
    exceptionRate = 100
  }
}

3、配置数据源代理和扫描器。

@Configuration
public class SeataConfig {

    @Value("${spring.application.name}")
    private String applicationId;

    // 第二个参数是file.conf中service.vgroupMapping.后面的值
    @Bean
    public GlobalTransactionScanner globalTransactionScanner() {
        return new GlobalTransactionScanner(applicationId, "test-tx-group");
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.hikari")
    public DataSource dataSource(DataSourceProperties properties) {
        HikariDataSource hikariDataSource =
            properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
        return new DataSourceProxy(hikariDataSource);
    }

解释

GlobalTransactionScanner是全局事务扫描类,创建这个类时,主要做了三件事:
1、初始化配置,读取上面的两个配置文件;
2、初始化和TC通信的TMClient和RMClient;
3、为加上@GlobalTransactional和@GlobalLock注解的方法所在类创建代理GlobalTransactionalInterceptor,重写了开启事务、提交事务和回滚事务的方法;可以自定义TransactionHook,在事务各个阶段加上自己的业务逻辑。

DataSourceProxy是seata的数据源代理类。由于原有业务使用的是HikariDataSource,配置文件使用的自动配置的属性,这里为了不影响现有的代码,自定义代理数据源。

4、在需要开启分布式事务的方法上加上@GlobalTransactional注解。

在TM开启全局事务之后,TC返回一个XID,RootContext类中会保存XID,该XID是一个线程变量。

RM中根据XID向TC注册分支变量。

5、使用feign时,需要将XID传递给微服务,具体做法是在业务系统加一个feign拦截器。

@Component
public class FeignInterceptor implements RequestInterceptor {

	 // 这里在feign请求的header中加入xid
	 // 注意:这里一定要将feign.hystrix.enabled设为false,因为为true时feign是通过线程池调用,而XID并不是一个InheritablThreadLocal变量。
    @Override
    public void apply(RequestTemplate template) {
        String xid = RootContext.getXID();
        if (StringUtils.isNotBlank(xid)) {
            template.header("xid", xid);
        }
    }
}

6、在Springcloud微服务中均以同样的方式引入seata,加入Xid过滤器,将header中的XID绑定到Rootcontext中,这样创建事务时就会根据XID创建一个分支事务。

@Component
public class SeataXidFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String restXid = request.getHeader("xid");
        if (StringUtils.isNotBlank(restXid)) {
            RootContext.bind(restXid);
            if (logger.isDebugEnabled()) {
                logger.debug("bind[" + restXid + "] to RootContext");
            }
        }
        filterChain.doFilter(request, response);
    }
}

7、在业务系统和各个微服务中加入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,
  `ext` varchar(100) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;