Seata 交易:不再有跨(Spring Boot)微服务的分布式事务噩梦

在怀旧情绪可能有些过度的时代,软件开发界引发了一场激烈辩论:微服务模式的大规模采用是否真正带来了预期的收益,还是其收支状况更具不确定性?许多团队开始思考,是时候“回归”到可靠的单体架构,还是答案介于两者之间,以模块化单体的形式存在。

一位分心的架构师

分布式事务:本文旨在准确解决这一主题,展示在这种情况下,Apache Seata 如何与 Spring Boot 的敏捷性相结合,将许多人眼中的噩梦转化为令人惊讶的可管理解决方案。将向你展示 Seata 如何帮助你安睡,无需复杂的架构和回滚策略,或依赖更繁琐的模式如“Outbox 模式”。

一位怀旧且持怀疑态度的架构师

Apache Seata

Apache Seata是一个开源的分布式事务解决方案,目前处于Apache 孵化阶段,在微服务架构下提供高性能且易于使用的分布式事务服务。
主要组件包括:

  • 事务协调器(TC):维护全局和分支事务的状态,驱动全局提交或回滚。
  • 事务管理器(TM):定义全局事务的范围,开始全局事务,提交或回滚全局事务。
  • 资源管理器(RM):管理分支事务工作的资源,与 TC 通信以注册分支事务并报告其状态,驱动分支事务的提交或回滚。
    支持的事务模型有:ATXA (2PC)SAGA

XA (2PC)

XA 是 1991 年 X/Open 发布的规范(后来与 The Open Group 合并)。

  1. 阶段 1:准备(提交投票)

    • TC(Seata 服务器)询问所有参与的RM(例如与数据库交互的微服务)是否准备提交本地事务。
    • 每个 RM 执行其操作,写入事务日志以确保持久性,并锁定资源以保证隔离性。
    • 如果 RM 准备好,则向 TC 响应“是”,否则响应“否”。
  2. 阶段 2:提交或回滚

    • 如果所有RM 响应“是”,TC 向所有 RM 发送“提交”命令。它们完成本地事务并释放锁。
    • 如果任何RM 响应“否”,或者 TC 检测到超时/故障,TC 发送“回滚”命令。所有 RM 撤销其操作并释放锁。

    优缺点(简要)

  3. 优点:在多个服务之间提供强数据一致性(ACID),类似于一个不可分割的事务。
  4. 缺点:如果参与者或协调器缓慢或失败,可能导致资源阻塞(高延迟),可能影响可用性和可扩展性。它还依赖于底层数据库/资源支持 XA 标准。

SAGA

SAGA 模式是微服务架构中管理分布式事务的一种广泛采用的方法。与 2PC 不同,Saga 牺牲了即时的强一致性以获得更高的可用性和可扩展性,实现最终一致性。
Saga 是一系列本地事务,其中每个本地事务(在单个微服务中)更新其自己的数据库,然后发布事件或消息以触发序列中的下一个本地事务。

  • 无全局锁:关键是,本地事务立即提交,不持有全局锁,允许更高的并发性。
  • 失败补偿:如果任何本地事务失败,Saga 不会在传统意义上“回滚”。相反,它执行一系列补偿事务,以语义方式撤销先前完成的本地事务的影响。这些补偿事务是旨在逆转业务影响的新操作。
    Saga 可以通过以下方式实现:
  • 编排:服务发布事件并对其做出反应,导致去中心化的流程。
  • 编排:一个中央编排服务协调流程,发送命令并对响应做出反应。
    优缺点(简要)
  • 优点:由于没有长期持有的分布式锁,非常适合高可用性和可扩展性。适用于松散耦合的微服务。
  • 缺点:实现最终一致性,意味着数据可能暂时不一致。需要大量的开发工作来实现所有补偿事务并管理复杂的 Saga 逻辑,这也可能使调试更加困难。

AT

