之前在开发一个电商支付系统时,我们团队遇到了一个棘手的问题:用户下单后,需要同时完成订单创建和库存扣减两个操作,这两个操作分别在不同的微服务中。如果不保证这两个操作的原子性,就可能出现订单创建了但库存没扣减,或者库存扣减了但订单创建失败的情况。

这不就是典型的分布式事务问题吗?于是我们选择了 RocketMQ 的事务消息来解决。但使用过程中发现,RocketMQ 的事务消息虽然强大,却也有不少让人头疼的地方。今天就和大家分享一下 RocketMQ 事务消息的那些坑,以及我们探索过的其他解决方案。

RocketMQ 事务消息原理简介

在讨论缺点前,先快速过一遍 RocketMQ 事务消息的工作原理。

sequenceDiagram
    participant A as 业务系统
    participant B as RocketMQ Broker
    participant C as 消费者

    A->>B: 1. 发送半消息(Half Message)
    B-->>A: 返回发送结果
    A->>A: 2. 执行本地事务

    alt 本地事务成功
        A->>B: 3a. 发送commit消息
        B->>C: 4a. 投递消息给消费者
    else 本地事务失败
        A->>B: 3b. 发送rollback消息
        B->>B: 4b. 丢弃半消息
    else 网络故障等问题导致未收到确认
        B->>A: 5. 事务状态回查
        A-->>B: 返回事务状态
    end

RocketMQ 事务消息的核心流程是:

  1. 发送"半消息"到 MQ 服务器,这条消息对消费者不可见(半消息:暂不对消费者可见的消息,用于标记事务中间状态)
  2. 执行本地事务
  3. 根据本地事务结果,提交或回滚半消息
  4. 如果因为网络等原因,MQ 没收到提交或回滚指令,会定期回查事务状态

听起来很完美,但实际使用中却暴露出了一系列问题...

RocketMQ 事务消息的 5 大缺点

1. 事务回查机制存在不确定性

我们在一个订单系统中使用 RocketMQ 事务消息时,发现了一个让人意外的问题:当网络波动导致提交事务确认消息失败时,RocketMQ 会启动事务回查机制,但这个回查间隔和次数是有限制的。

根据 RocketMQ 源码(TransactionConfig 配置类),事务回查是以指数方式增长的:默认从 10 秒开始,之后每次间隔翻倍,如 10s→20s→40s→80s,直到达到最大回查次数(默认 15 次)。这一机制设计在大多数场景下工作良好,但在某些长事务场景中会带来问题。

gantt
    title RocketMQ事务回查时间线(指数增长)
    dateFormat mm:ss
    axisFormat %M:%S

    首次回查      :milestone, m1, 00:10, 0s
    回查间隔期    :done, a1, after m1, 20s
    第二次回查    :milestone, m2, after a1, 0s
    回查间隔期    :done, a2, after m2, 40s
    第三次回查    :milestone, m3, after a2, 0s
    回查间隔期    :done, a3, after m3, 80s
    第四次回查    :milestone, m4, after a3, 0s
    回查间隔期    :done, a4, after m4, 160s
    第五次回查    :milestone, m5, after a4, 0s

我们曾遇到一个场景:某个订单处理过程中,因为数据库临时负载过高,事务处理时间超过了预期,而此时 RocketMQ 的回查次数已耗尽,导致系统误判事务失败并回滚了消息,最终造成了数据不一致。

缓解方案:可以通过配置transactionCheckInterval(自定义回查间隔)和transactionCheckMax(最大回查次数)来适配长事务场景。对于可能执行时间较长的事务,建议适当增加回查次数并调整回查间隔。

2. 性能瓶颈明显

当系统并发量上升时,RocketMQ 的事务消息性能下降明显。我们的测试数据显示:

在普通消息模式下,单 broker 可以轻松处理 10000TPS 的吞吐量,但切换到事务消息后,相同硬件条件下只能达到约 2000-3000TPS。具体性能对比数据如下:

