Seata 分布式事务

单体应用被拆分成微服务应用,原来的多个模块被拆分成多个独立的应用,分别使用多个独立的数据源,业务操作需要调用多个服务来完成。此时每个服务内部的数据一致性由本地事务来保证,但是全局的数据一致性问题没有办法保证。

简介

Seata是一款开源的分布式事务解决方案,旨在解决分布式事务场景下的数据一致性问题。Seata提供了一系列的技术手段,如TC(事务协调者)、TM(事务管理器)、RM(资源管理器)等,来确保分布式事务的正确性和一致性。

Seata的工作原理是通过TC来协调各个RM,实现全局事务的管理。当一个分布式事务开始时,TM会向TC注册一个全局事务,然后TC会向各个RM注册分支事务。在分支事务执行完成后,RM会将事务执行结果通知TC,TC会根据所有分支事务的执行结果来决定是否提交或回滚全局事务。

Seata支持多种分布式事务模式,如AT模式、TCC模式、Saga模式等。同时,Seata还提供了一些高级功能,如分布式锁、分布式ID、分布式事务日志等,以满足不同场景下的需求。

  • TC(Transaction Coordinator,事务协调者)是全局事务的协调者,负责协调各个RM的工作。当一个分布式事务开始时,TM会向TC注册一个全局事务,然后TC会向各个RM注册分支事务。在分支事务执行完成后,RM会将事务执行结果通知TC,TC会根据所有分支事务的执行结果来决定是否提交或回滚全局事务。
  • TM(Transaction Manager,事务管理器)负责全局事务的注册和提交/回滚。当一个分布式事务开始时,TM会向TC注册一个全局事务,然后根据事务的执行结果来决定是否提交或回滚全局事务。
  • RM(Resource Manager,资源管理器)负责本地事务的执行和管理。当TC向RM注册分支事务后,RM会执行分支事务并将执行结果报告给TC,然后根据TC的指示来提交或回滚本地事务。

Seata的处理流程

Seata流程

  1. TM向TC注册全局事务,全局事务创建成功并生成一个全局唯一的XID。
  2. XID在微服务调用链路的上下文中传播。
  3. RM向TC注册分支事务,将其纳入XID对应全局事务的管辖。
  4. TM向TC发起针对XID的全局提交或回滚决议。
  5. TC调度XID下管辖的全部分支事务完成提交或者回滚请求。

安装Seata

安装之前

Seata分TC、TM和RM三个角色,TC(Server端)为单独服务端部署,TM和RM(Client端)由业务系统集成。本文使用docker安装。

安装之前按照官网的说法Seata Server 1.5.0版本开始,配置文件改为application.yml,所以在使用自定义配置的时候,需要先把原生配置拷贝出来。

为了获取seata server 1.5.0的配置文件,我们需要先启动一个seata server 1.5.0的服务,然后再从启动的容器实例中把默认的配置文件复制出来,再进行修改。

  1. 先启动一个容器

    version: "3.8"
    services:
      seata-server:
        image: seataio/seata-server:${latest-release-version}
        container_name: seata-server
        ports:
          - "7091:7091"
          - "8091:8091"
    
  2. 从容器中将配置文件拷贝到一个临时目录

    docker cp seata-server:/seata-server/resources /tmp  
    
  3. 后续使用拷贝出来的application.yml配置文件即可。

修改配置文件

这里使用Nacos为注册中心以及配置中心。修改seata.config以及seata.registry

server:
  port: 7091
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: nacos
    nacos:
      server-addr: nacos-server:8848
      namespace: seata-server
      group: SEATA_GROUP
      usernam: nacos
      password: nacos
      data-id: seataServer.properties
  registry:
    # support: nacos, eureka, redis, zk, consul, etcd3, sofa
    type: nacos
    nacos:
      application: seata-server
      server-addr: nacos-server:8848
      group: SEATA_GROUP
      namespace: seata-server
      # tc集群名称
      cluster: default
      username: nacos
      password: nacos
