RocketMQ事务消息进阶:回查机制、状态机与生产实践

发布时间:2026/10/14 11:27:27
RocketMQ事务消息进阶:回查机制、状态机与生产实践 1. 从入门到进阶事务消息的第二个台阶本来这一篇应该紧接着上一篇继续讲API用法但我在梳理过程中发现如果只是把官方文档里的代码再抄一遍那这个系列就失去了意义。事务消息真正难的地方不在于怎么调sendMessageInTransaction而在于你知不知道它背后那套状态机是怎么转的以及当消息卡在中间状态时你的系统会经历什么。上一篇我们把事务消息的基本流程走通了发送半消息、执行本地事务、提交或回滚、消息队列自动处理后续。这一篇我打算换个角度直接从“坑”出发——那些你在测试环境跑得好好的一到生产就原形毕露的问题。我踩过的几个典型场景基本都是对事务消息的回查机制和消息状态理解不够导致的。先聊一个我印象最深的案例。某跨平台系统的订单模块下单成功后需要异步通知积分服务。项目组一开始用的是普通消息后来发现积分漏发的情况时有发生订单库提交了但消息发送失败或者消息发出去了积分服务那边没收到。换了事务消息之后漏发的问题解决了但新的麻烦来了——消息堆积。每天凌晨都有几百条消息卡在UNKNOWN状态日志里全是checkTransactionState的回查记录。当时排查了很久最后定位到问题出在本地事务执行时间和回查超时时间的配合上。这个案例基本涵盖了事务消息进阶要掌握的全部要点半消息的存储、状态回查的触发时机、本地事务执行时长的控制、消息恢复策略。下面我一个个拆开讲。2. 半消息的“半”到底是什么状态2.1 不是所有未提交的消息都叫半消息很多刚接触RocketMQ的同学会把“半消息”理解成“发送出去但没有被消费的消息”这其实是个误区。普通消息发出去之后如果消费者暂时没拉取消息在Broker上存储的状态依然是COMMITTED只是还没被消费而已。而半消息是Producer发送成功后Broker存储了但Consumer完全不可见的消息。这个“不可见”是怎么实现的RocketMQ在Broker端给半消息打了一个特殊的标记消息写入CommitLog之后ConsumerQueue的索引不会正常建立或者说索引被放在了一个独立的区域。消费者去拉消息的时候根本遍历不到这条消息。只有当事务状态确定之后消息才会被“解冻”进入正常的投递链路。这里有个关键点半消息虽然Consumer不可见但它占据存储空间也会被算进磁盘使用量。我见过一个案例某公司的事务消息量特别大每天几百万条但提交率只有60%——剩下的40%都是回滚。由于回滚的消息最终会被删除理论上存储压力不大。但实际上回查存在时间窗口在这个窗口内消息一直占着磁盘。如果并发高、窗口长存储占用会超出预期。2.2 本地事务执行成功消息一定可见吗本地事务执行成功只是意味着你的业务逻辑跑完了比如订单数据已经写进订单表。但这个消息能不能从半消息状态变成可投递状态还取决于后续的commit动作是否成功。在sendMessageInTransaction的流程里executeLocalTransaction返回COMMIT_MESSAGE之后RocketMQ客户端会把这个结果封装成请求发给Broker。这个请求走的是事务消息的提交接口而不是普通的确认接口。如果这个提交请求因为网络抖动、Broker重启等原因失败了会发生什么答案是这条消息会继续以半消息的状态存在直到Broker端的回查机制发现它超时了主动向Producer发起回查。所以你在写executeLocalTransaction的时候不能简单地在返回COMMIT之后就万事大吉。你需要理解本地事务执行成功 ≠ 消息立即对消费者可见中间还隔着一层网络往返。提示如果你在executeLocalTransaction里做了重试逻辑比如本地事务执行失败后会重试三次那么这个方法的总耗时可能会远远超过你的预期。后面讲回查机制的时候你会看到这个耗时直接决定了消息会不会被回查以及回查时的状态是否正确。2.3 事务状态的三态转换RocketMQ事务消息的事务状态有三种COMMIT_MESSAGE、ROLLBACK_MESSAGE、UNKNOWN。很多初学者对UNKNOWN理解得很模糊这里我用一个生活化的类比你把车停在路边告诉朋友“我下去买个东西马上回来”。朋友等你但等了一会儿没见你人影他只能打电话问你到底还回不回来回来了就等你不回来了他就自己先走。这里的“等你回来”就是半消息状态“打电话问你”就是回查。UNKNOWN就是你接到电话时自己也说不准车还开不开了——可能是你买东西的商店排队排太久了也可能是你临时决定不回来了但还没来得及通知朋友。在代码层面上executeLocalTransaction返回UNKNOWN之后这条消息就进入了等待回查的队列。回查时会调用checkLocalTransaction方法让你根据本地事务的执行结果最终确定是提交还是回滚。但请注意回查不是无限次的默认最多回查15次超过次数后消息会被丢弃同时记录一条警告日志。这个细节很多文章不会提但在生产环境里如果回查次数耗尽你可能会发现消息丢了而且没有任何消费失败记录。3. 回查机制这套“电话回访”是怎么工作的3.1 回查的触发时机与频率回查机制的核心目的是解决一个问题如果executeLocalTransaction执行过程中进程崩溃了或者返回了UNKNOWNBroker怎么知道这条消息最终该提交还是回滚RocketMQ的事务消息回查触发的时机不是固定的它遵循一个渐进式的间隔策略。具体来说回查间隔不是均匀的而是由事务消息的配置参数控制。在Broker端transactionTimeOut默认是6秒transactionCheckInterval默认是60秒。实际流程是这样的半消息发送成功后Broker启动一个定时任务检查它的状态。如果消息在transactionTimeOut时间内没有收到提交或回滚请求就会触发第一次回查。回查请求发送给ProducerProducer回调checkLocalTransaction。如果回调返回UNKNOWNBroker会在transactionCheckInterval之后再次回查。这里有个容易踩坑的地方transactionTimeOut设置得太短。默认6秒意味着如果你的executeLocalTransaction方法执行时间超过6秒比如里面查了外部接口或者批量处理了大量数据Broker在消息发送后6秒就开始回查了。而这个时候你的本地事务可能还没执行完回查时查到的是“事务进行中”的状态返回UNKNOWN然后进入下一个回查周期。假设你的executeLocalTransaction平均耗时8秒那么每次发送事务消息都会面临一次回查回查返回UNKNOWN再过60秒回查一次。虽然最终结果可能没问题但这个过程中的无谓回查会占用网络资源和Broker的CPU。我自己的建议是在事务消息发送前先评估一下executeLocalTransaction里最耗时的操作大概多久。如果超过5秒就要调整transactionTimeOut的值或者在executeLocalTransaction里优化耗时。有人可能会说直接把transactionTimeOut调大不就行了但调得太大会带来另一个问题消息不可见的时间变长。消费者看到这条消息的时间会延后如果业务对延迟敏感这可能比少量回查更严重。3.2 回查请求怎么找到对应的事务Broker发起回查时怎么知道要回调哪个Producer实例这涉及到RocketMQ的一个设计回查绑定在ProducerGroup上。当Producer发送半消息时会把ProducerGroup信息写入消息属性。Broker回查时会向这个ProducerGroup内的某个Producer实例发送回查请求。这里有个隐含问题如果你的Producer是多实例部署那么回查请求会被负载均衡地分发到其中一台而不是必须发给发送消息的那一台。这意味着什么意味着checkLocalTransaction的实现必须是幂等的、无状态的——它不能依赖“我自己的本地事务上下文”因为你接到的回查请求有可能是另一台机器上发出去的消息。你只能通过消息内容里的业务标识比如订单号去数据库里查状态。我第一次实现checkLocalTransaction时就犯过这个错。当时我把事务状态存在了本地内存的ConcurrentHashMap里想着executeLocalTransaction执行完就把结果存进去回查时直接读。结果上线后发现偶尔会有消息永远停留在UNKNOWN状态最后回查次数耗尽被丢弃。排查了很久才发现原因发送消息的请求落在A机器上但回查请求被分到了B机器。B机器内存里根本没有这条消息的状态记录返回了UNKNOWN。所以回查逻辑里的状态查询一定要走持久化存储比如查数据库里订单表的状态字段。3.3 回查逻辑的容错设计checkLocalTransaction方法返回UNKNOWN本身不是一种错误状态而是系统设计中的一种合理选择。它的存在意义在于当你确实查不到事务状态时不要贸然提交或回滚让Broker过一会儿再问你一次。这个机制本身很灵活但它有一个隐含前提——你的回查逻辑不能有副作用。比如你不能在回查时去修改业务数据因为在极端情况下同一消息的回查可能并发触发虽然RocketMQ自身有去重机制但Producer端的回调方法本身应该是幂等的。另外回查接口的耗时也需要控制。如果checkLocalTransaction里查数据库很慢比如超过了网络超时时间Broker端会认为回查失败然后进入下一轮回查。所以回查SQL的书写要尽量精简索引要建好。我在生产环境就把回查SQL简化到了只查一条主键记录耗时控制在10毫秒以内。4. 事务消息的最终一致性原理4.1 从本地事务表到事务消息方案演进在RocketMQ引入事务消息之前业界常用的方案是本地消息表。它的思路是在业务数据库里建一张消息表业务事务和消息写入放在同一个本地事务里。然后由一个定时任务扫描消息表把未发送的消息发给MQ发送成功的标记为已发送。这个方案最大的问题是定时任务的扫描频率决定了消息的延迟而且消息发送和业务事务的原子性需要额外保障——比如消息发出去了但标记更新失败就会重复投递。RocketMQ的事务消息本质上是对这个方案的优化把消息状态的协调从业务数据库搬到了MQ Broker。半消息存放在Broker上业务事务提交后告诉Broker“这消息可以发了”Broker再把消息变为可投递状态。如果业务事务回滚了告诉Broker“这消息不要了”Broker直接删掉。整个过程不再需要业务侧维护消息表也不再有定时扫描的开销。4.2 异常场景下的最终一致我们来看一个极端的异常场景executeLocalTransaction执行完数据库操作返回COMMIT_MESSAGE但发送提交请求给Broker时网络断开了。这时候业务数据已经写入成功但Broker上的半消息不知道该怎么办。回查机制在这里体现了价值——Broker会在超时后向Producer发起回查checkLocalTransaction查到业务数据已存在返回COMMIT_MESSAGE消息最终被提交。整个过程不需要人工介入这就是“最终一致性”的体现。再看另一个极端场景本地事务还没执行完进程突然崩溃了。半消息已经发到Broker但没有任何人告诉Broker该提交还是回滚。等到回查超时Broker向Producer发起回查但发送消息的Producer实例已经挂了。这个时候RocketMQ的处理策略是如果同ProducerGroup下还有其他存活实例回查请求会发给其他实例如果所有实例都挂了回查就失败了消息会一直停留在半消息状态直到Producer恢复。这里有个运维层面的经验事务消息的ProducerGroup至少要保持一个实例存活否则半消息会一直积压。我在某公司就看到过上线事务消息的时候只部署了一个Producer实例后来那个实例所在的机器宕机了结果那段时间所有事务消息全部卡在半消息状态。虽然Consumer没有报错但业务上出现了大量“订单已支付但积分未增加”的情况排查了很久才发现是Producer实例挂了。4.3 提交和回滚的最终效果当COMMIT_MESSAGE确认后半消息会变成正常消息进入ConsumerQueue消费者立即可见。当ROLLBACK_MESSAGE确认后半消息会被标记为删除消费者永远看不到它。这里要注意一个细节即使消息回滚了它占用的CommitLog空间也不会立即释放。CommitLog是顺序追加的存储结构消息回滚只是在逻辑上标记删除物理空间要等后续消息积累后由磁盘清理机制处理。所以如果你有大量回滚消息磁盘使用率的下降会有一个滞后期这是正常现象不用过度担心。5. 与普通消息的对比事务消息到底多做了什么5.1 生产链路的差异普通消息的生产链路是Producer发送消息 → Broker存储 → 返回发送结果。整个过程一步到位Producer不需要关心Broker后续怎么处理消息。事务消息的生产链路多了一个中间状态Producer发送半消息 → Broker存储半消息 → Producer执行本地事务 → Producer发送提交/回滚指令 → Broker处理半消息。上表的变化对比维度普通消息事务消息消息存储直接可被消费半消息状态不可被消费生产端流程发送 → 完成发送 → 本地事务 → 提交/回滚失败处理Producer感知发送结果Broker回查机制兜底消息状态状态唯一COMMIT / ROLLBACK / UNKNOWN 三种5.2 消费链路的差异对消费者来说事务消息和普通消息的消费方式完全一样。消费者感知不到这个消息曾经是半消息也感知不到回查的过程。也就是说事务消息的复杂性被完整地封装在了Broker和Producer之间消费者不需要做任何额外处理。这个设计是很聪明的事务消息只是解决了“消息该不该发”的确定性而“消息怎么消费、消费失败怎么重试”这些问题还是走MQ原有的机制。所以你在设计事务消息的消费逻辑时不需要考虑和普通消息有什么不同该幂等还是幂等该做重试还是做重试。5.3 适用场景的边界事务消息不是万能的。它适合的场景是一个业务操作需要同时更新数据库和发送MQ消息且两者需要保持最终一致。典型场景是订单创建后发消息、支付成功后发消息、库存扣减后发消息。它不适合的场景包括对实时性要求极高的场景半消息存在期消息不可见会有延迟、低吞吐量的场景事务消息相对普通消息有额外开销、以及业务上不需要保证一致性的场景直接用普通消息更简单。我自己见过一个不太合适的用法有人用事务消息来处理“用户注册后发送欢迎邮件”。注册成功后发邮件如果邮件发送失败其实影响不大可以异步重试。这种情况下用普通消息就够了用事务消息反而增加了复杂度给运维和排查都带来额外负担。6. 实战经验事务消息在生产环境的参数调优6.1 核心参数一览事务消息相关的参数分布在三个地方Producer端、Broker端、还有消费者端的一般不需要调整。我整理了生产环境中比较关键的几个参数参数名位置默认值说明transactionTimeOutBroker6000ms半消息等待提交/回滚的超时时间transactionCheckIntervalBroker60000ms回查的间隔时间transactionCheckMaxBroker15次最大回查次数sendMsgTimeoutProducer3000ms发送半消息的超时时间6.2 事务执行时间与回查频率的匹配我在前面的案例里提到executeLocalTransaction的执行时间直接影响回查频率。这个问题的最佳解法不是一味调大transactionTimeOut而是先优化本地事务的耗时。如果你的executeLocalTransaction需要查询外部接口建议把外部接口调用放到事务外面。比如先调外部接口拿到结果再进入事务消息的流程。这样executeLocalTransaction里只做本地数据库操作耗时一般可以控制在几十毫秒。我处理过一个极端案例某业务在executeLocalTransaction里调用了一个耗时3秒的第三方风控接口导致每次事务消息发送都至少触发一次回查。后来把风控接口的调用前移在发送半消息之前先调用风控通过之后才进入事务消息流程。executeLocalTransaction的耗时从3秒降到了50毫秒回查次数降为0。6.3 Broker端参数的调整思路transactionCheckInterval的默认值是60秒这个值在大多数场景下都是合理的。但如果你的业务对消息延迟比较敏感可以适当调小比如调到10秒或20秒。要注意的是调小这个值会增加Broker端的回查频率和Producer端的回查处理负担。反过来说如果业务对消息延迟不敏感但Producer端的压力比较大可以适当调大transactionCheckInterval减少回查次数。transactionCheckMax默认是15次配合默认的60秒间隔意味着一条半消息最多存活15分钟就会被判定为“回查超时”如果超过15次回查仍然没有明确结果消息会被丢弃。这个值要根据transactionCheckInterval和业务容忍延迟来配合调整。如果间隔是60秒、最大15次那么消息最多容忍15分钟的最终一致延迟。如果业务需要更长的等待时间可以把transactionCheckMax调大。注意如果消息因为回查次数耗尽而被丢弃这个行为只是记录日志不会通知Producer。你只能通过监控Broker日志来发现这种情况。所以对回查次数敏感的线上业务建议在监控系统里配置事务相关的告警。6.4 发送超时参数的隐藏陷阱sendMsgTimeout默认是3000ms它控制的是发送半消息时的网络超时。有些场景下半消息发送超时后会直接抛出异常但消息可能已经写入Broker了。这会造成一种情况本地代码认为发送失败于是不执行后续逻辑但Broker上已经有了一条半消息最终会通过回查机制把状态提交。这种情况如果事务状态没有准备好回查时可能会返回UNKNOWN然后一直重试直到超时丢弃。虽然不会导致消息错发但会产生无效的回查开销。为了避免这个问题可以在发送半消息之前先做好事务准备或者适当增加sendMsgTimeout的值。7. 监控运维事务消息的线上值班手册7.1 关键监控指标生产环境里事务消息的监控指标和普通消息有区别。我建议重点关注以下四个方面半消息积压数Broker上处于半消息状态的消息数量。这个数值持续上涨说明有大量事务没有完成提交或回滚需要排查执行本地事务的Producer是否异常或者回查是否存在失败。回查次数回查次数过多说明本地事务执行过慢或者checkLocalTransaction经常返回UNKNOWN。这个指标可以通过Broker日志中的checkTransactionState统计数据来获取。事务提交率在所有事务消息中最终提交的占比。如果提交率过低比如低于50%可能意味着很多业务事务在中途被回滚了也可能是业务本身就存在大量的异常分支需要结合业务场景分析。回查超时丢弃数回查次数耗尽导致的消息丢弃数量。这个指标正常情况下应该是0一旦出现正值说明有消息状态始终无法确定需要立即排查。7.2 排查流程的先后顺序线上遇到事务消息相关的异常我的排查顺序一般是这样的先看Broker日志里transaction关键字相关的报错信息确认是否有大量回查失败、消息丢弃的情况。再看半消息数量是否异常如果积压上涨要么是Producer实例不可用要么是回查回调逻辑有异常。接着抓几个处于UNKNOWN状态的半消息根据消息里的业务标识去数据库中查业务数据判断事务状态是否已经产生然后定位问题出在哪个环节。有一次某个公司的事务消息消息丢失我们就是按照这个顺序排查的。发现有一个Producer实例所在的容器频繁重启导致本地的半消息没有及时提交同时这个ProducerGroup的其他实例的回查逻辑查询数据库时总是查到“事务进行中”的状态。查看业务代码后发现executeLocalTransaction执行到一半容器就崩溃了数据库事务没有提交也没有回滚。后来对这个场景做了优化本地事务先确保原子性完成然后才发事务消息。如果消息发送失败通过补偿任务定期检查并补发。7.3 一条有价值的运维经验最后分享一个运维层的经验事务消息要和普通消息分开用不同的Topic前缀标识。比如普通订单消息用ORDER_TOPIC订单事务消息用TX_ORDER_TOPIC。这样做的好处是监控时可以直接通过Topic区分查看不会混淆。另外在梳理消息流转链路时事务消息的排查链路也更清晰。还有一点Producer的日志需要记录事务执行结果。sendMessageInTransaction执行完后把返回结果和executeLocalTransaction的返回值都打印出来同时把消息ID和业务ID写入日志。这样后续排查问题时可以快速通过日志走查消息的事务处理过程而不需要去数据库一条条反查。我在实际维护中就把事务消息的日志单独放了一个文件配合按时间切分和关键字过滤排障效率提升不少。这个操作很简单却能在关键时刻省下一个小时的定位时间。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询