指标普通消息事务消息差距
平均响应时间(RT)约 2ms约 5-8ms增加 3-6ms
单 broker 最大 TPS10000+2000-3000下降约 70%
CPU 使用率(峰值)40%70%增加 30%
磁盘 I/O中等增加约 50%

这种性能下降是因为每个事务消息都需要额外的半消息存储、状态管理和可能的回查操作。

半消息虽然存储在 CommitLog 中,与普通消息使用相同的存储机制,但它们增加了额外的 I/O 开销:

  • 半消息需要持久化到磁盘,增加写入负担
  • Broker 需要定期扫描未决事务消息,增加读取压力
  • 半消息状态变更(提交/回滚)需要额外的操作记录

我们曾在双十一活动期间遇到过一次严重的性能问题:系统订单量突增,RocketMQ 的事务消息处理能力成为了整个系统的瓶颈,导致订单处理出现明显延迟。

缓解方案:针对性能问题,可以采取以下措施:

  • 增加 RocketMQ 集群节点数量,分散事务消息处理压力
  • 对非核心业务流程,考虑使用普通消息+本地消息表的方式,降低中间件负担
  • 合理规划消息发送批次,避免瞬时高峰

3. 事务状态存储机制的局限性

RocketMQ 将事务状态保存在 CommitLog 中,通过持久化确保状态不丢失。然而,在特定场景下仍可能发生数据不一致:当使用异步复制模式时,如果主节点宕机且未将事务状态同步到从节点,状态信息可能丢失。普通的 Broker 重启不会导致事务状态丢失,因为状态已持久化到磁盘。

flowchart TB
    A[本地事务执行] --> B{事务完成?}
    B -->|是| C[发送Commit确认]
    B -->|否| D[发送Rollback确认]

    C --> E{确认送达?}
    D --> E

    E -->|是| F[事务正常完成]
    E -->|否| G[触发事务回查]

    G --> H{主节点宕机且<br>未同步到从节点?}
    H -->|是| I[事务状态可能丢失]
    H -->|否| J[正常回查]

在一次系统故障中,我们经历了主从切换导致部分事务状态丢失的问题,最终不得不进行了长达 4 小时的数据修复工作。

缓解方案:对于关键业务,建议使用同步复制模式(SYNC_MASTER)确保事务状态在多个节点间的一致性。同时,可以实现额外的事务状态持久化机制,如在业务数据库中记录消息发送状态。

故障处理指南:当遇到事务状态异常时,可通过 RocketMQ 提供的管理工具进行排查:

# 查看指定消息的事务状态
sh mqadmin -n 127.0.0.1:9876 checkTransactionState -g {生产者组名} -i {事务消息ID}

# 手动触发事务回查
sh mqadmin -n 127.0.0.1:9876 updateTransactionState -g {生产者组名} -i {事务消息ID} -s {状态:0提交/1回滚}

通过这些命令可以检查和纠正事务状态,避免长时间的人工数据修复。

4. 开发和调试复杂度高

使用 RocketMQ 事务消息需要实现 TransactionListener 接口,包括 executeLocalTransaction 和 checkLocalTransaction 两个方法。开发人员需要确保这两个方法的逻辑一致性,这增加了代码复杂度。

例如,我们曾有一个新团队成员在实现事务监听器时,checkLocalTransaction 方法的逻辑与 executeLocalTransaction 不一致,导致在网络波动时系统出现了数据不一致的问题。排查这类问题非常困难,因为它们往往只在特定条件下才会出现。

public class OrderTransactionListener implements TransactionListener {
    private static final Logger log = LoggerFactory.getLogger(OrderTransactionListener.class);