AT(自动事务)模式是 Seata 的旗舰解决方案,旨在提供 2PC 的易用性以及 Saga 的非阻塞性质和可扩展性优势。对于大多数使用关系数据库的微服务,它是推荐的默认模式。

  1. 阶段 1:本地事务和准备

    • 当微服务(RM)在全局事务中执行数据库操作(例如 UPDATE、INSERT、DELETE)时:
    • Seata 的智能DataSourceProxy拦截 SQL。
    • 它自动创建一个undo\_log(记录修改前的数据状态)。
    • SQL 操作在本地数据库上立即执行并提交
    • Seata 然后通过事务协调器(TC)获取修改资源的全局锁。此锁不是传统的数据库锁;它防止其他全局事务同时修改相同的资源,但不阻止读取操作。
    • RM 通知 TC 其分支事务已“准备就绪”。
  2. 阶段 2:全局提交或回滚

    • 全局提交:如果所有分支事务准备成功,TC 指示它们提交。由于本地数据库事务在阶段 1 中已经提交,RM 只需释放其全局锁。
    • 全局回滚:如果任何分支事务失败,或者全局事务需要回滚:

      • TC 指示 RM 回滚。
      • RM 使用其存储的undo\_log自动补偿对其本地数据库所做的更改,有效地恢复以前的状态。然后它们释放全局锁。

    优缺点(简要)

  3. 优点:为全局事务提供强一致性。提供出色的可用性和可扩展性,因为本地数据库锁仅短暂持有。对开发人员高度透明,需要最少的代码更改。自动回滚简化了错误处理。
  4. 缺点:主要为关系数据库设计。虽然在数据库级别是非阻塞的,但仍有生成 undo\_logs 和管理全局锁的开销。

一位不耐烦的开发者

与 Spring Boot 的实际演示

设想一个涉及两个不同微服务的场景,每个微服务都有自己专用的自治数据库:

  • 信用 API:负责管理用户信用(货币余额)
  • 发货 API:专门处理发货购买
    协调这两个服务的是一个 BFF(前端后端)。它的作用是协调发货购买操作,这转化为一系列分布式调用:
  • 通过信用 API 检查和/或扣除用户信用。
  • 使用信用通过发货 API 实际购买发货。
    关键问题是,如何确保这些跨不同服务和数据库的操作保持事务一致性,确保只有在信用已成功更新的情况下才完成购买,反之亦然?

一位邻居

架构

TM

BFF 将代表事务管理器,即它将定义全局事务。
Java 代码:

@Service
public class BFFManager {

    @Autowired
    private CreditService creditService;

    @Autowired
    private ShippingService shippingService;

    @GlobalTransactional
    public ShippingResult buyShipping(Long userID, BigDecimal cost) {
        var wallet = creditService.updateBalance(userID, cost);
        var shipping = shippingService.buyShipping(userID, cost);
        
        wallet = creditService.getWallet(userID);

        var result = new ShippingResult();
        result.setCost(cost);
        result.setShippingID(shipping.getId());
        result.setCurrentBalance(wallet.getBalance());
        return result;
    }
}

只需@GlobalTransactional注解就足够了吗?当然不是,但不需要太多其他东西:
TM 所需的依赖:
Java 代码:

implementation 'org.apache.seata:seata-spring-boot-starter:2.3.0'
implementation('com.alibaba.cloud:spring-cloud-starter-alibaba-seata:2023.0.3.3') {
  exclude group: 'org.apache.seata', module: 'seata-spring-boot-starter'
}

spring-cloud-starter-alibaba-seata除其他功能外,还确保微服务之间的 HTTP 通信始终包含全局事务 IDXID)。
seata-spring-boot-starter是经典的 Spring Boot 启动器,从属性开始自动配置 Seata 实体(在这种情况下为 TM):
Java 代码:

seata:
  enabled: true
  data-source-proxy-mode: AT
  enable-auto-data-source-proxy: true
  application-id:  ${spring.application.name}
  tx-service-group: default_tx_group
  service:
    vgroup-mapping:
      default_tx_group: default
    grouplist:
      default: 127.0.0.1:8091

RM

credit-apishipping-api都充当 RM。它们只需要seata-spring-boot-starter依赖,并具有以下属性:
Java 代码:

seata:
  enabled: true
  data-source-proxy-mode: AT
  enable-auto-data-source-proxy: true
  application-id:  ${spring.application.name}
  tx-service-group: default_tx_group
  service:
    vgroup-mapping:
      default_tx_group: default
    grouplist:
      default: 127.0.0.1:8091

RM 的数据库必须包含 Seata 的undo\_log表。这里是每种类型数据库的必要脚本。
在 GitHub 上的代码中,表的创建通过创建专用数据库的 docker compose(通过org.springframework.boot:spring-boot-docker-compose)进行管理。
在 RM 中,不需要特定的注解。像往常一样编写面向存储库的代码。如果愿意,我建议继续使用@Transactional进行本地事务。

TC

