RocketMQ 消息积压千万级如何快速恢复 生产环境踩坑实录

开发 前端
回顾这次战斗,我最大的感悟是:处理积压不是“头痛医头脚痛医脚”,而是要从“生产 - 存储 - 消费”全链路分析。比如这次我们只盯着消费者扩容,却忽略了 Producer 端的流量控制,以及 Broker 端的磁盘 IO 瓶颈。

兄弟们,凌晨三点,手机像被扔进了洗衣机,在枕边疯狂震动。我摸索着接起电话,运维小哥带着哭腔:“哥,订单系统的 RocketMQ 集群炸了,积压量直奔两千万,现在支付回调全堵着,客服已经收到二十多个商家投诉了!”  

我腾地坐起来,脑子还没完全清醒,手指已经条件反射地打开监控页面。好家伙,Consumer 集群的平均消费速度只有 500 条/秒,而堆积量正以肉眼可见的速度往上窜。再看 Producer 端,TPS 倒是稳如老狗——问题出在消费者这边,典型的“生产快、消费慢”导致的积压惨案。  

这不是我第一次跟 RocketMQ 积压打交道,但千万级的规模还是头一回。摸着键盘的手有点抖,废话不多说,咱们边复盘边聊,看看这种级别的积压到底该怎么破。  

一、先别急着“重启服务器”,先把积压原因盘清楚

很多同学遇到积压第一反应是“扩容消费者”,但盲目扩容前必须先搞清楚:到底是什么卡住了消费速度? RocketMQ 的消费链路就像一条流水线,任何一个环节“堵车”都会导致积压,咱们得先给这条流水线做个“CT 扫描”。  

1. 第一步:排查 Consumer 是不是“假死”状态

打开 RocketMQ Dashboard(没有的同学赶紧装,运维必备神器),先看 Consumer 分组的“在线客户端”列表。如果发现某台服务器的 Consumer 长时间没有上报心跳(LastHeartbeatTime 超过 2 分钟),恭喜你,大概率遇到了“消费者假死”。  

这种情况通常是因为消费者线程被 Full GC 卡住,或者代码里有死循环。我这次就碰到一台服务器,因为日志打印太猛(没错,就是某个同事在循环里写了 System.out.println),导致 CPU 100%,Consumer 线程直接卡死,积压量像滚雪球一样越滚越大。  

踩坑提醒:记得给 Consumer 加上 JVM 监控,重点看 GC 频率和耗时。我后来发现那台假死机器的 Young GC 耗时居然超过 500ms,老年代频繁 Full GC,这种情况下 Consumer 能正常工作才怪。  

2. 第二步:检查队列负载是否均衡

RocketMQ 的 Consumer 采用“队列均分”策略,每个 Consumer 会分配多个 Message Queue(以下简称 MQ)。如果某台 Consumer 分配了 100 个 MQ,另一台只分配了 10 个,那肯定是“忙的忙死,闲的闲死”。  

怎么看负载情况?Dashboard 里每个 Consumer 实例的“已分配队列数”一目了然。我这次就发现,有三台新扩容的服务器因为网络配置问题,没连上 NameServer,导致老服务器承担了 80% 的队列,消费能力直接被压垮。  

实操技巧:如果发现队列分配不均,先重启 Consumer 实例(触发重新负载均衡),如果还不行,检查 Consumer 分组的配置,确保 consumeFromWhere 和 messageModel 设置正确(默认 CLUSTERING 模式下会自动均衡)。  

3. 第三步:看看消费线程是不是“摸鱼”

RocketMQ Consumer 的默认消费线程数是 20(对,没错,就是这个藏得很深的参数 consumeThreadMin 和 consumeThreadMax)。如果你的业务逻辑比较复杂,比如需要查数据库、调接口,20 个线程可能根本不够用,导致大量线程在排队等待处理。  

我这次查看 Consumer 日志,发现线程池里的任务堆积量超过 1000,而实际在工作的线程只有可怜的 10 个——原来同事在初始化 Consumer 时,手滑把 consumeThreadMin 写成了 10,Max 也设成 10,相当于固定只有 10 个线程在干活,面对突然暴增的流量,自然顶不住。  