    @Override
    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        try {
            // 执行本地事务
            OrderService orderService = (OrderService)arg;
            String orderId = msg.getKeys();
            // 创建订单
            boolean result = orderService.createOrder(orderId);

            if (result) {
                // 成功后更新订单状态为已确认
                orderService.updateOrderStatus(orderId, OrderStatus.CONFIRMED);
                log.info("订单创建成功,orderId={}", orderId);
                return LocalTransactionState.COMMIT_MESSAGE;
            } else {
                log.warn("订单创建失败,orderId={}", orderId);
                return LocalTransactionState.ROLLBACK_MESSAGE;
            }
        } catch (Exception e) {
            log.error("Execute local transaction failed, msgId={}", msg.getTransactionId(), e);
            return LocalTransactionState.ROLLBACK_MESSAGE;
        }
    }

    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt msg) {
        // 回查本地事务状态
        String orderId = msg.getKeys();
        try {
            // 查询订单状态而非简单检查订单是否存在
            OrderStatus status = orderService.getOrderStatus(orderId);

            // 记录回查次数,便于排查异常情况
            int checkTimes = getCheckTimes(msg);
            log.info("事务回查,orderId={}, 当前状态={}, 回查次数={}", orderId, status, checkTimes);

            if (status == OrderStatus.CONFIRMED) {
                return LocalTransactionState.COMMIT_MESSAGE;
            } else if (status == OrderStatus.FAILED) {
                return LocalTransactionState.ROLLBACK_MESSAGE;
            } else {
                // 订单创建中或状态不明确,继续回查
                // 如果回查次数过多,可考虑特殊处理,避免无限回查
                if (checkTimes > 10) {
                    log.warn("回查次数过多,orderId={},强制回滚", orderId);
                    return LocalTransactionState.ROLLBACK_MESSAGE;
                }
                return LocalTransactionState.UNKNOW;
            }
        } catch (Exception e) {
            log.error("Check transaction status failed, msgId={}, orderId={}",
                     msg.getTransactionId(), orderId, e);
            // 注意:这里返回UNKNOW将导致继续回查,直到回查次数耗尽
            return LocalTransactionState.UNKNOW;
        }
    }

    // 从消息属性中获取回查次数
    private int getCheckTimes(MessageExt msg) {
        String checkTimesStr = msg.getProperty("CHECK_TIMES");
        int checkTimes = 0;
        if (checkTimesStr != null) {
            checkTimes = Integer.parseInt(checkTimesStr);
        }
        return checkTimes;
    }
}

缓解方案:建议在数据库设计时增加事务状态表,明确记录每个业务操作的事务状态,而不是依赖于对象是否存在的简单判断。同时,可以开发统一的事务管理框架,减少重复编码,确保 executeLocalTransaction 和 checkLocalTransaction 的一致性。

5. 和业务代码耦合紧密

RocketMQ 事务消息要求将业务逻辑和消息发送逻辑紧密结合,这导致业务代码和中间件代码高度耦合。当我们需要更换消息中间件或调整业务逻辑时,改动范围会非常大。

缓解方案:可以通过引入事务抽象层,将业务逻辑与消息发送逻辑解耦。例如,定义统一的事务接口,通过适配器模式适配不同的消息中间件,使业务代码只需关注业务流程,不直接依赖特定的消息中间件实现。

实际案例:订单-库存系统事务问题及解决方案

我们的电商系统需要在用户下单时同时处理订单创建和库存扣减,这两个操作分别在不同的微服务中。起初我们使用 RocketMQ 事务消息来确保这两个操作的一致性:

sequenceDiagram
    participant 用户
    participant 订单服务
    participant RocketMQ
    participant 库存服务

    用户->>订单服务: 下单请求
    订单服务->>RocketMQ: 发送半消息(扣减库存)
    RocketMQ-->>订单服务: 半消息发送成功

    订单服务->>订单服务: 创建订单(本地事务)

    alt 订单创建成功
        订单服务->>RocketMQ: Commit消息
        RocketMQ->>库存服务: 投递扣减库存消息
        库存服务->>库存服务: 扣减库存

        alt 库存扣减成功
            库存服务-->>订单服务: 库存扣减成功
        else 库存扣减失败(如库存不足)
            库存服务->>RocketMQ: 发送订单补偿消息(带订单ID,保证幂等)
            RocketMQ->>订单服务: 投递订单补偿消息
            订单服务->>订单服务: 将订单标记为无效(检查订单ID避免重复补偿)
        end

    else 订单创建失败
        订单服务->>RocketMQ: Rollback消息
        RocketMQ->>RocketMQ: 丢弃半消息
    end

    订单服务-->>用户: 下单结果