TC 由 Seata 的核心seata-server表示。这需要两个主要配置:

  • 注册中心:定义 Seata 将使用的服务注册中心(nacos、eureka、consul、zookeeper、redis、文件)。对于此示例,我选择使用file类型。
  • 存储:定义全局事务数据和全局锁的持久性(文件、数据库、redis)。对于此示例,我选择使用db类型。
    以下是用于初始化服务器的 Docker Compose 设置:
    YAML 代码:

    services:
    mysql:
      image: mysql:8.0.33
      container_name: mysql-seata
      environment:
        MYSQL_ROOT_PASSWORD: rootpass
        MYSQL_DATABASE: seata
        MYSQL_USER: seata_user
        MYSQL_PASSWORD: seata_pass
      ports:
        - "3317:3306"
      volumes:
        - mysql_data:/var/lib/mysql
        -./docker/seata/mysql.sql:/docker-entrypoint-initdb.d/seata.sql:ro
      healthcheck:
        test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-prootpass"]
        interval: 10s
        timeout: 5s
        retries: 5
    
    seata-server:
      image: apache/seata-server:2.3.0
      container_name: seata-server
      depends_on:
        mysql:
          condition: service_healthy
      environment:
        - SEATA_CONFIG_NAME=application.yml
      volumes:
        - "./docker/seata/resources/application.yml:/seata-server/resources/application.yml"
        - "./docker/seata/mysql-connector-j-8.0.33.jar:/seata-server/libs/mysql-connector-j-8.0.33.jar"
      ports:
        - "7091:7091"
        - "8091:8091"
    
    volumes:
      mysql_data:

    以及配置属性(application.yaml):
    YAML 代码:

    server:
    port: 7091
    
    spring:
    application:
      name: seata-server
    datasource:
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://mysql:3306/seata
      username: seata_user
      password: seata_pass
    
    logging:
    config: classpath:logback-spring.xml
    file:
      path: ${log.home:${user.home}/logs/seata}
    
    console:
    user:
      username: seata
      password: seata
    
    seata:
    security:
      secretKey: seata
      tokenValidityInMilliseconds: 1800000
    
    config:
      type: file
    
    registry:
      type: file
    
    store:
      mode: db
      db:
        dbType: mysql
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://mysql:3306/seata
        user: seata_user
        password: seata_pass
        min-conn: 10
        max-conn: 100
        global-table: global_table
        branch-table: branch_table
        lock-table: lock_table
        distributed-lock-table: distributed_lock
        vgroup-table: vgroup_table
        query-limit: 1000
          max-wait: 5000

    如你所见,专用数据库需要一些表才能运行。所有创建 SQL 脚本都可在此处获得这里

开始运行!

Java 代码:

@SpringBootTest
public class GlobalTransactionalTest {

    @Autowired
    private BFFManager bffManager;

    @Autowired
    private CreditService creditService;

    @MockitoBean
    private ShippingService shippingService;

    @Test
    public void globalTransactionalTest_OK() {
        var wallet = creditService.getWallet(1L);
        var shipping = new Shipping();
        shipping.setId(2L);
        when(shippingService.buyShipping(1L, new BigDecimal(4))).thenReturn(shipping);
        bffManager.buyShipping(1L, new BigDecimal(4));
        var newWallet = creditService.getWallet(1L);
        assertEquals(new BigDecimal("4.00"), wallet.getBalance().subtract(newWallet.getBalance()));
    }

    @Test
    public void globalTransactionalTest_KO() {
        var wallet = creditService.getWallet(1L);
        var shipping = new Shipping();
        shipping.setId(2L);
        when(shippingService.buyShipping(1L, new BigDecimal(4))).thenThrow(new RuntimeException());

        try {
            bffManager.buyShipping(1L, new BigDecimal(4));
        } catch (Exception e) {}

        var newWallet = creditService.getWallet(1L);
        assertEquals(newWallet.getBalance(), wallet.getBalance());
    }

}

完整且可运行的代码可在GitHub上获取。运行组件并告诉我你的想法!

关键替代方案

  • Atomikos:XA (2PC)。它仅在访问多个数据库的同一微服务内工作(没有事务协调器,没有 XID 传播)
  • SAGA 编排工作流引擎(CamundaTemporal.io):需要外部编排器和更高的集成工作。
  • SAGA(编排事件驱动):最终一致性权衡。
  • Outbox 模式:最终一致性。
    本文的目的不是推广 Apache Seata 优于其他提到的替代方案,而是强调其易用性。一如既往,应根据具体上下文和系统要求选择合适的工具。
    下一集:“单体有多酷?”敬请关注。
阅读 224
0 条评论