1. 为什么生产环境的RocketMQ总是“莫名其妙”出问题
我最早接触RocketMQ是公司业务量上来之后,Kafka那边几个大Topic的消费延迟开始频繁报警,架构组决定把一部分交易类消息切到RocketMQ上。当时想得很简单:RocketMQ文档全、社区活跃、阿里内部大规模用过,应该比我们之前自己拼出来的那套MQ省心很多。结果上线第二个月就开始“学做人”,消费堆积、消息重复、顺序乱掉、主从切换后写入超时,各种问题接二连三地冒出来。
后来我慢慢意识到,RocketMQ本身作为一款消息中间件,它的稳定性和功能完备性在开源产品里确实属于第一梯队,但生产环境里的故障,绝大多数不是“中间件坏了”,而是我们使用姿势不对:参数配置不合理、客户端API理解有偏差、容量预估严重不足、事务消息的使用时机搞错、以及最容易被忽略的——对RocketMQ底层存储和复制机制缺乏足够认识。
这篇文章不打算从“什么是RocketMQ”开始念文档,而是直接从我在生产环境里一次次踩坑、查日志、改配置、做压测的真实经历出发,把最常见的故障类型、根因分析思路、排查命令和处置方法整理出来。如果你正在做RocketMQ的运维、开发或架构设计,这篇文章应该能帮你少走很多弯路。哪怕你只是面试前临时抱佛脚,这里的实际案例也比背八股文有用得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 高频故障全景图与根因分类
2.1 消息丢失:最致命却也最容易误判
先说说最吓人的消息丢失。RocketMQ给人的印象是“比Kafka更可靠”,因为默认情况下它会做同步刷盘和主从同步复制,但这不代表消息绝对不会丢。生产里真正因为Broker崩溃导致commitlog文件损坏而丢消息的,我遇到过,但非常少。更多时候所谓的“丢消息”,其实是客户端逻辑问题。
第一个典型案例是异步发送失败后没有处理回调。RocketMQ的DefaultMQProducer提供了同步发送、异步发送和单向发送三种方式。很多人图性能用异步发送,send方法传了一个SendCallback进去,回调里只写了成功分支,失败分支直接打一行log就完了,甚至有的连log都没写。等到业务反馈某条数据对不上账的时候,去Broker上查消息,发现这条消息当时根本没写进去,但日志里又找不到报错记录——因为失败回调被忽略掉了。
第二个典型是sendOneway。单向发送本来就是“发出去就不管”,它适合日志采集这类允许丢失的场景。但我在一些对账系统里见过有人用它发核心流水,理由是“我们之前压测发现单向发送吞吐最高”。压测吞吐高是真的,但丢了消息要补数的时候,再高的吞吐也救不了你。
第三个典型是producer端的超时设置。默认sendMsgTimeout是3000毫秒,大消息、频繁FullGC、网络抖动、Broker磁盘IO过高的情况下,发送超时抛异常,但消息实际上可能已经被Broker写入并返回了,也可能完全没写入。超时异常会诱导业务系统走重试,于是出现重复消息;如果业务系统把超时当成失败并直接抛错、不重试,就可能出现“看似写失败了但实际成功了”、下游一直没收到消息的半丢失状态。
我建议所有核心业务消息至少满足以下条件:使用同步发送或带可靠回调的异步发送;send失败必须走补偿,补偿可以是重试队列、本地事务表轮询,但绝对不能只打日志;客户端设置retryTimesWhenSendFailed,但不要把重试次数调到特别大,否则消息积压在producer内存里,反而拖垮发送线程。
2.2 消费堆积:从表象到底层链路的逐层定位
消费堆积可能是RocketMQ运维里遇到最多的故障类型。它本身不是“故障”,而是“症状”。堆积的根因可以分成几类:消费能力不足、消费线程卡死、消费逻辑异常、Rebalance抖动、Broker拉取性能下降。
先看消费能力不足。RocketMQ的并发度由两个因素决定:Consumer的消费线程数和消息队列数。很多人以为把consumeThreadMin调到100,消费速度就会提升10倍,其实不然。一个Consumer实例最多能分配的队列数是它订阅的所有Topic队列数的总和,如果Topic只有4个队列,哪怕你把线程数调到50,真正能同时被这个实例消费的也只有4个队列上的消息,其他线程都在空转。遇到这类堆积,先看Topic的队列数是否够多,再看Consumer实例数是否够多。增加队列数量要谨慎,因为队列数一旦定了,虽然可以在控制台手动扩展,但扩展后会触发Rebalance,过程中可能出现短暂重复消费。
再看消费线程卡死。我们遇到过Java服务里某个消费方法调用了外部HTTP接口,外部接口因为连接池被打满导致耗时30秒以上,而消费线程的默认超时时间consumeTimeout是15分钟,表面上不会触发重试,但消费线程被长期占住,新的消息进不来。如果外部接口长时间不恢复,消费线程池会被逐渐占满,最后整个ConsumerGroup的消费能力直接降为零。排查这类问题别一上来就看堆积,要看消费者JVM的线程栈,看看消费线程到底卡在哪里。
第三类是消费逻辑异常导致无限重试。RocketMQ的DefaultMQPushConsumer默认会重试16次,重试消息进入重试队列%RETRY%ConsumerGroupName,重试次数耗尽后进入死信队列%DLQ%ConsumerGroupName。如果消费逻辑里有个bug,比如反序列化失败、空指针、数据库主键冲突,每条消息都会走完16次重试后才进DLQ,这个过程会产生大量重试消费,并且会掩盖真实的业务异常。我见过一个团队每晚都在处理几千条DLQ消息,但他们从来不打开消费日志看具体异常,只是把消息重新投递回去,结果第二天继续DLQ,形成了一种“虚假繁忙”。
2.3 顺序消息与订阅关系不一致
顺序消息是RocketMQ比较有特色的能力,也是生产里容易“自以为做了顺序”但实际上没做对的地方。RocketMQ的顺序消息分全局顺序和分区顺序。全局顺序就是把Topic队列数设为1,性能上限很低,一般只用于日志顺序回放;分区顺序是通过MessageQueueSelector把同一业务ID的消息选择到同一个队列,然后使用MessageListenerOrderly消费。
实际中翻车最多的场景是“用了顺序消息但Producer端没做队列选择”。有人直接调用send(msg),消息被默认的轮询策略分发到不同队列,消费端虽然用的是MessageListenerOrderly,但顺序已经在上游打乱了。还有更隐蔽的:同一个订单的消息在同一个队列里,但消费端在监听器里启了多线程去处理,或者把消息放进了线程池,结果队列内顺序又被自己的并发处理打乱。顺序消息的代价比普通消息高,它要求同一队列的消息串行消费,性能和可用性都有一定牺牲,所以非必要不用。
订阅关系不一致的问题也特别容易踩。RocketMQ要求同一个ConsumerGroup下的所有Consumer实例,订阅的Topic和Tag必须完全一致。如果发布时A实例订阅了“OrderTopic”的TagA,B实例订阅了“OrderTopic”的TagB,那在Rebalance后,某些队列上的TagA消息会分配给B实例,而B实例的SubscriptionData里没有TagA的过滤条件,于是消息被直接过滤掉,表现为TagA消息“神秘丢失”。这个问题排查起来非常痛苦,因为Broker日志里不会报错,只有用mqadmin查看消费者订阅关系时才能发现不一致。
3. 故障排查的具体路径与实操命令
3.1 从监控指标到日志证据链
故障排查最忌讳一上来就翻Broker日志。RocketMQ有完整的三层监控数据:客户端指标、NameServer指标、Broker指标。先把这三层指标看一遍,基本能定位出问题的大致方向,再去翻日志验证。
客户端指标里最重要的是每个ConsumerGroup的消费延迟、堆积量、消费TPS、拉取TPS和消费失败次数。如果堆积量涨,但消费TPS也高,说明有大量消息在重试消费,问题大概率在消费逻辑;如果堆积量涨,消费TPS很低,说明消费端被卡住了,优先查线程栈;如果消费TPS正常、堆积量不涨但延迟很高,要查是不是存在大批量消息在凌晨集中到达,或者拉取消息后处理链路里有慢调用。
Broker指标里我特别关注三个:putMessageAverageSize、dispatchBehindBytes和remainHowManyDataToFlush。dispatchBehindBytes表示Produce写入CommitLog后,ConsumeQueue的异步转发落后了多少,正常情况下这个值应该接近0,如果持续上涨,说明Broker上ConsumeQueue构建速度跟不上写入速度,消费端拉取自然会出现延迟。remainHowManyDataToFlush表示待刷盘的数据量,如果这个值一直很高,说明磁盘IO可能顶不住了。
3.2 我用得最多的几个排查命令
RocketMQ安装包里自带一个mqadmin命令,位置在bin目录下。这个命令能做的事非常多,我在排障时最常用这几个:
bash复制# 查看某个ConsumerGroup的消费进度和堆积情况
mqadmin consumerProgress -n 127.0.0.1:9876 -g GroupName
# 查看指定Topic的消息队列数量、读写队列配置
mqadmin topicStatus -n 127.0.0.1:9876 -t TopicName
# 查看指定消息在Broker上的存储位置和消费状态
mqadmin queryMsgById -n 127.0.0.1:9876 -i MessageId
# 查看Broker的运行时状态,包括各类指标
mqadmin brokerStatus -n 127.0.0.1:9876 -b 127.0.0.1:10911
# 查看Broker上部署的所有Topic和队列情况
mqadmin topicList -n 127.0.0.1:9876
# 查看某个ConsumerGroup的订阅关系
mqadmin consumerSubscription -n 127.0.0.1:9876 -g GroupName
# 查看集群信息、Broker角色、读写权限
mqadmin clusterList -n 127.0.0.1:9876 -c ClusterName
我个人的习惯是先用clusterList确认当前集群拓扑,然后用consumerProgress看堆积情况,再用topicStatus确认队列数量是否合理。如果需要查某条具体消息为什么没被消费,会用queryMsgById查消息的存储信息,再用getMsgById的broker文件偏移量去检索消息在CommitLog里的位置,确认消息是否写入成功。
另一个容易被忽略的点是NameServer的日志。NameServer本身不存业务数据,但它负责管理Broker的路由信息。集群里Broker启动时会向所有NameServer注册,每30秒发送心跳。如果NameServer与Broker之间的网络有问题,或者Broker长时间无心跳被剔除,生产端在发送时会收到“No route info of this topic”的异常。这类问题排查时看NameServer的日志,里面会有“unregister”或“remove broker”之类的记录。
3.3 一个消费堆积案例的完整排查过程
这里分享一个处理过很多次的典型场景:某天晚上告警群突然弹出消费堆积通知,ConsumerGroup名为OrderPayNotifyGroup的堆积量从几百条迅速涨到几十万条。
我的排查顺序是这样的:
先用consumerProgress看这个Group整体的堆积分布。发现堆积分布在两个broker上的8个队列里,所有队列的consumerOffset都停在半小时前,而brokerOffset还在增长。这说明消费端已经有一段时间完全没消费了。接着看这个Group的消费TPS,发现consumeTps是0,拉取TPS也是0。这说明问题不在消费逻辑慢,而是Consumer根本没有在拉消息。
下一步看Consumer(这里是Java应用)的监控面板,发现Consumer实例还活着,JVM堆内存使用率不高,但垃圾回收次数有点异常。我立刻用jstack抓了线程栈,发现所有consumeMessage线程都阻塞在某个HTTPClient调用上,线程状态是TIMED_WAITING,而且这个HTTP调用的超时时间设置成了60秒。查了应用配置,发现这个HTTP服务是一个第三方优惠券服务,对方在晚上做全量数据迁移,接口直接hang住。
处置方式很简单:先把Consumer实例的流量摘掉(在RocketMQ控制台把该ConsumerGroup的消费线程暂停,或者直接停掉应用),等第三方服务恢复后重启应用。不过这个方案有个问题:暂停消费期间,消息会继续堆积在Broker上,如果堆积量太大,重启后可能发生Rebalance,容易重复消费。所以我在重启前先把消费线程数调低、消费每批消息的最大条数调低,等堆积量降到安全水位,再把参数调回来。
这个案例的技术含量不高,但很有代表性。生产环境的故障,很多时候不是中间件的问题,而是业务代码里的外部依赖卡住了消费链路。一定要在消费逻辑里对外部调用做超时控制和熔断,不能想当然地认为外部接口一定会返回。
4. 生产部署与配置层避坑指南
4.1 参数调优前先想清楚的问题
RocketMQ的配置项非常多,但大部分遵循默认配置就能跑得很稳。生产环境里出问题,往往是有人听说了某个大厂的经验,把配置改得过高或过低。
首先是堆内存和存储目录。RocketMQ Broker默认的堆内存是8G,如果你的机器是32G内存,建议按机器物理内存的1/4到1/2来设置JVM堆大小,剩余留给PageCache。CommitLog文件写入依赖操作系统的PageCache,如果堆内存设置得过大,PageCache被占用,写入性能会急剧下降。CommitLog目录和数据目录分开放置,不要放在同一个磁盘分区,否则磁盘IO会互相争抢。
第二个是刷盘策略。flushDiskType配置有两个值:SYNC_FLUSH和ASYNC_FLUSH。很多教程说生产环境必须用SYNC_FLUSH,其实要看SLAT级要求。SYNC_FLUSH意味着每次消息写入文件组后必须落盘才算写入成功,性能损耗非常明显,尤其是内存盘以外的普通SSD上。如果业务可以容忍磁盘故障时的少量消息丢失,用ASYNC_FLUSH完全可行,但要保证Broker有主从同步,并且主从复制使用SYNC_MASTER。如果两者都用ASYNC,那主节点宕机后消息丢失的概率就会显著上升。
第三个是自动创建Topic的开关。RocketMQ默认autoCreateTopicEnable在Broker端是开启的,生产环境我建议立即关闭。自动创建Topic的机制很容易导致误创建大量无用Topic,而且自动创建的Topic内部会使用默认的队列数,无法按业务调整。线上批量创建Topic应该由运维脚本或控制台完成,并且要规划好队列数、读写队列数、权限。
4.2 主从切换、Broker故障与数据一致性
RocketMQ的主从同步机制是很多新人容易搞混的地方。一个Broker组里有Master和Slave,Master负责读写,Slave负责备份。Broker的复制方式由brokerRole决定:SYNC_MASTER表示Master在返回写入成功前必须等待Slave复制完成,ASYNC_MASTER不等待Slave复制完成直接返回。
很多团队为了高可用把Broker配成SYNC_MASTER,这确实能保证Master宕机后Slave上的数据是最完整的,但也有两个代价:写入延迟会明显增加,另外如果Slave挂了,Master会拒绝写入还是继续写入?RocketMQ在SYNC_MASTER模式下,如果Slave不可用,Master会返回系统繁忙,这种情况下如果producer端没有做好异常处理,消息会直接失败。所以SYNC_MASTER不是银弹,它要求Slave的稳定性至少和Master一样高。
主从切换的默认机制是NameServer每10秒扫描一次Broker的存活状态。Master宕机后,Slave不会自动变更为Master,消费端在尝试从Master拉取失败后,会自动从Slave拉取,但只有读权限,不能写入。RocketMQ 4.5版本之后引入了DLedger模式,可以自动进行主从切换,但DLedger模式要求集群至少3个节点,运维成本更高,并且和普通主从模式在数据复制机制上有本质区别。如果你的系统真正需要分钟级自动切换能力,DLedger是值得考虑的;如果只是希望宕机后能手动把Slave提升为主节点读流量,普通的异步复制加监控告警就够了。
4.3 容器化部署RocketMQ的特别注意事项
现在很多团队用Docker Compose或Kubernetes部署RocketMQ,这种部署方式确实省事儿,但也带来了一些新的故障模式。
在Kubernetes里跑RocketMQ Broker,最核心的是存储。Broker的CommitLog、ConsumeQueue、IndexFile都必须挂载到持久化存储,绝不能使用容器本地存储。我之前见过有团队用emptyDir挂CommitLog目录,Pod一旦被重建,所有消息全部消失。更隐蔽的问题是,如果挂载的云盘性能波动,比如IO延迟突然升高,Broker的写入TPS会大幅下降,而容器层面的CPU和内存指标看起来都是正常的。
容器的网络模式也需要注意。RocketMQ Broker在向NameServer注册时会上报自己的IP和端口,如果Broker跑在容器里,使用port映射或NodePort方式暴露端口,那Broker上报的IP如果是容器内部IP,外部Client就连接不上。正确做法是让Broker注册宿主机IP和外部可访问的端口。在Kubernetes里,可以通过环境变量或配置项指定brokerIP1和listenPort。排查这类问题有一个明显特征:Producer发送超时,日志里有“connect to x.x.x.x:10911 failed”,但x.x.x.x是容器IP,在集群外无法访问。
RocketMQ的消费也不是天生弹性伸缩的。有很多人在K8s里使用HPA根据CPU指标自动扩容Consumer实例,但因为Rebalance机制的存在,实例数的变化会导致消费中的消息短暂重复。所以如果你的业务对消息重复非常敏感,在消费逻辑里一定要做幂等,这也是RocketMQ官方一直强调的事:消息中间件只能保证“至少一次”,不能保证“恰好一次”。
5. 常见问题速查表:问题、原因、处置
下面这个表格是我在实际维护中整理出来的速查表,遇到问题时可以直接对着找方向。
| 现象 | 常见原因 | 快速排查方式 | 处置建议 |
|---|---|---|---|
| Producer发送一直超时 | Broker负载过高、网络抖动、连接数打满 | 查看Broker的putMessageTimes和putMessageDistributeTime耗时分布 |
扩容Broker节点,检查是否开启大事务,优化消息体大小 |
| 消费堆积但消费TPS很稳定 | Topic队列数不足,Consumer实例数不够 | 执行consumerProgress查看队列数,对比Consumer实例数 |
合理增加队列数和Consumer实例数,不要盲目加线程 |
| 消费堆积且消费TPS持续走高 | 消费逻辑抛异常,消息反复重试 | 查看消费日志,重点看ConsumeMessageConcurrentlyService相关的报错 |
修复消费逻辑;排查是否是同一批消息反复进入重试队列 |
| 消费没有堆积但生产端报错 | 主从复制延迟大,SYNC_MASTER模式下Slave不可用 | 执行brokerStatus查看putMessageTimes和主从复制延迟 |
检查Slave是否存活,是否磁盘空间不足 |
| 消息能查到但消费端拉取不到 | 订阅关系不一致,Tag过滤条件不匹配 | 用consumerSubscription查看消费者订阅的Tag |
统一同一ConsumerGroup下所有实例的订阅关系 |
| 顺序消息偶发乱序 | Producer没有使用MessageQueueSelector,或消费端多线程 | 查看Producer代码,看是否调用select方法来选择队列 |
改用MessageQueueSelector,或使用MessageListenerOrderly消费者 |
| 从Broker查询消息报文件不存在 | 消息已被清理,或CommitLog文件损坏 | 查看Broker的deleteWhen和fileReservedTime |
调大文件保留时间,启用消息轨迹,评估是否需要增加存储空间 |
| Topic在控制台能看到,但客户端发送报no route info | 客户端使用的NameServer没有刷新路由,或Topic为自动创建但路由未同步 | 执行topicRoute确认Topic的路由信息 |
确认NameServer列表配置正确,重启客户端或等待路由刷新 |
这个表格里每一行背后都有真实的踩坑过程。比如“顺序消息偶发乱序”那行,之前有个同事把Producer的send方法包装了一层并发线程池,消息进线程池后再发送,结果本来在同一个队列里按顺序发送的消息,因为线程调度的关系乱掉了。排查了两天才定位到,原因是那层线程池改变了消息的发送顺序。顺序消息必须做到“发送前排序、发送选择队列、消费串行”三点合一,缺一不可。
6. 最后分享几点个人排查心得
先说一个关于日志的体会。RocketMQ的客户端日志如果配置了默认的logback或者其他RocketMQ client日志抽取功能,日志会输出到业务的日志文件里,但如果业务侧把日志级别设成了ERROR,很多info级别的关键信息会被直接吞掉。遇到问题时,先在日志里搜一下Rebalance、pullMessage、attempt to rebalance、group这些关键字,往往能快速看出问题。
第二个很关键的心得是:不要迷信控制台展示的“消费堆积”。控制台计算堆积的方式是从Broker获取ConsumerGroup的消费进度,再和消息最大偏移量做差。如果Consumer已经停止消费并且下线了,控制台依然会显示堆积量,但这不代表当前有消费者卡住。要配合Consumer在线状态一起看。
第三个是压测的重要性。很多线上问题源自没有做过对比压力测试。比如生产环境Broker默认的异步线程池、拉取线程数、队列数都是从测试环境搬过来的,但测试环境的机器规格和线上完全不同,到了线上就会出现Broker CPU不高但消费吞吐很低的情况。RocketMQ的消费链路需要“客户端线程池 + 网络IO + Broker文件IO + 网络IO”整体联合调优,单独调任何一个环节都收效甚微。
最后,说说对故障的心态。RocketMQ这类的中间件,故障并不可怕,可怕的是没有一套完整的“故障预案 + 复盘机制”。我建议每个团队都准备一份RocketMQ排障手册,内容包括:集群拓扑图、所有ConsumerGroup负责人、每类消息的SLA级别、常用的mqadmin命令、以及每个历史故障的复盘文档。遇到问题时,先把现象写清楚,再按“客户端 → 网络 → Broker → 存储”的顺序逐层排查,不要一开始就怀疑“MQ有bug”。绝大多数故障,最终都能在日常实践里找到明确的原因,并且通过调整使用方式彻底解决。
