分布式事务实战:从Seata AT模式到本地消息表的落地选型与踩坑记录

分布式事务没有银弹只有权衡

微服务拆分后,一个业务操作跨多个数据库实例,本地事务管不了了。经典场景:电商下单——订单服务创建订单,库存服务扣减库存,账户服务扣减余额。三个操作必须要么全成功要么全回滚,但它们分属三个不同数据库。

CAP定理决定了分布式事务不可能同时满足一致性、可用性和分区容错性。工程上的选择都是取舍:

2PC/Seata AT:强一致,但有全局锁开销,性能损耗30%-50%
TCC:最终一致,但Try/Confirm/Cancel三段式编码量大
本地消息表 + MQ:最终一致,对业务侵入最小,但需处理幂等和补偿
Saga:长事务编排,适合调用外部第三方服务,正向+补偿

没有哪个方案在所有维度上最优。选型的核心依据是业务对一致性的容忍度开发团队对复杂度的承受力

Seata AT模式:快速落地但暗藏玄机

Seata AT模式对业务代码侵入最小——加一个@GlobalTransactional注解就能把本地事务纳入全局事务管理。原理是Seata在SQL执行前后自动拦截,生成前镜像(beforeImage)和后镜像(afterImage)写入undo_log表,全局回滚时用镜像反向补偿。

接入步骤

1. 部署Seata Server(TC),用Nacos做注册中心:

# application.yml - Seata Server配置
server:
  port: 8091

seata:
  registry:
    type: nacos
    nacos:
      server-addr: 127.0.0.1:8848
      namespace: seata
      group: DEFAULT_GROUP
  store:
    mode: db
    db:
      datasource: druid
      db-type: mysql
      url: jdbc:mysql://127.0.0.1:3306/seata?rewriteBatchedStatements=true
      user: seata
      password: seata_pwd

2. 每个微服务的数据源代理给Seata:

// Spring Boot配置
@Bean
@Primary
public DataSource dataSource(DataSourceProperties properties) {
    HikariDataSource original = properties
        .initializeDataSourceBuilder()
        .type(HikariDataSource.class)
        .build();
    // Seata代理数据源
    return new DataSourceProxy(original);
}

3. 每个业务数据库建undo_log表:

CREATE TABLE `undo_log` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `branch_id` bigint NOT NULL,
  `xid` varchar(100) NOT NULL,
  `context` varchar(128) NOT NULL,
  `rollback_info` longblob NOT NULL,
  `log_status` int 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;

4. 业务方法加注解:

@GlobalTransactional(timeoutMills = 60000, name = "create-order")
public OrderResult createOrder(OrderRequest request) {
    // 1. 创建订单
    orderMapper.insert(order);
    // 2. RPC调用库存服务扣减
    inventoryClient.deduct(request.getSkuId(), request.getQuantity());
    // 3. RPC调用账户服务扣减
    accountClient.debit(request.getUserId(), request.getAmount());
    return OrderResult.success(order.getId());
}

踩坑记录

坑1:全局锁超时。Seata AT模式在Phase 1就获取全局锁(写隔离),如果事务A锁住了某行,事务B尝试修改同一行会等待直到全局锁超时(默认30秒),然后抛出GlobalLockConflictException。高并发写场景下这个超时频繁触发。

解法:分析业务,确认哪些操作不需要强一致隔离。比如”查询用户积分后扣减”可以用SELECT FOR UPDATE在本地事务中保证原子性,不需要Seata全局锁。只有真正跨服务写多表的操作才走Seata。

坑2:undo_log清理。Seata在全局事务提交后不会自动清理undo_log,需要定时任务清理。否则undo_log表会无限增长。

// 清理7天前的undo_log
@Scheduled(cron = "0 0 3 * * ?")
public void cleanUndoLog() {
    undoLogMapper.deleteByLogCreatedBefore(
        LocalDateTime.now().minusDays(7)
    );
}

坑3:不支持复杂SQL。Seata AT模式对SQL的拦截和镜像生成有白名单,不支持子查询更新、多表关联更新、存储过程调用等。遇到这些SQL只能绕行或换TCC模式。

本地消息表:最朴素但最可靠

如果对强一致要求没那么高——允许几秒到几分钟的最终一致,本地消息表是工程实践中最可靠的方案。核心思路:把”业务操作”和”发送消息”放在同一个本地事务中,消息存本地表而不是直接发MQ,再由异步线程把消息投递到MQ。

// 1. 业务事务中写消息表
@Transactional
public void createOrder(OrderRequest request) {
    // 业务操作
    Order order = new Order(request);
    orderMapper.insert(order);
    
    // 写本地消息表——和业务操作在同一事务
    OutboxMessage msg = new OutboxMessage();
    msg.setTopic("order-created");
    msg.setKey(order.getId().toString());
    msg.setBody(JSON.toJSONString(new OrderCreatedEvent(order)));
    msg.setStatus(0); // 待发送
    msg.setRetryCount(0);
    msg.setCreatedAt(LocalDateTime.now());
    outboxMapper.insert(msg);
}
// 2. 定时任务扫描消息表并发送
@Scheduled(fixedDelay = 1000) // 每秒扫描
public void sendOutboxMessages() {
    List<OutboxMessage> messages = outboxMapper.selectPending(100);
    for (OutboxMessage msg : messages) {
        try {
            rocketMQTemplate.convertAndSend(msg.getTopic(), msg.getBody());
            outboxMapper.updateStatus(msg.getId(), 1); // 已发送
        } catch (Exception e) {
            outboxMapper.incrementRetry(msg.getId());
            if (msg.getRetryCount() >= 5) {
                outboxMapper.updateStatus(msg.getId(), 2); // 失败
                // 告警通知人工处理
                alertService.notify("消息发送失败", msg.getId());
            }
        }
    }
}

消费端幂等:消息可能被重复投递,消费端必须幂等。推荐方案是在业务表中增加message_id字段并设唯一索引:

CREATE TABLE `account_change_log` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `message_id` varchar(64) NOT NULL,
  `user_id` bigint NOT NULL,
  `amount` decimal(10,2) NOT NULL,
  `change_type` tinyint NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_message_id` (`message_id`)
) ENGINE=InnoDB;

// 消费逻辑
@Transactional
public void handleOrderCreated(OrderCreatedEvent event) {
    // INSERT时如果message_id冲突则忽略
    accountChangeLogMapper.insertIgnore(event);
    // 幂等扣减
    accountMapper.deduct(event.getUserId(), event.getAmount());
}

方案选型决策树

是否要求强一致(同一时刻所有服务数据一致)?
├── 是 → 调用链是否全在内部服务?
│   ├── 是 → Seata AT模式
│   └── 否(含外部第三方API) → TCC模式
└── 否 → 是否允许秒级延迟?
    ├── 是 → 本地消息表 + MQ
    └── 否(要求毫秒级) → Seata AT + 本地消息表双保险

分布式事务方案没有完美解,只有”在业务可接受的延迟范围内,用最小的开发和运维成本保证数据最终一致”。Seata AT模式开发成本低但运行时开销大,本地消息表开发成本低且运行时开销小但一致性延迟大。根据业务SLA选型,不要为了”强一致”而强一致。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/fen-bu-shi-shi-wu-shi-zhan-cong-seataat-mo-shi-dao-ben-di/

(0)
小编小编
上一篇 17小时前
下一篇 17小时前

相关推荐