#  store:
#    # support: file 、 db 、 redis
#    mode: file
#  server:
#    service-port: 8091 #If not configured, the default is '${server.port} + 1000'
  security:
    secretKey: SeataSecretKey0c382ef121d778043159209298fd40bf3850a017
    tokenValidityInMilliseconds: 1800000
    ignore:
      urls: /,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.ico,/console-fe/public/**,/api/v1/auth/login

在nacos配置中心上创建配置文件

此处dataId为seataServer.properties

在nacos配置中心上创建配置文件

修改nacos上的配置

这里我使用的是mysql8,官方提供了建表脚本文件。

store.mode=db
#-----db-----
store.db.datasource=druid
store.db.dbType=mysql
# 需要根据mysql的版本调整driverClassName
# mysql8及以上版本对应的driver:com.mysql.cj.jdbc.Driver
# mysql8以下版本的driver:com.mysql.jdbc.Driver
store.db.driverClassName=com.mysql.cj.jdbc.Driver
store.db.url=jdbc:mysql://mysql:3306/seata-server?useUnicode=true&characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useSSL=false
store.db.user= 用户名
store.db.password=密码
# 数据库初始连接数
store.db.minConn=1
# 数据库最大连接数
store.db.maxConn=20
# 获取连接时最大等待时间 默认5000,单位毫秒
store.db.maxWait=5000
# 全局事务表名 默认global_table
store.db.globalTable=global_table
# 分支事务表名 默认branch_table
store.db.branchTable=branch_table
# 全局锁表名 默认lock_table
store.db.lockTable=lock_table
# 查询全局事务一次的最大条数 默认100
store.db.queryLimit=100
# undo保留天数 默认7天,log_status=1(附录3)和未正常清理的undo
server.undo.logSaveDays=7
# undo清理线程间隔时间 默认86400000,单位毫秒
server.undo.logDeletePeriod=86400000
# 二阶段提交重试超时时长 单位ms,s,m,h,d,对应毫秒,秒,分,小时,天,默认毫秒。默认值-1表示无限重试
# 公式: timeout>=now-globalTransactionBeginTime,true表示超时则不再重试
# 注: 达到超时时间后将不会做任何重试,有数据不一致风险,除非业务自行可校准数据,否者慎用
server.maxCommitRetryTimeout=-1
# 二阶段回滚重试超时时长
server.maxRollbackRetryTimeout=-1
# 二阶段提交未完成状态全局事务重试提交线程间隔时间 默认1000,单位毫秒
server.recovery.committingRetryPeriod=1000
# 二阶段异步提交状态重试提交线程间隔时间 默认1000,单位毫秒
server.recovery.asynCommittingRetryPeriod=1000
# 二阶段回滚状态重试回滚线程间隔时间  默认1000,单位毫秒
server.recovery.rollbackingRetryPeriod=1000
# 超时状态检测重试线程间隔时间 默认1000,单位毫秒,检测出超时将全局事务置入回滚会话管理器
server.recovery.timeoutRetryPeriod=1000

docker-compose.yml文件

version: "3.8"
services:
  seata1:
    image: seataio/seata-server:latest
    container_name: seata1
    ports:
      - "7091:7091"
      - "8091:8091"
    environment:
      - STORE_MODE=db
      # 以SEATA_IP作为host注册seata server,可选, 指定seata-server启动的IP, 该IP用于向注册中心注册时使用, 如eureka等
      - SEATA_IP=${seata_ip}
      # 可选, 指定seata-server启动的端口, 默认为 8091     
      - SEATA_PORT=8091
    volumes:
      - "/usr/share/zoneinfo/Asia/Shanghai:/etc/localtime"        #设置系统时区
      - "/usr/share/zoneinfo/Asia/Shanghai:/etc/timezone"  #设置时区
      # 假设我们通过docker cp命令把资源文件拷贝到相对路径`./seata-server/resources`中
      - "./seata-server/resources:/seata-server/resources"
  seata2:
    image: seataio/seata-server:latest
    container_name: seata2
    ports:
      - "7092:7091"
      - "8092:8092"
    environment:
      - STORE_MODE=db
      # 以SEATA_IP作为host注册seata server,可选, 指定seata-server启动的IP, 该IP用于向注册中心注册时使用, 如eureka等
      - SEATA_IP=${seata_ip}
      # 可选, 指定seata-server启动的端口, 默认为 8091  
      - SEATA_PORT=8092
    volumes:
      - "/usr/share/zoneinfo/Asia/Shanghai:/etc/localtime"        #设置系统时区
      - "/usr/share/zoneinfo/Asia/Shanghai:/etc/timezone"  #设置时区
      # 假设我们通过docker cp命令把资源文件拷贝到相对路径`./seata-server/resources`中
      - "./seata-server/resources:/seata-server/resources"

踩坑:

  1. services.seata.environment 下的SEATA_IP不填写的话,会默认将容器IP注册到Nacos,可能导致网络不通。

  2. services.seata.environment 下SEATA_PORT,这里特别注意下,当时没有仔细看官网的环境变量描述,直接拷贝了docker-compose.yml,以为这个配置的仅仅是注册到nacos的端口,实际上也是seata服务启动的端口,官网上集群配置第二个是错的。直接启动客户端,总是会连不上第二个seata,客户端日志会打印出这个日志:

    can not connect to xxx.xxx.xxx.xxx:8092 cause:can not register RM,err:register RMROLE error, errMsg:null

    官网的配置:

    官网的配置
    这里官网上配置启动端口为8092,注册到nacos的端口也是8092 ,但是实际上seata在容器内启动的端口是8091 ,这里导致我就是连不上第二台服务。

配置客户端

Maven依赖

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
    <version>${seata.version}</version>
</dependency>

客户端application.yml配置

seata:
  enabled: true
  tx-service-group: test_tx_group
  service:
    vgroup-mapping:
      test_tx_group: default
  registry:
    type: nacos          #注册中心
    nacos:
      server-addr: ${spring.cloud.nacos.discovery.server-addr}
      group: SEATA_GROUP
      namespace: seata-server
      cluster: default
  config:
    type: nacos          #配置中心
    nacos:
      server-addr: ${spring.cloud.nacos.discovery.server-addr}
      namespace: seata-server
      group: SEATA_GROUP
      data-id: seataServer.properties

Seata原理

  1. TM开启分布式事务(TM向TC注册全局事务记录)。
  2. 按业务场景,编排数据库,服务等事务内资源(RM向TC汇报资源准备状态)。
  3. TM结束分布式事务,事务一阶段结束(TM通知TC提交/回滚分布式事务)。
  4. TC汇总事务信息,决定分布式事务是提交还是回滚。
  5. TC通知所有RM提交/回滚资源。

AT模式是怎么做到对业务无侵入的

事务一阶段

在第一阶段,Seata会拦截业务SQL:

  1. 解析SQL语义,找到业务SQL要更新的业务数据,在业务被更新之前,将其保存为“before image”。
  2. 执行业务SQL更新业务数据,在业务数据更新之后。
  3. 其保存为“after image”,最后生成行锁。

以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性。

解析SQL语句提取表的元数据保存原快照的Before Image执行业务员SQL保存新快照After Image生成行锁提交业务SQL、undo/redo log、行锁到业务DB

事务二阶段

二阶段提交

在二阶段如果没有异常,顺利提交的话。以为在一阶段,业务SQL已经提交到了数据库,所以Seata框架只需要将一阶段保存的快照数据和行锁删除掉,完成数据清理即可。

二阶段回滚

二阶段如果是回滚的话,seata就需要回滚一阶段已经执行的业务SQL,还原业务数据。其回滚方式便是使用“before image”来还原业务数据。但在还原之前要先校验脏写,对比“数据库当前的业务数据”和after image。如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。