分布式事务实战:Seata AT/TCC与RocketMQ最终一致性方案对比选型

分布式事务的典型问题场景

微服务架构中,一个业务操作往往跨多个服务:下单时需要同时扣库存、扣余额、生成订单记录。如果扣库存成功但扣余额失败,库存数据就处于不一致状态。这类问题在单体应用中用本地事务一行@Transactional就能解决,拆成微服务后变成了分布式事务问题——多个服务各自维护独立数据库,本地事务无法跨服务生效。

解决分布式事务有两套主流思路:强一致性(Seata AT/TCC模式)和最终一致性(消息中间件驱动)。强一致性保证所有参与方同时提交或回滚,代价是锁定资源和性能损耗;最终一致性允许中间状态存在,通过消息重试保证最终所有参与方达成一致,代价是业务方需要实现补偿逻辑。

Seata AT模式:低侵入的自动分布式事务

Seata AT模式的核心是自动拦截SQL、记录前后镜像、在事务协调器控制下统一提交或回滚。对业务代码的侵入最小——加一个@GlobalTransactional注解即可。

AT模式的工作流程分三个阶段:

  1. 一阶段:拦截业务SQL,执行前查询数据生成before-image,执行SQL后查询数据生成after-image,生成回滚日志(undo_log)一并提交到本地事务
  2. 二阶段提交:异步清理undo_log
  3. 二阶段回滚:根据undo_log中的before-image反向补偿,恢复原始数据
// Spring Boot项目中使用Seata AT模式
// 1. 添加依赖(pom.xml)
// <dependency>
//   <groupId>io.seata</groupId>
//   <artifactId>seata-spring-boot-starter</artifactId>
//   <version>2.2.0</version>
// </dependency>

// 2. 业务代码加注解
@Service
public class OrderService {

    @GlobalTransactional(timeoutMills = 60000, name = "create-order")
    public Order createOrder(OrderDTO dto) {
        // 1. 创建订单(本地事务)
        Order order = orderMapper.insert(dto);

        // 2. 扣库存(远程调用库存服务)
        inventoryClient.deduct(dto.getProductId(), dto.getQuantity());

        // 3. 扣余额(远程调用账户服务)
        accountClient.debit(dto.getUserId(), dto.getAmount());

        return order;
    }
}

// 3. 每个参与方的数据库创建undo_log表
// CREATE TABLE undo_log (
//   id BIGINT PRIMARY KEY AUTO_INCREMENT,
//   branch_id VARCHAR(64) NOT NULL,
//   xid VARCHAR(64) 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,
//   UNIQUE KEY ux_undo_log (xid, branch_id)
// );

AT模式的局限:只支持关系型数据库,不支持NoSQL;全局锁可能导致死锁(两个全局事务交叉锁定对方需要的行);性能开销在10-30%之间(SQL拦截和镜像记录)。

Seata TCC模式:手动补偿的精确控制

TCC(Try-Confirm-Cancel)模式要求业务方实现三个方法:Try预留资源、Confirm确认提交、Cancel释放预留。相比AT模式的自动拦截,TCC需要手写补偿逻辑,但控制粒度更细,不依赖SQL拦截。

// TCC模式实现扣库存
@LocalTCC
public interface InventoryTccService {

    @TwoPhaseBusinessAction(
        name = "deductInventory",
        commitMethod = "confirm",
        rollbackMethod = "cancel"
    )
    boolean tryDeduct(
        @BusinessActionContextParameter(paramName = "productId") Long productId,
        @BusinessActionContextParameter(paramName = "quantity") Integer quantity
    );

    boolean confirm(BusinessActionContext context);

    boolean cancel(BusinessActionContext context);
}

@Service
public class InventoryTccServiceImpl implements InventoryTccService {

    @Override
    public boolean tryDeduct(Long productId, Integer quantity) {
        // Try阶段:冻结库存,不实际扣减
        return inventoryMapper.freezeStock(productId, quantity) > 0;
    }

    @Override
    public boolean confirm(BusinessActionContext context) {
        // Confirm阶段:扣减冻结库存
        Long productId = (Long) context.getActionContext("productId");
        Integer quantity = (Integer) context.getActionContext("quantity");
        return inventoryMapper.deductFrozenStock(productId, quantity) > 0;
    }

    @Override
    public boolean cancel(BusinessActionContext context) {
        // Cancel阶段:释放冻结库存
        Long productId = (Long) context.getActionContext("productId");
        Integer quantity = (Integer) context.getActionContext("quantity");
        return inventoryMapper.releaseFrozenStock(productId, quantity) > 0;
    }
}

TCC模式的冻结库存表设计:

CREATE TABLE inventory (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_id BIGINT NOT NULL,
    available_qty INT NOT NULL DEFAULT 0,
    frozen_qty INT NOT NULL DEFAULT 0,
    INDEX idx_product (product_id)
);

-- Try: UPDATE inventory SET frozen_qty = frozen_qty + ? WHERE product_id = ? AND available_qty - frozen_qty >= ?
-- Confirm: UPDATE inventory SET available_qty = available_qty - ?, frozen_qty = frozen_qty - ? WHERE product_id = ?
-- Cancel: UPDATE inventory SET frozen_qty = frozen_qty - ? WHERE product_id = ?