划重点:消费线程数不是越多越好,要看 CPU 核心数(一般设置为 CPU 核心数的 2 - 3 倍)。如果是 IO 密集型任务,可以适当多开,比如设到 50;如果是 CPU 密集型,超过 32 基本没意义,反而会因为线程上下文切换拖慢速度。  

二、千万级积压的“急救三连招”,先把积压量压下来

搞清楚原因后,接下来就是“急救阶段”。记住:千万级积压时,任何微小的优化乘以千万都会放大成显著效果。咱们分步骤来,先让消费速度追上生产速度,再慢慢“消化”历史积压。  

1. 第一招:临时扩容 Consumer,先把“车道”拓宽

这是最直接的办法,相当于给高速公路多开几条车道。RocketMQ 的 Consumer 是“无状态”的,理论上可以无限扩容,但要注意两个关键点:  

(1)扩容数量不超过 MQ 总数

每个 MQ 同一时间只能被一个 Consumer 消费,比如集群有 100 个 MQ,最多开 100 个 Consumer 实例(每个实例分配 1 个 MQ)。我这次集群有 200 个 MQ,当前只有 10 个 Consumer,理论上可以先扩容到 50 个实例,把队列分配率拉满。  

(2)别踩“IP 不对等”的坑

之前扩容时,运维小哥直接复制了老服务器的配置,结果新服务器的 clientIP 被错误设置成了内网 IP,而 NameServer 在公网,导致 Consumer 注册时,其他实例根本找不到它。最后不得不手动加上 rocketmq.client.endpoint 参数,指定公网地址才解决。  

 实操步骤:  

  • 临时创建一个新的 Consumer 分组(比如加个后缀 -tmp),避免和原有消费者抢资源  
  • 启动时指定 --consumerThreadMin 50 --consumerThreadMax 50(临时调高线程数)  
  • 观察 Dashboard 上的“消费速度”,理想情况下,每台新服务器能分到 4 - 5 个 MQ,消费速度能提升 3 - 5 倍  

2. 第二招:开启批量消费,让消费者一次“搬多箱货”

RocketMQ 支持批量消费,默认每次拉取 1 条消息(参数 consumeMessageBatchMaxSize 默认为 1)。如果你的业务逻辑允许,可以改成一次拉 10 - 32 条,减少网络交互次数,提升吞吐量。  

我这次把这个参数改成 16,配合前面的扩容,消费速度从 500 条/秒直接跳到 8000 条/秒——相当于原来每次跑一趟搬 1 箱货,现在搬 16 箱,效率自然飙升。但要注意:  

(1)批量处理时保持幂等性

因为可能会重复消费(比如处理到第 10 条时消费者挂了,重启后这 16 条会重新消费),所以业务代码必须支持幂等(比如用唯一 ID 去重)。我们当时就吃了亏,没做幂等,导致数据库出现重复订单,最后不得不写脚本去重,血的教训!  

(2)别贪心设太大的值

超过 32 之后,吞吐量提升不明显,反而会增加内存压力(每条消息都会存到内存里)。我们试过设成 100,结果 Consumer 内存使用率瞬间超过 80%,差点触发 OOM,最后稳定在 16 - 32 之间最佳。  

3. 第三招:暂停 Producer 或限流,先“掐断源头”

如果积压量实在太大,比如像我们这次已经到两千万,而消费速度一时半会儿追不上,可以考虑暂时让 Producer 停止发消息,或者降低发送频率。  

注意:暂停 Producer 前一定要和业务方沟通,我们当时是电商大促期间,暂停支付回调消息会影响商家收款,最后只能和前端商量,在用户支付成功页增加“稍后刷新”提示,同时对 Producer 做限流(从 2000 TPS 降到 500 TPS),给消费者争取缓冲时间。  

