Seata分布式事务框架解读一:基本入门

2024-04-17

一、概述

Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。

首先它支持四种分布式事务模式。然后支持 Dubbo、Spring Cloud、Motan、gRPC、sofa-RPC、EDAS-HSF 和 bRPC 框架等多个RPC框架/协议。

其默认模式是AT模式,使用的是2PC 协议。分布式事务的基本概念就是请求链路中跨进程或者微服务的本地事务(普通的MySQL、oracle等事务),必须全部提交或者回滚,保证全局的一致性。

Seata

这里简单介绍一些AT模式或者说2PC。

2PC整体分为两个阶段:

  1. 准备阶段:

协调者(Coordinator)向所有参与者(Participants)发送准备请求(prepare request),询问它们是否可以执行事务提交操作。

参与者执行本地事务,并记录准备状态,然后向协调者发送准备响应(prepare response),表示是否准备就绪。

  1. 提交阶段:

如果所有参与者都成功地准备就绪,协调者向所有参与者发送提交请求(commit request)。 参与者收到提交请求后,执行事务的提交操作,并释放事务资源。

如果有任何一个参与者无法准备就绪,协调者将向所有参与者发送回滚请求(rollback request),要求它们回滚事务。

AT或者2PC简单来说就是一阶段参与者(某个微服务)本地提交,并且记录回滚信息。然后二阶段有协调者(Seata的话就是SeataSever)来裁定是否全局提交或者全局回滚,再发给每个参与者进行本地的回滚或者提交。

实际设计和实现是很复杂,要考虑各种超时和失败场景。但AT模式或者说2PC比TCC的侵入性更低,实现复杂度也比其他的更低。

具体到Seata的2PC,大致流程如下:

首先需要创建Seata Server作为全局协调者TC。

然后在入口微服务启动分布式事务,作为TM,会请求TC获得XID(全局事务ID)。XID会根据RPC上下文传递给调用链的其他微服务。

每个微服务(RM)本地提交后还会生成回滚记录undo_log,里面有执行业务SQL和修改前后的镜像数据,用于接受TC的全局回滚请求。

更详细的执行流程见这篇文章的一阶段和二阶段:

what-is-seata

TM或者RM的执行业务SQL是由Seata代理去执行,会去TC获取XID,上报本地事务执行状态等。

简单介绍到这,下面开始看一下具体案例。

案例来自官方的Samples:

incubator-seata-sample

这里选择的是AT模式的springboot-dubbo-seata。笔者对案例有所调整。

本文案例源码为: SeataSamples

二、环境准备

  1. 首先准备基本的IDE,这里是IDEA 2024。基本的JAVA 版本和Maven版本等。笔者的Java版本是21,直接使用会出现一个Hessian2序列化的问题,所以项目中使用了fastjson2作为序列化。

  2. 下载seata server和启动server(也可以自己下载源码编译和执行),这里是file模式和standalone单机模式:


sh seata-server.sh -p 8091 -h 127.0.0.1 -m file

如果是使用DB模式,需要修改对应的数据库密码和创建单独的seata数据库。

若使用Nacos,按照官方文档配置和启动Nacos。

  1. dubbo服务的注册中心。这里默认是使用Zookeeper,也可以使用Nacos。

  2. 库表创建和微服务配置修改。官方sample所有表都创建到一个库,这里都改成单独的库。以Account账户服务为例,以下是修改的配置。