问题展现

在使用过程中,我们遇到了以下问题:

  1. 高并发场景下,RocketMQ 事务消息处理能力成为瓶颈
  2. 事务回查机制在网络不稳定时可能导致误判
  3. 订单服务和消息中间件逻辑高度耦合,难以维护
  4. 当需要扩展流程(如增加积分服务)时,改动范围大
  5. 库存扣减失败时的补偿流程复杂,需要额外的消息处理逻辑

解决方案

经过研究,我们尝试了以下解决方案:

  1. 优化 RocketMQ 配置:增加 broker 数量,调整事务回查参数
  2. 在数据库中增加明确的事务状态字段,确保回查准确性
  3. 最终,我们决定用 Seata 的 AT 模式替代 RocketMQ 事务消息,效果更好(详见下一节)

其他事务消息实现方案对比

1. Kafka 事务消息

Kafka 自 0.11 版本开始支持事务,允许原子性地向多个分区写入消息。

sequenceDiagram
    participant 生产者
    participant Kafka
    participant 消费者

    生产者->>Kafka: 初始化事务(initTransactions)
    生产者->>Kafka: 开始事务(beginTransaction)
    生产者->>Kafka: 发送消息1(send)
    生产者->>Kafka: 发送消息2(send)

    alt 事务成功
        生产者->>Kafka: 提交事务(commitTransaction)
        Kafka->>消费者: 消息变为可见
    else 事务失败
        生产者->>Kafka: 中止事务(abortTransaction)
        Kafka->>Kafka: 消息被标记为已中止
    end

优点

  • 支持向多个 Topic/Partition 原子性写入
  • 性能相对较高
  • 配置简单,使用方便
  • 结合幂等性 Producer 功能(幂等性:同一操作执行多次和执行一次效果相同),可保证单分区内消息不重复,提升生产者端可靠性

缺点

  • 仅保证消息生产的原子性,无法关联本地事务(如数据库操作)
  • 不支持与外部系统的事务集成,无法实现"先执行本地事务,再根据结果提交消息"的场景
  • 恢复机制不如 RocketMQ 完善,缺少事务状态回查能力

2. RabbitMQ 事务机制

RabbitMQ 提供了两种事务机制:传统的 AMQP 事务和轻量级的 Publisher Confirmation。

sequenceDiagram
    participant 生产者
    participant RabbitMQ
    participant 消费者

    alt AMQP事务模式
        生产者->>RabbitMQ: 开启事务(txSelect)
        生产者->>RabbitMQ: 发送消息(basicPublish)

        alt 提交事务
            生产者->>RabbitMQ: 提交事务(txCommit)
            RabbitMQ->>消费者: 消息可消费
        else 回滚事务
            生产者->>RabbitMQ: 回滚事务(txRollback)
            RabbitMQ->>RabbitMQ: 消息丢弃
        end
    else Publisher Confirmation模式
        生产者->>RabbitMQ: 开启确认模式(confirmSelect)
        生产者->>RabbitMQ: 发送消息1(basicPublish)
        生产者->>RabbitMQ: 发送消息2(basicPublish)
        RabbitMQ-->>生产者: 确认消息(ack/nack)
        Note over 生产者,RabbitMQ: 需处理部分消息确认失败的情况
    end

优点

  • 配置简单,易于使用
  • Publisher Confirmation 机制性能较高
  • 与 Spring AMQP 集成良好

