事务消息落地的三关:半消息、回查与幂等
最近帮一个订单系统做分库分表后的数据同步,设计了一个典型方案:订单服务写本地库,然后通过 RocketMQ 事务消息通知库存服务扣减库存。方案设计看起来很美,但上线后连续两周出现两种诡异问题——偶尔库存扣减失败、偶尔用户收到"库存不足"的错误提示但实际订单已经提交成功。
排查过程走了不少弯路。问题不在代码逻辑,而在对事务消息机制的理解不够深。这篇文章把踩过的坑整理出来,重点讲清楚三个最容易出问题的环节:半消息提交时机、回查机制的边界条件、以及消费端幂等性的正确写法。
一、为什么需要事务消息

分布式场景下,本地事务和远程调用无法捆绑在一个连接里。最直观的做法是先在本地库写记录,再发 MQ 消息,最后由消费者处理下游业务。但这带来一个经典问题:本地事务提交了,消息却发失败了,或者消息发成功了,消费者还没处理系统就挂了。无论哪种情况,数据都会处于不一致状态。
RocketMQ 事务消息的设计思路是把"先写库还是先发 MQ"这个问题转化为一个两阶段提交协议:生产者先发送半消息(half message),Broker 返回消息 ID 但不投递给消费者;然后生产者执行本地事务;本地事务提交成功后才向 Broker 发送 commit,消息才被投递;如果本地事务回滚,则发送 rollback,消息被丢弃。
这个机制看起来优雅,但落地时有几个关键点很容易被忽略。
二、半消息的生命周期