踩坑提醒:暂停 Producer 后,记得监控 Consumer 的“堆积量”是否开始下降(理想情况下每分钟下降 10 - 20 万)。如果没变化,可能是消费者有重试逻辑在反复投递(比如消息处理失败后进入重试队列,导致积压量“假死”),这时候需要检查 maxReconsumeTimes 参数(默认 16 次,超过后进入死信队列)。  

三、积压消化中的“连环坑”,每一步都可能翻车

当积压量开始下降,千万别掉以轻心,这时候往往会遇到各种“隐性炸弹”。我们这次就踩了三个大坑,每个都让我在凌晨四点的会议室里冒冷汗。  

1. 坑一:消费过快导致“内存溢出”

前面提到我们把批量消费设成 16,线程数开到 50,消费速度确实上去了,但半小时后,一台 Consumer 突然挂掉,日志里写着“java.lang.OutOfMemoryError: GC overhead limit exceeded”。  

原因分析:  

  • 批量消费时,每条消息都会解析成 Java 对象,16 条一批,每秒处理 500 批,每秒产生 8000 个对象  
  • Consumer 堆内存默认只有 1G(很多同学不知道,RocketMQ 的 Consumer 启动脚本默认堆大小是 -Xms1g -Xmx1g),新生代很快被占满,触发频繁 Full GC  

解决方案:  

  • 给 Consumer 增加内存,改成 -Xms4g -Xmx4g(根据服务器配置调整,建议不超过物理内存的 70%)  
  • 调整 JVM 参数,比如 -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m,避免元空间溢出  
  • 最重要的:在业务代码里及时释放对象引用,比如处理完消息后把对象设为 null,帮助 GC 回收  

2. 坑二:网络带宽成了“最后一公里瓶颈”

当积压量降到 500 万时,消费速度突然卡住,无论怎么扩容都上不去。检查服务器监控,发现网卡吞吐量达到 90%(我们用的是 1G 带宽的服务器)。  

解决思路:  

RocketMQ 的消费流程是“拉取消息”→“处理消息”→“提交消费位点”,其中“拉取消息”是网络密集型操作。当批量拉取 16 条消息时,每条消息平均 1KB,一次拉取就是 16KB,每秒 500 次拉取就是 8MB/s,50 个 Consumer 实例就是 400MB/s,接近 1G 带宽的极限。

实操方案:  

  • 给部分 Consumer 实例更换成 10G 带宽的服务器(临时采购,有钱任性)  
  • 调整拉取策略,比如增加 pullBatchSize 参数(默认 32,我们设成 64,减少拉取次数)  
  • 对消息体进行压缩(Producer 发送时启用 GZIP 压缩,Consumer 自动解压缩),我们的消息体从平均 1KB 压缩到 300 字节,带宽占用直接降了 70%  

3. 坑三:死信队列突然“爆炸”,积压量反弹

当积压量降到 100 万时,运维小哥突然喊:“死信队列里的消息怎么突然多了 50 万?” 原来是很多消息重试 16 次后仍失败,自动进入死信队列,而我们没有配置死信队列的消费者,导致这部分消息被“遗忘”了。  

处理步骤:  

  1. 先暂停死信队列的自动投递(在 RocketMQ 控制台找到死信队列对应的 Consumer 分组,设置 maxReconsumeTimes 为 0,停止重试)  
  2. 写一个临时消费者,专门消费死信队列,把消息内容记录到日志文件,然后人工排查失败原因(我们发现大部分是因为数据库连接超时,凌晨数据库负载高导致)  
  3. 修复业务代码后,把死信队列的消息重新发送回原队列(用 RocketMQ 的 sendMessageInTransaction 接口,避免重复消费)  

经验教训:死信队列是“最后的防线”,平时一定要监控死信队列的堆积量,建议设置报警阈值(比如超过 1000 条就提醒),否则等问题爆发时,又是一场硬仗。  

四、积压恢复后的“亡羊补牢”,避免下次再掉坑

