☰
RocketMQ消息丢失排查实战:从生产到消费的完整不丢方案
2026/10/21 7:11:47 网站建设 项目流程

你有没有在半夜被一条“RocketMQ消息丢失”的报警吵醒过?我遇到过不止一次。最近一次是在凌晨两点多,群里刷屏说一批订单在支付回调成功之后,订单状态一直卡在“待支付”,后端服务日志里压根没看到相关的消费记录。查了一整圈,最后定位到的原因特别不起眼:生产端把发送异常吞掉了,消息根本没发出去。今天想借这个标题,把我在实际排查和修复“MQ消息丢失”问题时积累的经验完整梳理一遍。这篇文章会从一条消息的完整生命周期入手,把生产、存储、消费三个阶段里所有可能导致消息丢失的点挨个讲透,包括底层原理、排查命令、配置参数、代码写法,最后给出一套可以落地执行的“不丢消息”方案。

1. 消息丢失问题,首先要聚焦到链路里的三个环节

1.1 一条消息的生命周期:从发起到消费的三段旅程

要排查消息丢失,第一件事是搞清楚一条消息从产生到被业务消费完,到底要经过哪些阶段。很多人一听到“消息丢了”就直接去看消费者日志,这是不对的。RocketMQ里一条消息的完整生命周期可以拆成三段:

  • 生产阶段:生产者客户端将消息发送给Broker,Broker写入CommitLog后返回SendResult,此时生产端才认为“发送成功”。
  • 存储阶段:Broker将消息物理落盘,并根据复制策略把数据同步到从节点。这一步决定的是消息在服务器上到底“活没活下来”。
  • 消费阶段:消费者从Broker拉取消息,执行业务逻辑后返回消费状态,Broker记录消费位点(offset)。这个阶段决定的是业务“有没有真正处理完”。

每一段旅程都对应一个“确认信号”。生产端收到SEND_OK、Broker完成物理落盘、消费端返回CONSUME_SUCCESS,这三个信号全部闭环,消息才算真正成功。所谓消息丢失,本质上就是某个环节的确认信号没有达成,而你还没有意识到。

1.2 先定性:你遇到的是哪一种“丢”

排查消息丢失最容易犯的错误是上来就乱翻日志。我的习惯是先给问题定性:这到底是哪一种“丢”?根据上面的三段旅程,可以把“丢消息”分成三类:

丢失类型表面现象本质原因排查方向
生产端没发出去数据库有业务记录,但MQ里查不到消息发送异常被代码吞掉,或使用了单向发送生产端代码与日志
Broker存储不可靠生产端返回成功,但宕机后消息查不到异步刷盘、异步复制导致数据未真正落盘Broker配置、集群状态
消费端“假成功”Broker显示消息已消费,但业务状态没变更消费逻辑异常被catch,返回了CONSUME_SUCCESS消费端代码、offset提交时机

还有一类容易被误判为“丢消息”的情况:消息其实没丢,只是被过滤了。比如生产端发送时tag是A,消费端订阅的是tag B,消息永远不会投递过来,业务侧看起来就是“消息消失了”。这种问题在排查时也要纳入考虑。

所以我的排查原则很简单:先判断消息在哪一段旅程上断掉了,再针对性地深挖,而不是用同一个方法去套所有问题。

2. 生产阶段为什么丢:发送方式就是第一道分水岭

2.1 三种发送方法的可靠性差异

RocketMQ生产端有三种发送方式,可靠性完全不是一个量级:

发送方式是否会等待结果失败能否感知是否自动重试适用场景
同步发送(send)会,阻塞等待SendResult能,返回异常或错误状态默认有重试订单、支付、库存等核心业务消息
异步发送(send+Callback)不会,回调处理结果回调里能感知默认有重试对耗时敏感、吞吐要求高的场景
单向发送(sendOneway)不会,直接返回完全不能无日志上报、监控数据等允许丢失

很多团队为了追求发送性能,上线时把sendOneway用在了业务消息上,这是非常危险的做法。sendOneway字面意思就是“发了就不管”,网络抖动、Broker刷盘超时、消息过大被拒绝,任何问题你都无法感知,消息丢了都没有任何记录。我见过不止一个项目,日志型消息和订单消息用同一套封装好的发送方法,结果把单向发送带到了核心链路上。

2.2 发送失败被吞掉,才是实际中最高发的“凶手”

还有一类更隐蔽的问题,比用错发送方式更常见:代码里把发送异常catch住了,然后没有做任何处理。比如下面这段代码,就是我在一个真实项目里见过的写法:

