分布式事务几乎是 MQ 面试的必考点,但也是很多候选人最容易含糊的地方。尤其是“事务消息”,既要能说出 RocketMQ 半消息机制,又要说清楚它解决的是最终一致性而不是强一致。面试官换一种问法,比如“订单建好了,库存没扣怎么办”“消息发送成功但本地事务回滚了怎么办”,如果你只背了一套答案,很容易卡住。
这篇文章把 MQ 事务消息、分布式事务的核心要点整理成一套可复习的清单:方案对比、工作原理、RocketMQ 代码示例、常见坑和面试回答思路。如果你正在准备面试,或者工作中要在订单、库存、积分这类业务里做一致性设计,这篇可以直接收藏。
1. 分布式事务与 MQ 事务消息核心能力速览
先看分布式事务常见方案的整体对比。面试时先说出这几种方案的区别,再往事务消息上聚焦,会显得有全局观。
| 方案 | 一致性类型 | 对业务侵入 | 性能影响 | 典型组件 | 适用场景 |
|---|---|---|---|---|---|
| 2PC / XA | 强一致 | 高 | 高 | 数据库、事务管理器 | 跨库强一致,但锁和协调者开销大 |
| TCC | 最终一致 | 高 | 中 | 自研框架 | 资金类、高频核心链路,需要写Try/Confirm/Cancel |
| 本地消息表 | 最终一致 | 中 | 低 | 业务库 + MQ | 通用异步场景,兼容所有MQ |
| 事务消息 | 最终一致 | 中 | 低 | RocketMQ | 通用异步场景,推荐优先考虑 |
| 最大努力通知 | 最终一致 | 低 | 低 | MQ + 定时任务 | 通知类、对账类,结果以查询为准 |
关于 MQ 事务消息,面试最核心的几个结论是:
- 事务消息的本质是“可靠消息”的一种实现,解决的是消息发送与本地事务原子性问题。
- 它不等于分布式事务的全部,更不等于强一致。
- 其中最关键的机制是半消息和事务状态回查。
- 无论用哪种方案,消费端都必须做幂等。
2. 适用场景与使用边界
事务消息不是万能的。先搞清楚它适合解决什么问题,再决定要不要用它。
典型适合场景:
- 下单成功后扣库存。
- 支付成功后通知积分服务加积分。
- 用户注册成功后发送优惠券。
- 订单创建后触发物流、财务等异步流程。
这些场景共同点是:核心业务在本地事务中完成,下游动作可以异步执行,并且允许短时间内不一致,最终通过消息重试达到一致。
不适合场景:
- 账户扣款和余额更新,要求实时强一致。
- 两个服务必须同时成功或同时失败。
- 下游失败会导致严重资金风险的场景。
这类场景要优先考虑 TCC、本地事务,或者干脆通过搜索系统状态而不是靠消息传递。
使用边界也要说清楚:
- 事务消息不保证消费者只收到一次,重试会带来重复投递。
- 事务消息不保证下游一定能处理成功,下游失败要靠消费重试和人工补偿。
- 事务消息不保证消息延迟可控,不能拿它做延迟队列。
- 回查次数有上限,超过后消息可能被丢弃,必须有对账或告警兜底。
从工程合规角度,涉及订单、支付、库存等业务时,不要在消息体中写入明文敏感信息,发送前做好数据脱敏。生产链路要预留消息流水表,方便核对和审计。
3. MQ 事务消息工作原理:半消息与回查机制
先想一个问题:为什么不能先发消息再执行本地事务?
如果先发消息,本地事务失败,消费者已经拿到消息去扣库存,但订单没创建成功,数据就错了。如果先执行本地事务再发消息,消息发送失败时,业务已经提交,下游不知道需要扣库存。两种方式都做不到消息和本地事务一致。
RocketMQ 的设计是先把消息发送到 Broker,但这个消息处于半消息状态,消费者不可见。业务本地事务执行完成后再通知 Broker 提交或回滚。
完整流程如下:
Producer -> Broker: 发送半消息 Producer -> DB: 执行本地事务 Producer -> Broker: COMMIT_MESSAGE / ROLLBACK_MESSAGE Broker -> Producer: 如果状态未知,发起事务状态回查 Producer -> Broker: 返回本地事务真实状态 Broker -> Consumer: 只有 COMMIT_MESSAGE 的消息才能被消费关键点是半消息:
- 半消息对消费者不可见。
- 半消息也会持久化,Broker 会单独管理。
- 本地事务返回 COMMIT_MESSAGE,消息才会变成普通消息。
- 返回 ROLLBACK_MESSAGE,消息会被删除。
- 返回 UNKNOWN,Broker 会按照配置定时向发送端发起回查。
回查时,发送端需要根据业务数据判断本地事务到底成功没有。比如订单服务收到回查,就去查订单表,订单存在则返回 COMMIT_MESSAGE,不存在则返回 ROLLBACK_MESSAGE。
RocketMQ 的回查次数和间隔受 Broker 端配置控制,例如transactionCheckMax、transactionCheckInterval。回查超过上限仍然未知,消息会被丢弃。生产环境一定要监控这个异常分支,不能只依赖回查。
4. 本地消息表 vs 事务消息:两种可靠消息方案对比
本地消息表是理解事务消息的最好前置知识,也是面试常见追问点。
本地消息表的核心思路是:业务数据和消息状态放在同一个数据库事务里写。业务表插入一条订单,消息表同时插入一条“待发送消息”,这两个操作要么同时成功,要么同时失败。然后由定时任务扫描消息表,把待发送消息投递到 MQ,投递成功后更新消息状态。
消息表结构可以简化成这样:
CREATE TABLE local_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(32) NOT NULL COMMENT '业务类型', biz_id VARCHAR(64) NOT NULL COMMENT '业务主键', payload TEXT NOT NULL COMMENT '消息内容', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发送 1已发送 2失败', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_biz_type_biz_id (biz_type, biz_id) ) COMMENT '本地消息表';定时任务发送逻辑伪代码:
public void scanAndSend() { List<LocalMessage> messages = messageMapper.selectPending(0, 100); for (LocalMessage message : messages) { try { SendResult result = producer.send( new Message("ORDER_TOPIC", message.getPayload().getBytes()) ); if (result.getSendStatus() == SendStatus.SEND_OK) { messageMapper.markSent(message.getId()); } } catch (Exception e) { messageMapper.incrementRetry(message.getId()); } } }本地消息表的优点是:不依赖 MQ 的特定高级功能,Kafka、RabbitMQ、RocketMQ 都能用。
缺点也很明显:
- 业务代码里多维护一张表。
- 定时任务需要处理发送失败、重试上限。
- 消息重复投递概率更高,消费端必须幂等。
事务消息的不同在于,把“本地事务 + 发送消息”的原子性交给 Broker 来协调。业务不用自己维护消息表,而是通过监听器实现本地事务,并通过回查机制兜底。
对比表格如下:
| 对比项 | 本地消息表 | 事务消息 |
|---|---|---|
| 对 MQ 的依赖 | 任意 MQ | RocketMQ 等具备事务消息能力的 MQ |
| 业务侵入 | 需要消息表 + 定时任务 | 需要实现事务监听器 |
| 事务一致性保障 | 业务库 + 消息表同一事务 | 半消息 + 本地事务 + 回查 |
| 排查复杂度 | 多一张表和一套任务 | 需要理解半消息和回查机制 |
| 通用性 | 高 | 中 |
两种方案本质上都是“可靠消息 + 最终一致性”。面试时把本地消息表讲清楚,再引出事务消息,会显得对方案演进有理解。
5. RocketMQ 事务消息落地实战
这一节用一个经典场景:订单创建成功后,通知库存服务扣减库存。
5.1 环境准备
本地需要先准备 RocketMQ 运行环境:
- JDK 8 及以上。
- RocketMQ 4.x 或 5.x。
- NameServer 和 Broker 能正常启动。
- 如果使用管理后台(dashboard),需要单独启动 dashboard 项目。
启动 NameServer 和 Broker 的通用命令如下:
# 启动 NameServer nohup sh bin/mqnamesrv & # 启动 Broker,并指定 NameServer 地址 nohup sh bin/mqbroker -n 127.0.0.1:9876 &启动后可以用 mqadmin 检查集群状态:
sh bin/mqadmin clusterList -n 127.0.0.1:9876注意:不同版本 RocketMQ 的启动脚本和参数可能略有差异,生产环境不要直接裸跑,需要配置账号鉴权、日志路径和存储路径。
5.2 引入依赖
以 Maven 为例:
<dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-client</artifactId> <version>4.9.4</version> </dependency>版本号以你本工程实际使用为准,4.x 和 5.x 的事务消息 API 整体兼容。
5.3 实现事务监听器
事务监听器包含两个方法:
executeLocalTransaction:执行本地事务并返回状态。checkLocalTransaction:Broker 回查时调用,确认本地事务结果。
订单场景示例:
public class OrderTransactionListener implements TransactionListener { private final OrderMapper orderMapper; public OrderTransactionListener(OrderMapper orderMapper) { this.orderMapper = orderMapper; } @Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { try { Order order = (Order) arg; orderMapper.insert(order); return LocalTransactionState.COMMIT_MESSAGE; } catch (Exception e) { return LocalTransactionState.ROLLBACK_MESSAGE; } } @Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { String orderId = msg.getKeys(); boolean exists = orderMapper.existsByOrderId(orderId); return exists ? LocalTransactionState.COMMIT_MESSAGE : LocalTransactionState.ROLLBACK_MESSAGE; } }代码说明:
executeLocalTransaction中插入订单,成功时返回 COMMIT_MESSAGE,消息对消费者可见。- 失败时返回 ROLLBACK_MESSAGE,半消息会被丢弃。
- 如果本地事务执行期间进程重启或异常,状态未知,Broker 会回调
checkLocalTransaction。 - 回查逻辑以订单表数据为准,这是最可靠的判断方式。
5.4 发送事务消息
使用TransactionMQProducer而不是普通DefaultMQProducer:
TransactionMQProducer producer = new TransactionMQProducer( "order_tx_producer_group" ); producer.setNamesrvAddr("127.0.0.1:9876"); producer.setTransactionListener(new OrderTransactionListener(orderMapper)); producer.start(); Order order = buildOrder(); Message msg = new Message( "ORDER_STOCK_TOPIC", "stock_deduct", order.getOrderId(), JSON.toJSONBytes(order) ); SendResult sendResult = producer.sendMessageInTransaction(msg, order); System.out.println(sendResult.getSendStatus()); producer.shutdown();注意点:
sendMessageInTransaction的第二个参数会原样透传给executeLocalTransaction的arg。- 业务参数不要塞入消息体之外的敏感字段。
- 发送事务消息后不能使用
sendOneway,否则无法确认半消息状态。
5.5 消费端幂等消费
RocketMQ 消费语义通常是 at-least-once,网络抖动、重试都会导致重复消息。库存服务消费时一定要幂等。
推荐做法:在库存服务维护一张扣减流水表,以消息 key 作为唯一键:
CREATE TABLE stock_deduct_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id VARCHAR(64) NOT NULL, sku_id VARCHAR(64) NOT NULL, quantity INT NOT NULL, status TINYINT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL, UNIQUE KEY uk_order_id (order_id) ) COMMENT '库存扣减流水表';消费逻辑示例:
consumer.registerMessageListener( (MessageListenerConcurrently) (msgs, context) -> { for (MessageExt msg : msgs) { String orderId = msg.getKeys(); if (deductLogMapper.existsByOrderId(orderId)) { continue; } // 先写流水,再扣库存 deductLogMapper.insert(orderId); stockService.deduct(orderId); } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } );先写流水再执行扣减,利用唯一索引挡住重复消息。即使扣减逻辑异常,也可以根据流水表做补偿。
6. Kafka 与 RabbitMQ 场景下的方案选择
面试经常追问:如果项目用的是 Kafka 或 RabbitMQ,没有 RocketMQ 的事务消息怎么办?
6.1 Kafka 的事务 API
Kafka 提供事务 API,可以保证发送到多个分区的消息,以及消费位移的提交是原子的。
producer.initTransactions(); try { producer.beginTransaction(); producer.send(new ProducerRecord<>("order-topic", orderId, orderJson)); producer.sendOffsetsToTransaction(offsets, consumerGroupId); producer.commitTransaction(); } catch (Exception e) { producer.abortTransaction(); }这段代码解决了“处理消息 + 发送消息 + 提交消费位移”的原子性问题,典型场景是 Kafka Streams 的恰好一次处理。
但要注意:Kafka 事务不能直接保证“业务数据库事务”和“发送消息”的原子性,因为它无法感知外部数据库到底提交了没有。它和 RocketMQ 半消息模型不是一回事。
Kafka 生态里做订单和库存一致性更常见的做法是:
- 业务数据库写订单,同时写消息表和 Kafka 发送占位。
- 定时任务或 CDC 把待发送记录投递到 Kafka。
- 消费端做幂等。
这本质上就是本地消息表模式。
6.2 RabbitMQ 的事务与消息确认
RabbitMQ 原生支持事务,但性能较差:
channel.txSelect(); try { channel.basicPublish(exchange, routingKey, props, body); channel.txCommit(); } catch (Exception e) { channel.txRollback(); }事务模式下生产者发送是串行的,吞吐下降明显。生产环境更推荐使用 Publisher Confirm 机制:
- 发送方开启 confirm。
- Broker 成功落盘后异步回调确认。
- 发送方维护未确认消息,失败后重发。
- 消费端配合手动 ack 和幂等。
这同样不能做到业务数据库和消息发送强原子,仍需要在业务侧做补偿或使用本地消息表。
面试回答时可以说:没有哪种 MQ 是银弹,重要的是根据业务一致性等级选择方案。如果需要全链路最终一致,RocketMQ 事务消息、本地消息表、Kafka 事务加幂等三种方案都可行,区别在于维护成本和延迟上限。
7. 延迟消息与分布式事务的关系
搜索热词里经常出现“mq 延迟消息队列”。很多候选人会把延迟消息和事务消息搞混,这里单独说明。
延迟消息解决的是“定时触发”问题,比如:
- 下单 30 分钟未支付,自动关单。
- 支付完成后,如果 10 秒内没收到对账回调,触发主动查询。
RocketMQ 支持延迟消息,通过messageDelayLevel配置延迟等级。默认共有若干时间等级,发送时指定等级:
Message msg = new Message("ORDER_CLOSE_TOPIC", "close_order", orderId, body); // 具体等级依赖 broker 的 messageDelayLevel 配置 msg.setDelayTimeLevel(16); producer.send(msg);注意:不同 RocketMQ 版本的默认延迟等级可能不同,生产环境以 Broker 配置为准。RocketMQ 的延迟消息是按等级设计的,不能任意指定秒数。
RabbitMQ 实现延迟队列通常有两种方式:
- 给消息设置 TTL,消息过期后进入死信交换机。
- 使用延迟消息插件
rabbitmq-delayed-message-exchange。
延迟消息不能替代事务消息:
- 事务消息保证的是消息与本地事务的一致性。
- 延迟消息保证的是消息在指定时间后才被消费者看到。
两者可以配合使用。例如订单模块:
- 订单创建成功后,通过事务消息通知库存扣减。
- 同时发送一条 30 分钟后的延迟消息,用于判断订单是否超时未支付并主动关单。
这样既能保证核心链路一致性,又能做超时兜底。
8. 发送端接口与批量投递的工程处理
MQ 客户端通常提供三种发送方式:同步发送、异步发送、单向发送。
// 同步发送 SendResult sendResult = producer.send(msg, 3000); // 异步发送 producer.send(msg, new SendCallback() { @Override public void onSuccess(SendResult sendResult) { // 发送成功 } @Override public void onException(Throwable e) { // 发送失败,记录日志并重试 } }, 3000); // 单向发送,不关心结果 producer.sendOneway(msg);事务消息必须使用sendMessageInTransaction,不能使用单向发送。原因很简单:半消息发送后需要依赖本地事务结果和回查来推进,不能发送完就不管。
批量投递场景需要注意:
- RocketMQ 普通消息支持批量发送,但批量消息有大小和条数限制。
- 事务消息一般按单条发送,逐条维护事务状态。
- 批量扣库存时,可以拆成多条普通消息加消费端幂等,或者用同一事务消息携带列表,由消费者逐条处理。
发送失败重试建议:
- 同步发送失败,可以捕获异常后重试,但要注意幂等。
- 消息 key 尽量用业务主键,如订单 ID。
- 重试超过阈值后,记录日志并走补偿任务。
- 不要把无限重试写在一个请求线程里,容易拖垮应用。
9. 性能、监控与资源消耗观察
事务消息比普通消息多出的成本主要在三个方面:
- 半消息写入和回查消息带来的额外写盘。
- Broker 需要定时扫描半消息队列。
- 发送方需要实现并响应回查接口。
从监控角度,需要重点观察:
- Broker 磁盘使用率,commitlog 增长是否异常。
- 半消息 topic 中积压数量。
- 回查次数,如果回查过于频繁,说明本地事务返回 UNKNOWN 太多。
- 消费端消息堆积情况,堆得越久,最终一致性窗口越大。
- 生产者发送耗时,特别是事务消息发送
sendMessageInTransaction的响应时间。
常见的 mqadmin 命令可以检查集群状态和 topic 信息,但不同版本命令参数不同:
sh bin/mqadmin clusterList -n 127.0.0.1:9876 sh bin/mqadmin topicList -n 127.0.0.1:9876 sh bin/mqadmin consumerProgress -g consumer_group -n 127.0.0.1:9876关于资源消耗,不需要背具体数字。但面试时可以说明:事务消息的额外开销与本地事务耗时、回查频率、日志保留时间相关。如果业务希望降低开销,可以让executeLocalTransaction快速返回 COMMIT 或 ROLLBACK,减少 UNKNOWN 状态,从而减少回查。
另外,很多人在本地启动 RocketMQ 后管理后台打不开。这个问题大部分是环境问题,排查顺序如下:
- 确认 NameServer 和 Broker 是否真的启动成功。
- 确认 dashboard 项目里配置的
namesrvAddr是否正确。 - 确认管理后台端口是否被占用,默认通常是 8080,具体看项目配置文件。
- 确认 9876 端口防火墙是否放行。
10. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 事务消息一直不被消费 | 半消息没有 COMMIT,或回查逻辑异常 | 查看半消息 topic、回查日志 | 修正checkLocalTransaction,保证返回真实状态 |
| 本地事务成功但消息被回滚 | executeLocalTransaction 抛异常,返回 ROLLBACK | 查看本地事务日志 | 检查业务代码异常分支,避免把外部调用失败当成业务失败 |
| 消费者重复收到消息 | 消费端重试导致重复投递 | 查看消费日志和重投次数 | 消费端幂等,使用唯一索引或状态表 |
| 库存扣减失败但消息已消费成功 | 消费端没有重试机制或重试耗尽 | 检查消费端异常日志 | 配置消息重试,失败进死信队列,配合补偿任务 |
| 管理后台无法进入 | NameServer/Broker 未启动,或端口配置错误 | 检查 9876、dashboard 端口和日志 | 按排查顺序修复环境 |
| 消息发送超时 | Broker 磁盘 IO 高,或网络抖动 | 查看 Broker 磁盘、发送耗时 | 清理磁盘、扩容或优化发送方式 |
| 回查次数过多 | 本地事务耗时太长,或返回 UNKNOWN 频繁 | 查看回查日志 | 控制本地事务耗时,一次返回真实状态 |
| 事务消息在 broker 重启后丢失 | 半消息未刷盘,或事务日志未同步 | 检查 Broker 刷盘策略和主从配置 | 生产环境开启适当刷盘策略,并做集群部署 |
面试时不需要把排查表背下来,但至少要能说出:事务消息出问题,先看半消息状态,再看回查日志,最后看消费端幂等。
11. 面试回答思路与最佳实践
最后一个部分,直接给出可用的面试回答框架。
面试官问:“订单创建后,怎么保证库存一定扣减?”
推荐回答结构:
- 先分析场景。订单和库存允许短暂不一致,可以接受最终一致,不需要强一致。
- 再选方案。如果 MQ 是 RocketMQ,优先用事务消息。
- 说原理。发送半消息,执行本地事务插入订单,返回 COMMIT,消息才可见;如果状态未知,Broker 会回查。
- 说实现。订单表写入和事务消息返回结果在一个事务语义内,库存服务消费消息后先写扣减流水再扣库存,保证幂等。
- 说兜底。消费失败会重试,重试失败进死信队列或对账任务;极端情况靠人工补偿。
面试官追问:“如果消费者挂了,一直消费不了怎么办?”
回答思路:消息会重试,RocketMQ 支持重试队列,超过重试次数进入死信队列,再配合定时任务扫描死信队列做补偿。更进一步的方案是业务层记录订单快照,对账任务定期核对订单表和库存扣减流水。
面试官追问:“为什么不直接用 2PC?”
回答思路:2PC 是强一致方案,但跨服务场景锁资源、协调者单点、故障恢复复杂,高并发下性能下降明显。订单扣库存这种场景,最终一致已经够用,没必要用强一致。
最后给几条工程实践建议:
- 第一次接入事务消息,先小额流量测试,不要一上来全量切换。
- 保留一套最小可运行配置,方便排查环境问题。
- 消息 key 使用业务主键,日志里能串联完整链路。
- 批处理任务要加日志、重试、失败告警。
- 涉及订单、支付、库存的日志和消息体,不打印敏感字段。
- 消费端一律做幂等,不信任 MQ 的至少一次语义。
- 事务消息回查逻辑要查询业务真实状态,而不是返回固定值。
这套思路如果能在面试中顺畅讲出来,并且能回答“为什么不用本地消息表”“为什么不用 Kafka 事务”,基本就能把 MQ 事务消息和分布式事务这个考点拿稳。关键不是背代码,而是把每个方案背后的取舍说清楚。