从MySQL事务到Seata分布式事务:我的SpringBoot微服务是如何一步步引入AT模式并平滑切换的

去年我们的电商系统完成了从单体架构到微服务的拆分,订单、库存、账户三个核心模块被拆分成独立服务。当用户下单时,系统需要依次调用这三个服务完成创建订单、扣减库存和账户扣款操作。最初我们天真地认为,只要在每个服务的方法上添加Spring的@Transactional注解就能保证数据一致性——直到某天凌晨收到告警:一个用户下单后库存已扣减,但账户余额未变化,而订单状态却显示"已完成"。

1. 分布式事务问题的浮现与临时方案

1.1 第一个生产事故分析

那个引发事故的订单链路是这样的:

// 订单服务
@Transactional
public void createOrder(OrderDTO order) {
    // 1. 本地创建订单记录
    orderMapper.insert(order); 
    
    // 2. 远程调用库存服务
    stockFeignClient.deduct(order.getProductId(), order.getQuantity());
    
    // 3. 远程调用账户服务
    accountFeignClient.debit(order.getUserId(), order.getAmount());
    
    // 4. 更新订单状态
    order.setStatus(OrderStatus.COMPLETED);
    orderMapper.updateById(order);
}

当账户服务因网络抖动超时抛出异常时,我们发现:

  • 订单服务的本地事务回滚(记录被删除)
  • 库存服务的扣减操作已提交(因为它的本地事务已提交)
  • 账户服务由于超时未执行

更糟的是,由于Feign的默认重试机制,账户服务最终收到了两次扣款请求。这个案例暴露出三个关键问题:

  1. 跨服务事务边界:单个服务的本地事务无法覆盖其他服务的操作
  2. 缺乏全局协调:各服务无法感知整个调用链的成败状态
  3. 重试雪崩:部分服务可能被重复调用

1.2 过渡期的临时解决方案

在全面引入分布式事务框架前,我们尝试了两种过渡方案:

方案一:本地消息表+定时任务

-- 订单表新增字段
ALTER TABLE orders ADD COLUMN tx_status VARCHAR(20);
ALTER TABLE orders ADD COLUMN retry_count INT DEFAULT 0;
// 订单服务改造
@Transactional
public void createOrder(OrderDTO order) {
    // 1. 创建订单记录(初始状态为PROCESSING)
    order.setStatus(OrderStatus.PROCESSING);
    orderMapper.insert(order);
    
    try {
        // 2. 调用其他服务
        stockFeignClient.deduct(...);
        accountFeignClient.debit(...);
        
        // 3. 成功则更新状态
        order.setStatus(OrderStatus.COMPLETED);
        orderMapper.updateById(order);
    } catch (Exception e) {
        // 记录异常信息
        order.setTxStatus("FAILED");
        orderMapper.updateById(order);
    }
}

方案缺陷

  • 需要额外开发补偿逻辑
  • 定时任务扫描频率难以平衡(太频繁影响性能,间隔长则恢复延迟)
  • 无法解决服务间调用顺序的强一致性

方案二:基于RocketMQ的事务消息

// 订单服务发送半消息
TransactionSendResult sendResult = producer.sendMessageInTransaction(
    new Message("order_topic", JSON.toJSONBytes(order)), 
    null
);

// 本地事务执行器
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
    try {
        OrderDTO order = JSON.parseObject(msg.getBody(), OrderDTO.class);
        orderService.createOrder(order);
        return LocalTransactionState.COMMIT_MESSAGE;
    } catch (Exception e) {
        return LocalTransactionState.ROLLBACK_MESSAGE;
    }
}

遇到的新问题

  • 消息队列的引入增加了系统复杂度
  • 某些业务场景需要保证消息消费的顺序性
  • 仍然需要处理最终一致性问题

经过两周的对比测试,我们决定引入专业的分布式事务框架——Seata的AT模式。

2. Seata AT模式的引入与配置

2.1 环境准备与核心组件

