RocketMQ 事务消息和 RabbitMQ 确认机制,在拆微服务后保证数据一致性上,各自适合什么场景?

先说结论:RocketMQ 事务消息适合“先发消息后落库”的强一致性场景,RabbitMQ 的确认机制更适合“先落库后发消息”的最终一致性兜底方案。 这两套机制解决的问题维度不同,RocketMQ 解决的是消息与本地事务的原子性,RabbitMQ 解决的是消息投递的可靠性。选型的关键在于你的业务到底是先改数据库还是先发消息。

我参与过两次大规模微服务拆分,第一次用 RabbitMQ,第二次上了 RocketMQ,踩了不少坑。这两个消息中间件在数据一致性上的表现差异,远比官方文档描述的复杂。

RocketMQ 事务消息:解决的是“发消息”和“本地事务”的原子性

RocketMQ 的事务消息机制,本质上是一个两阶段提交的变体。它要解决的问题非常具体:当一个服务既要修改数据库,又要发送消息通知下游时,如何保证这两个操作要么同时成功,要么同时失败。

它的执行流程分三步:

  1. 发送半消息:生产者先向 Broker 发送一条“半消息”,这条消息此时对消费者不可见;
  2. 执行本地事务:生产者拿到半消息的发送结果后,执行自己的本地事务(比如订单入库、扣减库存);
  3. 提交或回滚:根据本地事务的执行结果,向 Broker 发送 Commit 或 Rollback。Commit 后半消息变为可见,消费者可以消费;Rollback 则 Broker 会删除这条消息。

这里有一个容易被忽略的细节:Broker 会定期回查未决的半消息。如果你的服务在本地事务执行完后、提交状态前挂掉了,Broker 会回调你实现的 checkLocalTransaction 接口,根据业务状态反查这次事务到底该不该提交。这个回查机制是整个方案的兜底保障,但也是代码写得最恶心的地方——你得在回查逻辑里再查一次数据库,判断那笔业务数据到底落库了没。

一个典型的订单场景代码长这样:

// RocketMQ 事务消息生产者
TransactionMQProducer producer = new TransactionMQProducer("order_group");
producer.setTransactionListener(new TransactionListener() {
    @Override
    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        try {
            // 执行本地事务:订单入库
            orderService.createOrder((Order) arg);
            return LocalTransactionState.COMMIT_MESSAGE;
        } catch (Exception e) {
            return LocalTransactionState.ROLLBACK_MESSAGE;
        }
    }

    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt msg) {
        // 回查:根据订单号查数据库,判断订单是否创建成功
        String orderId = msg.getKeys();
        Order order = orderService.getByOrderId(orderId);
        return order != null ? LocalTransactionState.COMMIT_MESSAGE 
                             : LocalTransactionState.ROLLBACK_MESSAGE;
    }
});

这种机制的优势在于:消息发送和本地事务是原子化的,不存在“数据库写成功但消息没发出去”的情况。但代价也很明显——必须实现回查接口,而且半消息期间下游看不到数据,延迟比普通消息略高(实测 P99 多出 10-30ms)。

RabbitMQ 确认机制:解决的是“消息投递”的可靠性

RabbitMQ 的确认机制分两种:生产者确认(Publisher Confirm)消费者确认(Consumer Ack)。这两个机制合在一起,保证的是消息从生产者到 Broker、从 Broker 到消费者的可靠投递,但不保证消息与本地事务的原子性

生产者确认的核心逻辑是:Broker 收到消息并持久化后,会回传一个确认给生产者。如果生产者没收到确认(网络抖动、Broker 宕机等),就需要重发。消费者确认则是消费者处理完业务逻辑后手动 Ack,如果消费者挂掉,Broker 会重新投递给其他消费者。

在微服务拆分场景下,通常的做法是“先落库后发消息”配合“本地事件表”。具体流程:

  1. 本地事务中,业务数据落库的同时,往一张 outbox 表里插入一条事件记录;
  2. 一个后台定时任务扫描 outbox 表,把待发送的事件通过 RabbitMQ 发出去;
  3. 发送成功收到 Confirm 后,更新 outbox 记录状态为已发送;
  4. 消费者处理完业务后手动 Ack。

这个方案的关键在于第 1 步,业务数据和事件记录在同一个本地事务里。只要数据库事务提交成功,事件记录就一定在,后续的定时任务就能保证消息最终发出去。RabbitMQ 的生产者确认和消费者确认,只是这个链条中的两个环节。

# 生产者侧:先落库,再发消息
def create_order(order_data):
    with db.transaction():
        order = insert_order(order_data)
        insert_outbox(event_type='ORDER_CREATED', payload=order.to_json())
    
    # 事务提交后,异步发送消息
    send_to_rabbitmq(order)

# 发送消息时开启 Publisher Confirm
channel.confirm_delivery()
try:
    channel.basic_publish(exchange='order', routing_key='created', body=payload)
    # 确认成功,标记 outbox 为已发送
    mark_outbox_sent(event_id)
except Exception:
    # 未确认,等定时任务重试
    pass