try { SendResult sendResult = producer.send(message); if (SendStatus.SEND_OK.equals(sendResult.getSendStatus())) { // 仅记录成功日志 } } catch (Exception e) { // 异常打印到日志,然后什么都不做 log.error("send message failed", e); }

表面上看,这段代码记录了异常日志,好像没什么问题。但在线上场景里,异常日志很容易被大量正常日志刷掉,而且不会触发任何告警。更关键的是,catch住异常之后,消息没有重新投递,数据库里主流程依然正常开展,用户完全感知不到异常。等到后面业务状态对不上,你去查消息,早就找不到了。

我的建议是:核心业务消息一律采用同步发送,并把发送失败纳入异常处理链路,要么直接抛出异常让上游感知,要么进入本地重试表由定时任务补投。异步发送的回调里也必须做失败处理,至少要做到“告警+补偿”,绝不能只在日志里打一行字就完事。

生产端发送还有一个隐藏参数值得关注:sendMsgTimeout,默认是3000毫秒。如果Broker在同步刷盘模式下写入比较慢,或者消息体积比较大,3秒很容易超时。超时后生产者会触发重试,但重试次数(retryTimesWhenSendFailed)默认只有2次。对于核心业务,建议把超时时间调整为5000毫秒,并适当增加重试次数。同时要注意,重试只能解决“发送失败”的问题,解决不了“发送成功但没落盘”的问题,那是下一章节要讲的内容。

2.3 事务消息:把本地事务和消息发送绑在一起

如果说同步发送解决的是“消息要真正发出去”,那事务消息解决的是“业务操作和消息发送必须同时成功或同时失败”。比如下单场景,本地写入订单表成功后,再发送一条消息通知下游服务。如果订单写成功但消息发送失败,两边就不一致了。RocketMQ的事务消息提供了一套半消息机制:

  1. 生产者先发送一条“半消息”(half message)到Broker,这条消息对消费者不可见。
  2. 生产者执行本地事务,比如写入订单表。
  3. 本地事务成功则提交(commit)半消息,事务失败则回滚(rollback)。
  4. 如果步骤3中生产者没有明确提交或回滚,Broker会定期回查生产者本地事务的状态,根据回查结果决定消息的最终命运。

这套机制的核心价值是:消息的可见性和本地事务的成败被绑定在一起,不会出现“业务成功但消息缺失”或者“业务失败但消息被消费”的情况。我在做资金类项目时,对账消息、通知消息都改用事务消息之后,因为“本地成功但发送失败”导致的数据不一致问题几乎归零。

不过事务消息也不是银弹,它的性能开销明显高于普通同步发送,而且需要实现executeLocalTransaction和checkLocalTransaction两个方法,回查逻辑必须保持幂等,能根据业务主键快速判断本地事务状态。如果不需要这种强一致,不要滥用事务消息,同步发送加补偿机制通常就够用了。

3. Broker存储阶段为什么会丢:刷盘和复制决定物理可靠性

3.1 异步刷盘还是同步刷盘,丢数据的窗口期在哪

生产端把消息发给Broker之后,Broker会先把消息顺序写入CommitLog文件。这里有个关键概念:写入CommitLog并不等于写入磁盘。操作系统会把数据先写到PageCache(页缓存),然后再由内核异步地刷到物理磁盘。这就是异步刷盘(ASYNC_FLUSH)模式:Broker只要把消息写入PageCache就返回成功,性能很好,但一旦机器突然断电或者宕机,PageCache里还没落盘的那部分消息就会丢掉。

而同步刷盘(SYNC_FLUSH)模式,会把消息真正刷到物理磁盘之后才返回成功。两者对比:

配置项性能表现可靠性适用场景
flushDiskType=ASYNC_FLUSH高,写入PageCache即返回断电/宕机可能丢最近1秒内的消息日志、监控、非核心通知
flushDiskType=SYNC_FLUSH明显下降,约降低50%左右消息落盘后才确认订单、支付、库存等核心数据

这里要特别说明一个误区:很多人以为只要用了同步刷盘就不会丢,但如果主从复制是异步的,主节点刚刷完盘还没等从节点同步,主节点就宕机了,这条消息虽然在自己的磁盘上,但从节点没拿到,如果主节点无法恢复,消息依然“丢了”。所以Broker层面的可靠性,刷盘和复制必须一起看。

3.2 主从复制:异步复制的坑比你想象的深

RocketMQ的主从复制有两种模式:异步复制(ASYNC_MASTER)和同步复制(SYNC_MASTER)。

  • 异步复制:Master写入CommitLog并刷盘后,立即向生产者返回成功。Slave是通过异步线程去同步数据的。这种方式吞吐高,但如果Master宕机时Slave还没有同步完最近的数据,这部分消息就物理丢失了。
  • 同步复制:Master刷盘完成后,会等待Slave确认也写入成功,再向生产者返回成功。这样消息在主从至少各有一份,Master宕机后Slave可以直接接管。
部署模式数据冗余Master宕机数据安全写入性能
异步复制有延迟窗口可能丢数据高
同步复制实时双写基本不丢相对较低

在同步复制模式下,还有一个细节必须注意:如果Master配置了SYNC_MASTER,但是集群里没有可用的Slave,生产端的写入会直接报错SLAVE_NOT_AVAILABLE,消息根本发不进去。这反而是一种“必然失败”的提示,好过悄无声息地丢数据。所以配置同步复制之前,务必确认集群拓扑是真的有从节点。

3.3 不丢消息的Broker配置组合:刷盘加复制一起锁死

如果业务对消息可靠性要求很高,比如资金、订单、库存,我建议Broker端至少做到“同步刷盘 + 同步复制”。在broker.conf里这样配置:

flushDiskType = SYNC_FLUSH brokerRole = SYNC_MASTER

这套组合意味着每条消息必须写入Master磁盘并同步到Slave后,生产端才会收到成功响应。可靠性很高,代价是写入吞吐有明显下降。我在一个订单系统里实测过,同型号物理机、同Topic、同消息体大小,从“异步刷盘+异步复制”切到“同步刷盘+同步复制”,单Broker写入TPS大约下降了40%左右。所以不是所有消息都需要这么重的配置,日志型消息完全可以保持异步刷盘、异步复制。

如果还要更进一步,可以考虑DLedger模式(基于Raft协议的多副本方案)。DLedger通常部署3个节点,写入需要多数派(至少2个节点)确认,相当于多副本强一致,节点宕机后自动选主,不需要人工干预。它的可靠性和自动化程度比“同步刷盘+同步复制”更高,但对硬件和网络的要求也更高,适合核心集群使用。

这里还要提醒一句:不要忽略磁盘本身。即使是同步刷盘,如果用的是机械磁盘,写入延迟会非常难看,建议核心集群使用SSD。另外磁盘写满也是导致消息丢失的一个隐患——CommitLog写入失败时,如果生产端没有适当重试,消息就会在发送阶段丢失。

4. 消费阶段的“丢”:很多其实是offset和业务代码的问题

4.1 offset提交的时机不对,消费再成功也没用

消息到了Broker,消费者拉取到之后,还需要确认消费成功。RocketMQ的PushConsumer在处理消息时,消息回调返回CONSUME_SUCCESS之后,Broker才会推进消费位点(offset)。这里最容易出问题的是手动提交offset的场景。

手动提交模式下,如果你在业务执行之前就提交了offset,一旦业务逻辑执行失败,消息就再也不会被拉取到了——这就是消费阶段的“丢消息”。反过来,如果在消费成功后忘记提交offset,消息会被重复消费,这属于“重复”问题,需要靠幂等解决。

比提交时机更隐蔽的问题,是消费回调里“吞异常”:

consumer.registerMessageListener((MessageListenerConcurrently) (msgs, context) -> { for (MessageExt msg : msgs) { try { processOrder(msg.getBody()); } catch (Exception e) { // 业务处理失败,但没有返回RECONSUME_LATER,而是返回成功 log.error("process order failed", e); } } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; });

这段代码的问题非常经典:业务处理抛了异常,被catch住之后,消费端依然返回CONSUME_SUCCESS。在Broker看来,这条消息已经被成功消费,offset正常推进,但订单状态根本没更新。从监控上看,消费组没有积压,消息轨迹显示CONSUMED,一切正常,唯独业务数据不对。这种“假成功”在排查时最难发现,因为所有技术指标都是正常的。

正确的做法是:消费逻辑里的业务异常不能被吞掉,处理失败就返回RECONSUME_LATER,让消息进入重试流程。除非这个异常已经确认是永久性失败(比如数据格式错误),才考虑记录后返回成功并人工补齐。

4.2 消费失败后消息去哪了:重试队列和死信队列

消费失败并返回RECONSUME_LATER之后,消息并不会重新投递给你,而是进入该消费者组对应的重试队列(topic为%RETRY%消费组名)。RocketMQ会对重试消息的投递时间做升幂控制:先隔几秒投递一次,然后逐渐拉长间隔,默认情况下重试16次之后如果依然失败,消息会被扔进死信队列(topic为%DLQ%消费组名)。

死信队列里的消息,消费者组不会自动订阅,也就是说,如果没有人工介入,这些消息就会一直躺在DLQ里,业务侧表现为“消息彻底没了”。我在实际项目中,会专门写一个告警任务,持续监控所有%DLQ%开头的topic,一旦有消息进入就立刻报警,再由运营或开发人员从死信队列里把消息捞出来,重新投递到原始topic,或者直接调用业务接口补数据。

这里还要提一个很多人踩过的坑:如果消费端线程池里的线程因为业务代码里的第三方调用长期阻塞,比如HTTP调用超时设置不合理,导致消费线程一直被占用,消息既没有消费成功,也没有重试,而是长期“卡”在消费中。从业务角度看,消息就像是丢了。这种问题通常伴随消费组积压持续上涨,排查时要结合线程栈去看消费端到底卡在哪个调用上。

4.3 消费端幂等:最后的保险,把重复消费拦在外面

RocketMQ的消息投递语义是At Least Once(至少一次),也就是说同一条消息可能会被投递多次。触发重复消费的原因很多,比如消费者在消费成功后、offset上报前宕机了,重启后消息会被重新投递;再比如消费组发生重平衡(rebalance)时,队列重新分配也会导致消息被重复拉取。

这种情况下,如果不做幂等,重复消费可能会造成重复下单、重复发券、金额重复计算等严重问题。有些人把这当成“消息丢失”来排查,其实方向就错了。幂等设计的思路一般是:

  • 数据库唯一索引:用消息里的业务主键(比如orderId)做唯一约束,插入时若冲突则忽略。
  • 业务状态机判断:处理前先查订单当前状态,只有“待支付”才能执行“支付成功”的逻辑,状态变了就直接跳过。
  • Redis去重:处理前先SETNX,利用Set操作的原子性,确保同一条消息只处理一次。

我在实际项目里用最多的是“业务状态机 + 数据库更新条件”双重保证:更新订单表时带上status = '待支付'的条件,如果影响行数为0,说明已经被其他消息处理过了,直接返回消费成功即可。这套方案完全不需要引入外部存储,逻辑也简单。

5. 一次消息丢失的完整排查实战

5.1 快速定位工具与命令

排查消息丢失,我最常用的工具是RocketMQ Dashboard和mqadmin命令行工具。建议线上环境提前开启消息轨迹(trace)功能:Broker端在broker.conf里设置traceTopicEnable=true,生产者和消费者初始化时分别调用setEnableMsgTrace(true)。开启后,每一条消息从生产到消费的状态变化都会被记录到专门的轨迹topic里,排查时可以直接看到消息发送成功没有、消费成功没有、每次重试的时间和原因。

常用命令如下:

# 根据消息ID查询消息详情 mqadmin queryMsgById -i 7F00000100002B9F0000000000033B4A # 根据业务key查询消息,生产端发送时通过setKeys设置的业务主键 mqadmin queryMsgByKey -t order_topic -k ORDER20240618001 # 查看某个消费组的消费进度 mqadmin consumerProgress -g order_consumer # 查看某个topic各队列的最大位点 mqadmin topicStatus -t order_topic

有了这些命令,结合消息轨迹,先花五分钟定位消息丢在哪个环节,再去翻对应服务的代码和日志,效率会高很多。

5.2 实战记录:一条订单消息消失的排查路径

说一个我半年前处理过的真实案例,某电商系统中,用户支付成功后订单状态一直不更新。排查过程如下:

第一步,用订单号作为消息key,在Dashboard里查询order_topic中的消息。结果发现这条消息确实存在,轨迹显示最后一次状态是CONSUMED。也就是说Broker认为消费已经成功了。

第二步,到消费端服务里查业务日志,发现根本没有处理这条订单的日志记录。那就奇怪了,Broker显示已消费,业务却没执行。继续翻日志,终于在当天的异常日志里找到了关键信息:消费逻辑在处理时抛了一个NullPointerException,但是被catch住,并且最终返回了CONSUME_SUCCESS。

第三步,确认根因后,修复消费代码,把异常处理改成返回RECONSUME_LATER,然后把积压的这几十条订单消息从Broker里捞出来,通过脚本重新投递到order_topic,业务很快补上了。

如果第一步在Dashboard里压根查不到消息,那就说明问题在更靠前的环节:要么生产端没发出去(查生产日志的SendResult和异常),要么消息在Broker存储阶段丢了(查Broker配置和宕机记录)。

排查时还要检查一下消费组的订阅关系。有个高频翻车点:同一消费组内不同机器订阅了不同的tag,RocketMQ的订阅关系以最后注册的为准,部分消息会被过滤掉,表现也是“消息消失”。这种问题在控制台的“消费者订阅关系”页面一眼就能看到。

5.3 从一次故障延伸出来的预防体系

排查完一次消息丢失,我更建议你把“事后查”变成“事前防”。我自己沉淀下来的一套预防机制包括:

  • 消费积压监控:消费组堆积数量超过阈值立刻告警,这是发现“消费者卡住或假死”最直接的手段。
  • 死信队列监控:持续监听所有%DLQ%开头的topic,一旦有消息进入,自动报警并拉起人工处理流程。
  • 生产端发送告警:发送异常或者返回非SEND_OK状态时,立刻通过日志和监控系统暴露出来,不让异常被吞掉。
  • 定期巡检消费组:检查订阅关系是否一致、消费位点是否存在异常跳动、消费者实例是否都健康。
  • 全链路消息轨迹:所有核心业务消息开启trace,让每条消息的生命周期都可视化。

这套机制跑起来之后,很多消息异常会在用户感知之前就被发现,而不是等到第二天业务对不上账才去翻查。

6. 总结:从配置到代码的“不丢消息”清单

6.1 推荐配置与参数组合

针对不同可靠性要求,我把配置组合整理成一个速查表格,可以直接参考:

环节核心业务(订单/支付/资金)一般业务(通知/活动)日志型(监控/埋点)
生产端发送方式同步发送,超时5000ms,重试3次同步发送或异步发送单向发送
生产端失败处理抛异常+本地补偿表回调告警+重投不需处理
Broker刷盘SYNC_FLUSHASYNC_FLUSHASYNC_FLUSH
Broker复制SYNC_MASTER或DLedgerASYNC_MASTERASYNC_MASTER
消费端异常处理返回RECONSUME_LATER返回RECONSUME_LATER默认即可
消费幂等必做,状态机+唯一索引建议做可省略
消息轨迹开启开启按需

这套组合不是越重越好,而是根据业务对数据一致性的容忍度来做取舍。日志丢几十条没人管,订单消息丢一条可能就是一次资损事故。

6.2 代码层与架构层的最佳实践

从代码和架构层面,我总结了几个让“消息不丢”更稳妥的设计原则:

  • 消息不能作为唯一的业务状态存储。关键数据一定要落数据库,消息只是触发后续动作的载体。数据库里有订单状态,哪怕MQ整个集群不可用,你也可以用定时任务从数据库扫描未处理的记录,重新发送消息或直接补调用。
  • 关键消息发送前先落库。建一张消息发送记录表,状态从“待发送”到“已发送”,定时任务扫描超过N分钟还是“待发送”的记录,重新投递。这套方案配合消费端幂等,能兜住绝大多数发送阶段的问题。
  • 消费逻辑做到可重入。每次消费前先判断业务状态是否已经满足,判断属于哪一步,再决定往下走还是直接返回成功。
  • 给每条业务消息设置业务key。发送时通过setKeys(orderId)设置业务主键,这样即使链路断了,也能按业务维度快速检索消息轨迹。

6.3 我的个人经验与教训

说句实话,排查过这么多次消息问题,我越来越觉得“消息丢失”四个字里,真正因为RocketMQ本身故障导致的丢失少之又少,绝大多数都是某一个环节的失败被静默处理掉了。有的是生产端异常被吞,有的是消费端catch住异常还返回成功,有的是配置了异步复制然后Master物理机直接起不来。RocketMQ能给你的,是“至少一次”的投递保证,而不是“恰好一次”的魔法。

我个人在实际操作中的体会是:不要试图在可靠性上追求极致的“一次都不丢”,那是成本极其高昂的奢望。更务实的思路是承认“可能丢”和“可能重复”这两个事实,然后把它们分别解决掉——用同步发送、同步刷盘、同步复制把丢失概率压到极低,再用消费幂等把重复带来的影响消除干净,最后用一套兜底任务给最坏情况兜底。能做到这三步,你的消息链路就基本不会再出现“业务静默失败”这种让人半夜爬起来查日志的问题了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询