缺点

  • AMQP 事务模式性能较差
  • 不支持分布式事务
  • 无回查机制,可靠性较低
  • Publisher Confirmation 是异步确认机制,使用批量发送时需额外处理部分消息失败的情况(如记录未确认消息 ID,通过死信队列重试)

3. Seata 分布式事务框架

Seata 提供了多种分布式事务模式,包括 AT、TCC、Saga 和 XA 模式。对于消息事务一致性问题,我们主要关注 AT 模式和 Saga 模式。

flowchart TB
    A[应用] --> B[Seata Client]
    B --> C[Seata Server]

    subgraph AT模式
    D[Seata解析SQL自动生成undo_log] --> E[两阶段提交]
    E -->|一阶段| F[本地事务提交]
    E -->|二阶段成功| G[删除undo_log]
    E -->|二阶段失败| H[根据undo_log回滚]
    end

    subgraph Saga模式
    I[正向服务] --> J{成功?}
    J -->|是| K[完成]
    J -->|否| L[调用补偿服务]
    L --> M[状态机记录执行状态]
    end

    C --> AT模式
    C --> Saga模式

优点

  • 全面的分布式事务解决方案
  • 多种事务模式可选
  • 与业务解耦程度高
  • 性能相对较好

缺点

  • 部署复杂度高,需要额外维护 Seata 服务器
  • 学习曲线陡峭
  • AT 模式对数据库有特定要求:必须支持 ACID、表必须有主键(用于生成反向 SQL)
  • AT 模式生成的反向 SQL 可能扩大行锁范围(如更新所有字段),导致长事务场景下的并发性能下降

故障排查指南:当 Seata 事务出现异常时,可通过以下方式排查:

# 查看全局事务状态
curl -X GET 'http://seata-server-ip:8091/api/v1/transaction/globalTransaction?pageNum=1&pageSize=10&xid=xxx'

# 查看具体的事务日志
cat ${SEATA_HOME}/logs/seata-server.log | grep '全局事务ID'

# 查看数据库undo_log表检查回滚记录
SELECT * FROM undo_log WHERE xid='xxx';

这些命令可以帮助开发人员快速定位事务异常的根本原因。

本地消息表方案示例

对于非核心业务场景,我们采用了本地消息表方案,这种方案的实现相对简单:

// 本地消息表设计
@Entity
@Table(name = "local_message")
public class LocalMessage {
    @Id
    private String messageId;  // 消息ID,唯一标识
    private String topic;      // 消息主题
    private String body;       // 消息内容
    private Integer status;    // 状态:0-待发送,1-已发送,2-发送失败
    private Integer retryCount; // 重试次数
    private Date createTime;   // 创建时间
    private Date updateTime;   // 更新时间
    // getters and setters
}

// 业务服务中使用本地消息表
@Service
@Transactional
public class OrderServiceImpl implements OrderService {

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private LocalMessageRepository messageRepository;

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    public void createOrder(Order order) {
        // 1. 开启本地事务
        try {
            // 2. 创建订单
            orderRepository.save(order);

            // 3. 创建本地消息
            LocalMessage message = new LocalMessage();
            message.setMessageId(UUID.randomUUID().toString());
            message.setTopic("order-topic");
            message.setBody(JSON.toJSONString(order));
            message.setStatus(0); // 待发送
            message.setRetryCount(0);
            message.setCreateTime(new Date());
            message.setUpdateTime(new Date());

            messageRepository.save(message);
            // 事务提交后,消息和订单都会保存
        } catch (Exception e) {
            // 本地事务回滚,订单和消息都不会保存
            throw new RuntimeException("创建订单失败", e);
        }
    }
}

// 定时任务扫描未发送的消息
@Component
public class MessageSender {