半消息是事务消息的起点。调用 sendTransactionally 时,Broker 会把消息存入 RMQ_SYS_TRANS_HALF_TOPIC 这个内部 topic,此时消费者看不到这条消息。关键问题在于:谁来决定半消息的命运?
生产者执行本地事务后,会回调 executeLocalTransaction 方法。这个方法有三种返回值:CommitTransactionMsg(提交)、RollbackTransactionMsg(回滚)、UNKNOW(未知)。
transactionMQProducer.sendTransactionally(msg, new TransactionArg() {
@Override
public Object arg() {
return order;
}
@Override
public void executeLocalTransaction(Message msg, Object arg) {
Order order = (Order) arg;
try {
orderService.createOrder(order);
// 本地事务成功,提交半消息
localTransactionExecuted = true;
transactionMQProducer.commitTransactionMsg(msg);
} catch (Exception e) {
// 本地事务失败,回滚半消息
transactionMQProducer.rollbackTransactionMsg(msg);
}
}
});
这里有一个常见的误解:认为 executeLocalTransaction 返回 UNKNOW 就万事大吉了。实际上,返回 UNKNOW 只是告诉 Broker"我现在不知道事务状态",Broker 会启动回查机制。如果回查期间系统挂了或者回查超时,消息可能会一直被挂起,直到超时后被 Broker 自动回滚。
更关键的是,executeLocalTransaction 方法必须在本地事务提交之前完成。如果方法执行时间过长,超过了 Broker 的回查超时时间(默认 15 秒),Broker 会直接回滚消息,即使你的本地事务还在正常运行中。
三、回查机制的正确姿势
回查是事务消息最容易被忽视的环节。Broker 在本地事务状态未知时,会定期向生产者发起回查请求,询问这条半消息最终应该提交还是回滚。
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
String orderId = (String) msg.getKeys();
Order order = orderMapper.selectById(orderId);
if (order == null) {
// 订单不存在,说明本地事务没完成,回滚
return LocalTransactionState.ROLLBACK_MESSAGE;
}
if (order.getStatus() == OrderStatus.CREATED) {
// 订单已创建,本地事务成功,提交
return LocalTransactionState.COMMIT_MESSAGE;
}
// 状态异常,继续回查
return LocalTransactionState.UNKNOW;
}
回查逻辑有两个陷阱。
第一个陷阱是回查超时。Broker 默认 60 秒没有收到回查响应就认为回查失败,会重试或回滚。如果回查逻辑涉及复杂的数据库查询或外部调用,很容易超时。建议回查方法只做轻量级的状态查询,避免 IO 操作。
第二个陷阱是回查频率。Broker 的回查策略是指数退避,第一次回查间隔很短,后续逐步拉长。但如果回查一直返回 UNKNOW,消息会被持续挂起,占用 Broker 资源。生产环境建议设置合理的回查次数上限,超过后强制回滚或报警。
另一个容易被忽视的问题是:回查方法和 executeLocalTransaction 应该查同一个数据源。如果回查时查的是从库,而本地事务刚提交到主库,主从延迟可能导致回查到旧数据,误判事务状态。
四、消费端幂等:不是选择题是必答题
事务消息解决了"本地事务和消息发送的原子性"问题,但没有解决消费端重复消费的问题。网络抖动、消费者重启、Broker 重试都可能导致同一条消息被消费多次。
很多项目在这里踩坑:以为事务消息保证了不重复,消费端不加幂等控制。这是错误的理解。事务消息保证的是"消息要么全部成功,要么全部回滚",而不是"消息只被消费一次"。
正确的做法是在消费端建立幂等表。以订单场景为例:
CREATE TABLE idempotent_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
msg_id VARCHAR(64) NOT NULL UNIQUE,
order_id VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL,
INDEX idx_msg_id (msg_id)
);
消费逻辑:
@RocketMQMessageListener(topic = "ORDER_TOPIC", consumerGroup = "order_consumer_group")
public class OrderMessageConsumer implements RocketMQListener<MessageExt> {
@Override
public void onMessage(MessageExt msg) {
String msgId = msg.getMsgId();
// 先查幂等表
if (idempotentMapper.existsByMsgId(msgId)) {
log.info("消息已处理,跳过: {}", msgId);
return;
}
try {
// 执行业务逻辑
Order order = parseOrder(msg);
orderService.processOrder(order);
// 插入幂等记录
idempotentRecordMapper.insert(new IdempotentRecord(msgId, order.getOrderId()));
} catch (Exception e) {
log.error("消息处理失败: {}", msgId, e);
throw e;
}
}
}
这里有一个细节:幂等记录的插入应该在业务逻辑成功执行之后。如果先插入幂等记录再执行业务,业务失败时消息会重试但幂等记录已经存在,导致消息被静默丢弃。
另外,幂等表需要定期清理历史数据。可以按时间分表,或者设置定时任务删除 N 天前的记录。
五、常见故障排查
故障一:消息一直回查但从未消费
排查步骤:
- 检查 Broker 日志,确认回查是否正常触发
- 检查生产者实例是否在线,回查请求是否可达
- 检查
checkLocalTransaction方法返回状态是否正确 - 检查是否有未处理的异常导致回查超时
故障二:本地事务提交成功但消息未投递
可能原因:
commitTransactionMsg调用失败(网络超时、Broker 不可达)- 消息已 commit 但 Broker 重启导致半消息丢失(极端情况下)
- 消息被手动回滚(代码逻辑错误)
建议增加 commit 调用的重试机制,并记录详细日志。
故障三:消费端重复处理但幂等表查不到
常见原因:
- 幂等记录和业务流程不在同一事务中,事务回滚时幂等记录未回滚
- 并发消费导致同一消息被多个消费者同时处理,幂等记录插入失败但业务已执行
解决方案:使用数据库唯一约束保证幂等记录的原子性,或者使用 Redis 分布式锁。
六、什么时候不该用事务消息
事务消息不是万能的。以下几种场景不建议使用:
实时性要求极高的场景。事务消息的提交是异步的,从本地事务提交到消息可消费之间存在延迟。如果业务要求强一致性,应该考虑 2PC 或 TCC。
本地事务执行时间不确定的场景。如果本地事务可能执行几分钟甚至更久,Broker 的回查机制会很快超时,导致消息被错误回滚。
对消息丢失零容忍的场景。事务消息依赖 Broker 的可靠性,如果 Broker 不可靠或配置不当,消息可能丢失。对于金融级场景,建议结合本地消息表方案。
下游服务不稳定且无补偿机制的场景。事务消息只能保证消息发送和本地事务的原子性,无法保证下游消费成功。如果下游服务经常不可用,需要配合重试机制和补偿逻辑。
七、总结
事务消息是解决分布式数据一致性的有效工具,但落地时需要跨过三道坎:半消息的提交时机、回查机制的正确实现、消费端的幂等保障。每一步都有潜在的坑,需要在设计阶段就考虑清楚。
最根本的建议是:不要过度依赖事务消息的"天然一致性",始终假设消息可能重复消费,始终为回查失败预留处理方案。分布式系统的可靠性,来自于对故障的承认和预案,而不是对中间件的盲目信任。

浙公网安备 33010602011771号