CREATE TABLE `account_tbl`
(
    `id`      int(11) NOT NULL AUTO_INCREMENT,
    `user_id` varchar(255) DEFAULT NULL,
    `money`   int(11) DEFAULT '0',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
CREATE TABLE IF NOT EXISTS `undo_log`
(
    `branch_id`     BIGINT       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 AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4 COMMENT ='AT transaction mode undo table';
ALTER TABLE `undo_log` ADD INDEX `ix_log_created` (`log_created`);

每个库除了业务表之外,单独新建一个undo_log表,用于AT模式下的回滚。

然后数据库改成每个微服务单独一个库。

spring.application.name=springboot-dubbo-seata-account
spring.datasource.driverClassName=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/account?userSSL=false&useUnicode=true&characterEncoding=UTF8&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=mysql
seata.application-id=springboot-dubbo-seata-account
seata.tx-service-group=my_test_tx_group
dubbo.scan.base-packages=org.apache.seata
dubbo.application.qos-enable=false
dubbo.registry.address=zookeeper://localhost:2181
dubbo.protocol.name=dubbo
dubbo.protocol.port=28801
dubbo.protocol.serialization=fastjson2
dubbo.provider.serialization=fastjson2

注意本地启动dubbo端口不能重复了。

三、案例执行和解读

下面开始执行案例。Account、Order、Storage三个微服务,分别启动。Business最后启动。

3.1 全局提交

这里注释掉异常模拟全局正常提交:

    @Override
    @GlobalTransactional(timeoutMills = 300000, name = "spring-dubbo-tx")
    public void purchase(String userId, String commodityCode, int orderCount) {
        LOGGER.info("purchase begin ... xid: " + RootContext.getXID());
        storageService.deduct(commodityCode, orderCount);
        // just test batch update
        //stockService.batchDeduct(commodityCode, orderCount);
        orderService.create(userId, commodityCode, orderCount);
       // throw new RuntimeException("random exception mock!");
    }

先记录全局的相关表的执行前数据。

account_tbl:

1,U100001,9998800

stock_tbl:

1,C00321,94

order_tbl:

1,U100001,C00321,2,400
2,U100001,C00321,1,200
3,U100001,C00321,1,200
5,U100001,C00321,1,200
6,U100001,C00321,1,200
7,U100001,C00321,1,200
8,U100001,C00321,1,200

执行Business服务,会调用Account、Order、Storage三个微服务,模拟全局事务提交。SpringbootDubboSeataBusinessApplication笔者简单修改为如下:

@SpringBootApplication
public class SpringbootDubboSeataBusinessApplication implements ApplicationContextAware {

    private static ApplicationContext context;


    public static void main(String[] args) throws Exception {
        SpringApplication.run(SpringbootDubboSeataBusinessApplication.class, args);
        while (true) {
            Thread.sleep(1000);
            break;
        }
        //延迟获取Bean再执行
        BusinessService businessService = context.getBean(BusinessService.class);
        businessService.purchase("U100001", "C00321", 1);

    }


    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.context = applicationContext;
    }
}

再检查执行后数据状态。 account_tbl:

1,U100001,9998600

stock_tbl:

1,C00321,93

order_tbl:

1,U100001,C00321,2,400
2,U100001,C00321,1,200
3,U100001,C00321,1,200
5,U100001,C00321,1,200
6,U100001,C00321,1,200
7,U100001,C00321,1,200
8,U100001,C00321,1,200
12,U100001,C00321,1,200

观察Account的执行日志:

2024-04-17 21:37:38.251  INFO 9868 --- [           main] o.a.d.r.c.m.ServiceInstanceMetadataUtils :  [DUBBO] Start registering instance address to registry., dubbo version: 3.1.2, current host: 172.29.16.1
2024-04-17 21:37:38.255  INFO 9868 --- [           main] org.apache.dubbo.metadata.MetadataInfo   :  [DUBBO] metadata revision changed: null -> fb210b6443b6bd3a636ef651e06c0392, app: springboot-dubbo-seata-account, services: 1, dubbo version: 3.1.2, current host: 172.29.16.1
2024-04-17 21:37:38.297  INFO 9868 --- [           main] o.a.d.c.d.DefaultApplicationDeployer     :  [DUBBO] Dubbo Application[1.1](springboot-dubbo-seata-account) is ready., dubbo version: 3.1.2, current host: 172.29.16.1
2024-04-17 21:37:38.299  INFO 9868 --- [           main] s.SpringbootDubboSeataAccountApplication : Started SpringbootDubboSeataAccountApplication in 1.919 seconds (JVM running for 2.246)
2024-04-17 21:37:38.301  INFO 9868 --- [pool-1-thread-1] .b.c.e.AwaitingNonWebApplicationListener :  [Dubbo] Current Spring Boot Application is await...
2024-04-17 21:43:35.278  INFO 9868 --- [-28801-thread-1] org.apache.seata.service.AccountService  : Account Service ... xid: 172.29.16.1:8091:6927047580972048427
2024-04-17 21:43:35.279  INFO 9868 --- [-28801-thread-1] org.apache.seata.service.AccountService  : Deducting balance SQL: update account_tbl set money = money - 200 where user_id = U100001
2024-04-17 21:43:35.436  INFO 9868 --- [-28801-thread-1] io.seata.rm.AbstractResourceManager      : branch register success, xid:172.29.16.1:8091:6927047580972048427, branchId:6927047580972048429, lockKeys:account_tbl:1
2024-04-17 21:43:35.441  WARN 9868 --- [-28801-thread-1] ServiceLoader$InnerEnhancedServiceLoader : Load [io.seata.rm.datasource.undo.parser.ProtostuffUndoLogParser] class fail: io/protostuff/runtime/IdStrategy
2024-04-17 21:43:35.451  INFO 9868 --- [-28801-thread-1] org.apache.seata.service.AccountService  : Account Service End ... 
2024-04-17 21:43:36.404  INFO 9868 --- [h_RMROLE_1_1_48] i.s.c.r.p.c.RmBranchCommitProcessor      : rm client handle branch commit process:BranchCommitRequest{xid='172.29.16.1:8091:6927047580972048427', branchId=6927047580972048429, branchType=AT, resourceId='jdbc:mysql://localhost:3306/account', applicationData='null'}
2024-04-17 21:43:36.405  INFO 9868 --- [h_RMROLE_1_1_48] io.seata.rm.AbstractRMHandler            : Branch committing: 172.29.16.1:8091:6927047580972048427 6927047580972048429 jdbc:mysql://localhost:3306/account null
2024-04-17 21:43:36.405  INFO 9868 --- [h_RMROLE_1_1_48] io.seata.rm.AbstractRMHandler            : Branch commit result: PhaseTwo_Committed

这里能看到全局的XID和本地的branchId,和2阶段的提交。

这样就模拟了正常提交全局事务的执行流程。

下面模拟全局回滚的步骤。

3.2 全局回滚

修改purchase方法模拟异常情况。

  @Override
    @GlobalTransactional(timeoutMills = 300000, name = "spring-dubbo-tx")
    public void purchase(String userId, String commodityCode, int orderCount) {
        LOGGER.info("purchase begin ... xid: " + RootContext.getXID());
        storageService.deduct(commodityCode, orderCount);
        // just test batch update
        //stockService.batchDeduct(commodityCode, orderCount);
        orderService.create(userId, commodityCode, orderCount);
        throw new RuntimeException("random exception mock!");
    }

在Business服务可以看到异常情况和回滚日志:

2024-04-17 21:48:56.284  INFO 38512 --- [           main] io.seata.tm.TransactionManagerHolder     : TransactionManager Singleton io.seata.tm.DefaultTransactionManager@2567c091
2024-04-17 21:48:56.287  INFO 38512 --- [           main] i.seata.tm.api.DefaultGlobalTransaction  : Begin new global transaction [172.29.16.1:8091:6927047580972048431]
2024-04-17 21:48:56.293  INFO 38512 --- [           main] o.apache.seata.service.BusinessService   : purchase begin ... xid: 172.29.16.1:8091:6927047580972048431
2024-04-17 21:48:56.406  INFO 38512 --- [           main] i.seata.tm.api.DefaultGlobalTransaction  : transaction 172.29.16.1:8091:6927047580972048431 will be rollback
2024-04-17 21:48:56.515  INFO 38512 --- [           main] i.seata.tm.api.DefaultGlobalTransaction  : transaction end, xid = 172.29.16.1:8091:6927047580972048431
2024-04-17 21:48:56.515  INFO 38512 --- [           main] i.seata.tm.api.DefaultGlobalTransaction  : [172.29.16.1:8091:6927047580972048431] rollback status: Rollbacked
Exception in thread "main" java.lang.RuntimeException: try to proceed invocation error

同样在Account服务可以看到回滚日志:

2024-04-17 21:48:56.388  INFO 9868 --- [-28801-thread-2] org.apache.seata.service.AccountService  : Account Service ... xid: 172.29.16.1:8091:6927047580972048431
2024-04-17 21:48:56.388  INFO 9868 --- [-28801-thread-2] org.apache.seata.service.AccountService  : Deducting balance SQL: update account_tbl set money = money - 200 where user_id = U100001
2024-04-17 21:48:56.393  INFO 9868 --- [-28801-thread-2] io.seata.rm.AbstractResourceManager      : branch register success, xid:172.29.16.1:8091:6927047580972048431, branchId:6927047580972048433, lockKeys:account_tbl:1
2024-04-17 21:48:56.396  INFO 9868 --- [-28801-thread-2] org.apache.seata.service.AccountService  : Account Service End ... 
2024-04-17 21:48:56.445  INFO 9868 --- [h_RMROLE_1_2_48] i.s.c.r.p.c.RmBranchRollbackProcessor    : rm handle branch rollback process:BranchRollbackRequest{xid='172.29.16.1:8091:6927047580972048431', branchId=6927047580972048433, branchType=AT, resourceId='jdbc:mysql://localhost:3306/account', applicationData='null'}
2024-04-17 21:48:56.445  INFO 9868 --- [h_RMROLE_1_2_48] io.seata.rm.AbstractRMHandler            : Branch Rollbacking: 172.29.16.1:8091:6927047580972048431 6927047580972048433 jdbc:mysql://localhost:3306/account
2024-04-17 21:48:56.491  INFO 9868 --- [h_RMROLE_1_2_48] i.s.r.d.undo.AbstractUndoLogManager      : xid 172.29.16.1:8091:6927047580972048431 branch 6927047580972048433, undo_log deleted with GlobalFinished
2024-04-17 21:48:56.492  INFO 9868 --- [h_RMROLE_1_2_48] i.seata.rm.datasource.DataSourceManager  : branch rollback success, xid:172.29.16.1:8091:6927047580972048431, branchId:6927047580972048433
2024-04-17 21:48:56.492  INFO 9868 --- [h_RMROLE_1_2_48] io.seata.rm.AbstractRMHandler            : Branch Rollbacked result: PhaseTwo_Rollbacked

然后执行后的数据状态也是都回滚到执行前的状态。 account_tbl:

1,U100001,9998600

stock_tbl:

1,C00321,93

order_tbl:

1,U100001,C00321,2,400
2,U100001,C00321,1,200
3,U100001,C00321,1,200
5,U100001,C00321,1,200
6,U100001,C00321,1,200
7,U100001,C00321,1,200
8,U100001,C00321,1,200
12,U100001,C00321,1,200

因为undo_log在全局提交/回滚后会删除,如果想阅读回滚记录undo_log,可以在回滚方法前debug看一下:

rollback

就可以看到如下的回滚记录:

{
  "@class": "io.seata.rm.datasource.undo.BranchUndoLog",
  "xid": "172.29.16.1:8091:6927047580972048423",
  "branchId": 6927047580972048000,
  "sqlUndoLogs": [
    "java.util.ArrayList",
    [
      {
        "@class": "io.seata.rm.datasource.undo.SQLUndoLog",
        "sqlType": "UPDATE",
        "tableName": "account_tbl",
        "beforeImage": {
          "@class": "io.seata.rm.datasource.sql.struct.TableRecords",
          "tableName": "account_tbl",
          "rows": [
            "java.util.ArrayList",
            [
              {
                "@class": "io.seata.rm.datasource.sql.struct.Row",
                "fields": [
                  "java.util.ArrayList",
                  [
                    {
                      "@class": "io.seata.rm.datasource.sql.struct.Field",
                      "name": "id",
                      "keyType": "PRIMARY_KEY",
                      "type": 4,
                      "value": 1
                    },
                    {
                      "@class": "io.seata.rm.datasource.sql.struct.Field",
                      "name": "money",
                      "keyType": "NULL",
                      "type": 4,
                      "value": 9998800
                    }
                  ]
                ]
              }
            ]
          ]
        },
        "afterImage": {
          "@class": "io.seata.rm.datasource.sql.struct.TableRecords",
          "tableName": "account_tbl",
          "rows": [
            "java.util.ArrayList",
            [
              {
                "@class": "io.seata.rm.datasource.sql.struct.Row",
                "fields": [
                  "java.util.ArrayList",
                  [
                    {
                      "@class": "io.seata.rm.datasource.sql.struct.Field",
                      "name": "id",
                      "keyType": "PRIMARY_KEY",
                      "type": 4,
                      "value": 1
                    },
                    {
                      "@class": "io.seata.rm.datasource.sql.struct.Field",
                      "name": "money",
                      "keyType": "NULL",
                      "type": 4,
                      "value": 9998600
                    }
                  ]
                ]
              }
            ]
          ]
        }
      }
    ]
  ]
}

这里就能看到具体的SQL和表,XID和branchId,以及修改前后的镜像数据。

四. 参考资料

  1. https://seata.apache.org/zh-cn/docs/overview/what-is-seata
  2. https://github.com/apache/incubator-seata-samples.git