这个方案的缺点是有延迟——消息发送不是实时的,依赖定时任务的扫描间隔(一般设 1-5 秒)。优点是实现简单,不侵入业务逻辑,而且 RabbitMQ 本身运维成本比 RocketMQ 低得多。

两种机制的对决:什么时候选哪个

很多团队在这两个方案之间纠结,其实判断标准很清晰,就看你的业务是“先发消息”还是“先落库”。

RocketMQ 事务消息适合的场景:业务要求“先发消息再落库”,或者消息发送必须和本地事务严格同步。典型例子是支付回调通知——用户支付成功后,支付服务必须立即通知订单服务更新状态,不允许有任何延迟,也不允许漏通知。这种场景下,如果你先落库再发消息,万一发消息失败,订单状态就卡住了。RocketMQ 的半消息机制能保证“支付成功”和“通知发出”是原子的。

另一个信号是下游依赖链路长。比如一笔订单创建后,要通知库存扣减、积分发放、优惠券核销三个服务,这三个服务之间还有顺序依赖。RabbitMQ 的 Outbox 模式会导致每个下游都要等定时任务扫描,延迟叠加后可能到十几秒。RocketMQ 的半消息在 Commit 后立即投递,延迟可控在毫秒级。

RabbitMQ 确认机制适合的场景:业务允许短暂延迟(秒级),且对运维复杂度敏感。大多数业务场景其实都属于这一类——用户下单后,发个短信通知、更新个统计报表、同步到数据仓库,这些操作晚几秒甚至几分钟都没关系。

另外,如果你的团队已经深度使用 RabbitMQ,且没有专门的中间件运维人员,强上 RocketMQ 可能会带来额外负担。RocketMQ 的事务消息需要部署 NameServer、Broker 集群,回查机制的代码写不好还会导致消息堆积或重复消费。RabbitMQ 的运维要简单得多,配合 Outbox 模式一样能达到最终一致性。

还有一个容易被忽视的条件:数据库事务的隔离级别。Outbox 模式依赖“业务数据”和“事件记录”在同一事务中。如果你的数据库是 MySQL 5.6 且用了 READ COMMITTED,事务内写两张表不会有问题。但如果是某些分库分表中间件,不支持跨分片事务,Outbox 模式就直接破产。这时 RocketMQ 事务消息的优势就出来了——它不强制要求事件记录和业务数据在同一个数据库事务里。

我实际踩过的坑

第一次拆微服务时,我们用的是 RabbitMQ + Outbox 模式。上线一个月后发现,定时任务扫描 outbox 表时有漏扫的情况——原因是定时任务用了 SELECT ... FOR UPDATE SKIP LOCKED,但没加索引,导致全表扫描锁冲突,部分记录被跳过。后来在 (status, create_time) 上加联合索引解决。这个小问题导致消息延迟从 3 秒变成了 30 分钟,用户投诉订单状态不更新。

第二次拆服务我们上了 RocketMQ 事务消息,结果栽在回查逻辑上。回查接口里查数据库时,因为连接池满了查不到数据,返回了 ROLLBACK_MESSAGE,导致一批半消息被误删。后来在回查逻辑里加了重试和数据库连接池监控才稳住。

这两次经历让我意识到:没有银弹方案,选型只是起点。RocketMQ 事务消息的强一致性背后,是你必须写好回查逻辑并监控回查成功率;RabbitMQ 确认机制的简单背后,是你必须管好 Outbox 表的扫描效率和去重。

常见问题

RocketMQ 事务消息的回查接口被调用时,如果数据库也挂了怎么办?

回查接口查不到数据时,返回 UNKNOW 而不是 ROLLBACK_MESSAGE。Broker 会对返回 UNKNOW 的半消息进行重试回查,默认重试 15 次。如果数据库一直没恢复,15 次后 Broker 会删除这条半消息。所以数据库挂了的情况下,消息最终会丢失,但这是可接受的——数据库都挂了,业务本身也已经不可用了。

RabbitMQ 的 Outbox 模式怎么避免消息重复发送?

在 Outbox 表加一个 message_id 字段,发送消息时用 message_id 作为消息的唯一标识。消费者侧做幂等处理——用 message_id 去重,要么在数据库加唯一索引,要么用 Redis 缓存已处理的 ID。定时任务扫描 Outbox 时,用 status=待发送 条件,发送成功更新为 已发送,即使重复扫描到同一条记录,发出去的消息 ID 也是一样的。

能不能把 RocketMQ 事务消息和 RabbitMQ 混用?

可以,但不推荐。我们有过一个过渡方案:核心链路(支付、订单状态变更)走 RocketMQ 事务消息,非核心链路(日志、通知)走 RabbitMQ。但维护两套中间件集群的成本不低,而且部分服务要同时引入两个客户端依赖,排查问题时容易混淆。除非是迁移期间,否则不建议长期混用。

Outbox 模式的定时任务扫描频率设多少合适?

我们设的是 3 秒一次,每次最多取 100 条。这个值取决于你的消息吞吐量——如果每秒产生几百条消息,3 秒扫 100 条肯定不够,会产生积压。建议根据实际流量测算:扫描间隔 ≤ Outbox 表单次扫描上限 / 峰值消息产生速率,保证在峰值时也能在下一个间隔内清空积压。