    @Autowired
    private LocalMessageRepository messageRepository;

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    @Scheduled(fixedRate = 10000) // 每10秒执行一次
    public void sendMessage() {
        List<LocalMessage> messages = messageRepository.findByStatusAndRetryCountLessThan(0, 5);
        for (LocalMessage message : messages) {
            try {
                // 发送消息
                rocketMQTemplate.convertAndSend(message.getTopic(), message.getBody());

                // 更新消息状态
                message.setStatus(1); // 已发送
                message.setUpdateTime(new Date());
                messageRepository.save(message);
            } catch (Exception e) {
                // 发送失败,增加重试次数
                message.setRetryCount(message.getRetryCount() + 1);
                message.setUpdateTime(new Date());

                if (message.getRetryCount() >= 5) {
                    message.setStatus(2); // 发送失败
                }

                messageRepository.save(message);
                log.error("消息发送失败, messageId=" + message.getMessageId(), e);
            }
        }
    }
}

本地消息表方案的核心思想是将消息发送与本地事务绑定,确保消息不会丢失。虽然不能保证强一致性,但可以实现最终一致性(最终一致性:系统在经过一段时间后最终达到数据一致状态),适合非核心业务场景。

实战选择:我们的最终方案

经过对比和实测,我们最终在系统中采用了不同场景不同方案的组合:

  1. 对于订单-库存-支付等关键业务流程:使用 Seata 的 AT 模式,保证数据一致性的同时,与业务代码解耦
  2. 对于非核心业务如通知、日志等:使用普通的 RocketMQ 消息+本地消息表方案,简化开发的同时保证最终一致性
  3. 对于特殊高性能场景:使用 Kafka 事务消息,满足高吞吐量需求

我们选择 Seata AT 模式的考虑因素包括:

  • 现有微服务架构已使用 Spring Cloud,与 Seata 集成较为简便
  • 主要业务数据库均为 MySQL,符合 AT 模式的基础要求
  • 技术团队对 Java 生态系统较为熟悉,Seata 作为国产框架文档和社区支持较好
  • 相比事务消息方案,Seata 提供更完整的分布式事务保障
  • 我们的大部分事务场景为短事务,AT 模式的锁扩大影响可控

对于长事务或跨异构系统的场景(如涉及 MySQL 和 Redis 的复合操作),我们选择了 Seata 的 Saga 模式(柔性事务:通过补偿机制而非锁机制实现一致性的事务类型),通过状态机定义服务编排和补偿逻辑,确保数据最终一致性。

在实施过程中,我们还总结了一些经验:

  1. 事务消息适用于强一致性要求较低的场景,而对于核心业务流程可考虑使用更严格的分布式事务方案
  2. 对核心业务采用更严格的事务保证,非核心业务可适当放宽要求
  3. 在设计阶段就考虑分布式事务问题,而不是等系统上线后再临时解决

总结

让我们用表格形式总结一下各种事务消息方案的对比:

特性/方案RocketMQ 事务消息Kafka 事务消息RabbitMQ 事务Seata AT 模式Seata Saga 模式本地消息表
性能中等低(AMQP 事务)/中(确认模式)中等
可靠性
开发复杂度
维护成本
与本地事务集成支持不支持不支持支持支持支持
回查机制无(定时任务)
补偿机制依赖回查自动生成反向 SQL手动定义补偿接口应用层重试
最终一致性是(仅消息)是(两阶段)是(柔性事务)
适用场景一般分布式事务高性能消息事务简单消息事务短事务/同构系统长事务/异构系统/需人工干预非核心业务
并发性能受半消息影响受全局锁影响

感谢您耐心阅读到这里!如果觉得本文对您有帮助,欢迎点赞 👍、收藏 ⭐、分享给需要的朋友,您的支持是我持续输出技术干货的最大动力!

如果想获取更多技术深度解析,欢迎点击头像关注我,后续会每日更新高质量技术文章,陪您一起进阶成长~


异常君
1 声望1 粉丝

在 Java 的世界里,永远有下一座技术高峰等着你。我愿做你登山路上的同频伙伴,陪你从看懂代码到写出让自己骄傲的代码。咱们,代码里见!