我们的技术栈组合:

  • 注册中心:Nacos 2.2.1
  • 配置中心:Nacos(与注册中心共用)
  • Seata版本:1.5.2
  • 数据库:MySQL 8.0(必须使用InnoDB引擎)

关键Maven依赖:

<!-- 所有微服务都需要添加 -->
<dependency>
    <groupId>io.seata</groupId>
    <artifactId>seata-spring-boot-starter</artifactId>
    <version>1.5.2</version>
</dependency>
<dependency>
    <groupId>com.alibaba.nacos</groupId>
    <artifactId>nacos-client</artifactId>
    <version>2.2.1</version>
</dependency>

2.2 服务端配置详解

seata-server的nacos配置(seataServer.properties)

# 事务日志存储模式
store.mode=db
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.cj.jdbc.Driver
store.db.url=jdbc:mysql://127.0.0.1:3306/seata?useSSL=false
store.db.user=seata
store.db.password=seata
store.db.minConn=5
store.db.maxConn=30

# 事务分组配置
service.vgroupMapping.default_tx_group=default
service.default.grouplist=127.0.0.1:8091

数据库表结构要求

  1. 每个业务数据库需要创建undo_log
  2. Seata Server需要单独的数据库存储全局事务信息
-- 业务库需要的表
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,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2.3 客户端关键配置

application.yml配置要点

seata:
  enabled: 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
  config:
    type: nacos
    nacos:
      server-addr: 127.0.0.1:8848
      namespace:
      group: SEATA_GROUP
  registry:
    type: nacos
    nacos:
      application: seata-server
      server-addr: 127.0.0.1:8848
      namespace:
      group: SEATA_GROUP

数据源代理配置类

@Configuration
public class SeataDataSourceConfig {
    
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource")
    public DruidDataSource druidDataSource() {
        return new DruidDataSource();
    }

    @Primary
    @Bean("dataSource")
    public DataSource dataSourceProxy(DruidDataSource druidDataSource) {
        return new DataSourceProxy(druidDataSource);
    }

    @Bean
    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
        factoryBean.setDataSource(dataSource);
        factoryBean.setMapperLocations(
            new PathMatchingResourcePatternResolver()
                .getResources("classpath*:/mapper/*.xml"));
        return factoryBean.getObject();
    }
}

3. 代码改造与事务声明

3.1 全局事务注解的使用

原始代码改造对比:

// 改造前
@Transactional
public void createOrder(OrderDTO order) {
    orderMapper.insert(order);
    stockFeignClient.deduct(order.getProductId(), order.getQuantity());
    accountFeignClient.debit(order.getUserId(), order.getAmount());
    order.setStatus(OrderStatus.COMPLETED);
    orderMapper.updateById(order);
}

// 改造后
@GlobalTransactional(timeoutMills = 60000, name = "createOrder")
public void createOrder(OrderDTO order) {
    orderMapper.insert(order);
    stockFeignClient.deduct(order.getProductId(), order.getQuantity());
    accountFeignClient.debit(order.getUserId(), order.getAmount());
    order.setStatus(OrderStatus.COMPLETED);
    orderMapper.updateById(order);
}

关键注意事项

  1. @GlobalTransactional应该加在事务发起方的方法上
  2. 超时时间需要根据业务链路的长度合理设置
  3. 方法内部不要捕获异常(除非明确需要特殊处理)

3.2 Feign客户端的特殊处理

为确保事务上下文传递,需要对Feign客户端进行改造:

@FeignClient(name = "account-service", 
    configuration = FeignConfig.class)
public interface AccountFeignClient {
    
    @PostMapping("/account/debit")
    Boolean debit(@RequestParam("userId") Long userId, 
                 @RequestParam("amount") BigDecimal amount);
}

public class FeignConfig {
    @Bean
    public RequestInterceptor seataFeignInterceptor() {
        return template -> {
            String xid = RootContext.getXID();
            if (StringUtils.isNotBlank(xid)) {
                template.header(RootContext.KEY_XID, xid);
            }
        };
    }
}

3.3 事务隔离级别的考量

Seata AT模式的隔离级别与MySQL默认的RR级别有所不同:

隔离问题AT模式解决方案
脏读全局锁阻止未提交数据的读取
不可重复读通过快照数据保证重复读取结果一致
幻读不保证(业务设计应避免依赖幻读的场景)

应对策略

  1. 对于金额等敏感字段,采用select for update显式加锁
  2. 在查询接口添加@GlobalLock注解保证读取最新数据
  3. 业务设计上尽量避免前后依赖的连续查询

4. 上线后的监控与问题排查

4.1 Seata控制台的关键指标

我们搭建了Seata-Server的监控看板,重点关注以下指标:

  1. 全局事务统计

    • 每分钟事务数(TPS)
    • 成功/失败比例
    • 平均耗时分布
  2. 异常事务排查

    -- 查询失败的事务
    SELECT * FROM global_table WHERE status = 3 ORDER BY begin_time DESC LIMIT 10;
    
    -- 查询对应分支事务
    SELECT * FROM branch_table WHERE xid = 'xxx';
    
  3. 锁冲突检测

    -- 查询当前锁等待
    SELECT * FROM lock_table WHERE row_key LIKE 'order%';
    

4.2 常见问题与解决方案

问题一:全局锁等待超时

错误信息:

io.seata.rm.datasource.exec.LockWaitTimeoutException: Global lock wait timeout

解决方案:

  1. 检查是否有长时间未提交的本地事务
  2. 优化业务逻辑,减少全局锁持有时间
  3. 调整seata.server.max.commit.retry.timeout参数(默认值-1表示无限重试)

问题二:分支事务注册失败

错误信息:

Branch register failed: TransactionException[Failed to store branch]

排查步骤:

  1. 检查undo_log表结构是否正确
  2. 确认数据库用户有足够的权限
  3. 检查网络连接是否稳定

问题三:Heuristic异常

错误信息:

Heuristic termination: some branches have been committed while others have been rollbacked

处理流程:

  1. 通过xid查询各分支状态
  2. 人工核对数据一致性
  3. 使用Seata提供的修复工具进行补偿

4.3 性能优化实践

经过三个月运行,我们总结出以下优化经验:

  1. 连接池配置

    spring:
      datasource:
        druid:
          initial-size: 5
          min-idle: 5
          max-active: 20
          max-wait: 60000
    
  2. Seata Server调优

    # 增加处理线程数
    server.worker.thread.max=500
    # 调整日志批量写入大小
    store.db.max.batch.size=100
    
  3. 客户端参数优化

    seata:
      client:
        rm:
          report.retry.count: 5
          async.commit.buffer.limit: 10000
          lock:
            retry.internal: 10
            retry.times: 30
    

5. 从AT模式到其他模式的扩展思考

虽然AT模式满足了我们80%的业务场景,但在某些特殊情况下,我们也评估了其他模式:

5.1 模式对比决策矩阵

评估维度AT模式TCC模式SAGA模式XA模式
一致性弱一致最终一致最终一致强一致
性能高(≈本地事务)非常高
开发成本
数据库要求支持本地事务无特殊要求无特殊要求支持XA协议
典型场景订单创建库存预扣长流程业务银行转账

5.2 混合模式实践案例

我们在支付结算模块采用了AT+TCC的混合模式:

@GlobalTransactional
public void settlePayment(Long orderId) {
    // AT模式操作
    orderService.updateStatus(orderId, PAID);
    
    // TCC模式操作
    couponService.useCouponTry(orderId);
    pointService.deductPointsTry(orderId);
    
    // 其他业务操作
    accountingService.record(orderId);
}

// TCC确认阶段
@Transactional
public boolean useCouponConfirm(Long orderId) {
    // 实际扣减优惠券
}

// TCC取消阶段  
@Transactional
public boolean useCouponCancel(Long orderId) {
    // 返还优惠券
}

这种组合方式既保持了核心链路的事务性,又在非核心环节获得了更好的性能。实际落地过程中,我们建立了完善的事务日志系统,确保任何异常都能被及时发现和处理。

更多推荐