RocketMQ事务消息:最终一致性方案

当业务可以容忍短暂的不一致状态时,用消息中间件实现最终一致性比Seata轻量得多。RocketMQ原生支持事务消息,保证本地事务和消息发送的原子性。

以创建订单并发送扣库存消息为例:

// 1. 订单服务:发送事务消息
@Service
public class OrderMessageService {

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    public void createOrderAndNotify(OrderDTO dto) {
        rocketMQTemplate.sendMessageInTransaction(
            "order-create-topic",
            MessageBuilder.withPayload(dto).build(),
            dto
        );
    }

    // 本地事务执行器
    @RocketMQTransactionListener
    public class OrderTransactionListener implements RocketMQLocalTransactionListener {

        @Autowired
        private OrderService orderService;

        @Override
        public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
            try {
                OrderDTO dto = (OrderDTO) arg;
                orderService.createOrder(dto);
                return RocketMQLocalTransactionState.COMMIT;
            } catch (Exception e) {
                return RocketMQLocalTransactionState.ROLLBACK;
            }
        }

        @Override
        public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
            String orderId = msg.getHeaders().get("orderId", String.class);
            Order order = orderService.getById(orderId);
            return order != null ? RocketMQLocalTransactionState.COMMIT
                                 : RocketMQLocalTransactionState.ROLLBACK;
        }
    }
}

// 2. 库存服务:消费消息扣库存
@Component
@RocketMQMessageListener(topic = "order-create-topic", consumerGroup = "inventory-consumer-group")
public class InventoryConsumer implements RocketMQListener<OrderDTO> {

    @Autowired
    private InventoryService inventoryService;

    @Override
    public void onMessage(OrderDTO dto) {
        try {
            inventoryService.deduct(dto.getProductId(), dto.getQuantity());
        } catch (Exception e) {
            throw new RuntimeException("扣库存失败,等待重试", e);
        }
    }
}

消息幂等性:避免重复消费的关键

RocketMQ保证消息至少投递一次(at-least-once),消费端可能收到重复消息。必须实现幂等性:同一消息消费多次和消费一次的效果相同。常用方案是消费记录表:

// 消费幂等表
CREATE TABLE consume_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    msg_id VARCHAR(64) NOT NULL UNIQUE,
    status TINYINT NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_msg_id (msg_id)
);

// 幂等消费逻辑
@Override
public void onMessage(OrderDTO dto) {
    String msgId = dto.getMsgId();

    ConsumeRecord record = consumeRecordMapper.selectByMsgId(msgId);
    if (record != null && record.getStatus() == 1) {
        log.info("消息已处理,跳过: {}", msgId);
        return;
    }

    if (record == null) {
        consumeRecordMapper.insert(msgId, 0);
    }

    try {
        inventoryService.deduct(dto.getProductId(), dto.getQuantity());
        consumeRecordMapper.updateStatus(msgId, 1);
    } catch (Exception e) {
        consumeRecordMapper.updateStatus(msgId, 2);
        throw e;
    }
}

API接口规范与服务治理集成

分布式事务方案的选型直接影响API接口规范设计。强一致性方案(Seata)适合同步调用的API,调用方等待事务最终结果;最终一致性方案(RocketMQ)适合异步API,调用方只关心本地事务是否成功,下游操作通过消息驱动。

API设计原则:

  • 使用Seata的接口返回明确的成功/失败状态码,超时需要调用方主动查询
  • 使用消息驱动的接口返回本地事务结果,下游操作通过回调或状态查询获取
  • 关键操作提供查询接口:GET /api/orders/{id}/status

服务治理方面,Seata Server需要高可用部署(至少3节点集群),注册到Nacos实现服务发现。RocketMQ集群同样至少3个Broker,2主2从保证消息不丢失。

方案选型决策树

分布式事务选型决策流程:

是否需要强一致性?
├── 是 → 数据是否全部在关系型数据库?
│   ├── 是 → Seata AT模式(低侵入,自动补偿)
│   └── 否 → Seata TCC模式(手动补偿,支持非SQL资源)
└── 否 → 是否可接受短暂不一致?
    ├── 是 → RocketMQ事务消息(最终一致性)
    └── 否 → 重新评估业务需求

注意事项:
- AT模式单事务建议不超过3个参与方,参与方越多锁冲突概率越大
- TCC模式需要业务方保证Try/Confirm/Cancel的幂等性
- 消息模式需要消费端实现幂等,消费失败依赖重试机制
- 混合方案:核心链路用Seata,非核心链路用消息,降低锁冲突范围

微服务架构下没有完美的分布式事务方案,只有适合当前业务阶段的方案。选择的核心依据是业务对一致性的容忍度和系统的吞吐量需求。低频高价值操作用Seata保强一致,高频低价值操作用消息保最终一致,两条路径组合使用是生产环境最务实的做法。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/fen-bu-shi-shi-wu-shi-zhan-seataattcc-yu-rocketmq-zui-zhong/

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

相关推荐