经过 6 个小时的奋战,积压量终于归零,看着监控曲线慢慢 flatten 下来,我揉着酸痛的脖子,开始总结这次的教训。其实很多问题都是可以提前预防的,以下是我梳理的“防积压三板斧”,建议写进运维手册:  

1. 事前:给 Consumer 装“仪表盘”,实时监控关键指标

别只看“堆积量”,这几个指标更重要:  

  • 消费延迟(consumeLatency):消息产生到被消费的时间差,超过 10 秒就该警觉  
  • 拉取吞吐量(pullTPS):如果突然下降 50%,可能是网络或 Broker 有问题  
  • 消费线程池利用率:用 ThreadPoolExecutor.getActiveCount() 监控,长期接近 consumeThreadMax 说明线程不够用   

我们后来给每个 Consumer 实例加了 Prometheus 监控,配合 Grafana 仪表盘,现在积压预警比心跳还准。  

2. 事中:准备“应急预案模板”,让新人也能快速上手

把这次的急救步骤写成脚本:  

# 快速扩容消费者脚本 
for i in {1..50}; do  
  java -jar consumer.jar \ 
  --consumerGroup order_consumer_tmp \ 
  --namesrvAddr xxx:9876 \ 
  --consumeThreadMin 50 \ 
  --consumeMessageBatchMaxSize 16 \ 
  --clientIP $(curl ifconfig.me) & # 自动获取公网 IP 
done  

# 暂停死信队列脚本 
curl -X POST http://rocketmq-dashboard:8080/consumer/update \ 
-H "Content-Type: application/json" \ 
-d '{"groupName":"order_consumer","maxReconsumeTimes":0}'
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.

记住:应急预案一定要定期演练,我们后来组织了一次“积压模拟演练”,发现脚本里居然有路径错误,还好提前发现,否则实战时又要抓瞎。  

3. 事后:给消息处理加“保险丝”,防止单个消息拖垮整体

这次发现,90% 的消费延迟都是因为个别“毒消息”(比如超大消息体、格式错误)导致的。解决方案:  

  • 设置消息处理超时时间:用 CompletableFuture 包装业务逻辑,超过 500ms 自动放弃,记录到日志  
  • 隔离消费线程池:给不同类型的消息分配不同的线程池,比如订单消息和日志消息分开处理,避免互相影响  
  • 增加本地重试机制:业务代码里先重试 3 次,失败后再交给 RocketMQ 的重试队列,减少 Broker 压力   

我们给订单消费加了本地重试后,RocketMQ 的重试次数下降了 60%,消费速度稳定多了。  

五、总结:千万级积压不可怕,怕的是没有“系统化思维”

回顾这次战斗,我最大的感悟是:处理积压不是“头痛医头脚痛医脚”,而是要从“生产 - 存储 - 消费”全链路分析。比如这次我们只盯着消费者扩容,却忽略了 Producer 端的流量控制,以及 Broker 端的磁盘 IO 瓶颈。  


责任编辑:武晓燕 来源: 石杉的架构笔记
相关推荐

2024-08-02 10:55:30

2025-03-28 08:40:00

C#异步编程

2024-10-09 08:09:11

2021-06-09 08:21:14

Webpack环境变量前端

2024-11-20 18:16:39

MyBatis批量操作数据库

2021-06-26 15:31:25

Dubbo应用级服务

2023-12-21 08:01:41

RocketMQ消息堆积

2022-03-31 08:26:44

RocketMQ消息排查

2022-03-14 11:05:01

RocketMQRedis缓存

2025-02-08 08:42:40

Kafka消息性能

2024-04-23 08:46:45

消息积压KafkaMQ

2025-04-02 08:17:42

2024-12-12 14:56:48

消息积压MQ分区

2023-08-03 07:13:59

2022-02-07 08:55:57

Go程序代码

2022-06-27 11:20:13

工具内存GO

2020-09-15 08:46:26

Kubernetes探针服务端

2023-01-18 23:20:25

编程开发

2017-10-24 13:02:29

2023-08-31 08:10:18

点赞
收藏

51CTO技术栈公众号