兄弟们,凌晨三点,手机像被扔进了洗衣机,在枕边疯狂震动。我摸索着接起电话,运维小哥带着哭腔:“哥,订单系统的 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 次后仍失败,自动进入死信队列,而我们没有配置死信队列的消费者,导致这部分消息被“遗忘”了。
处理步骤:
- 先暂停死信队列的自动投递(在 RocketMQ 控制台找到死信队列对应的 Consumer 分组,设置
maxReconsumeTimes
为 0,停止重试) - 写一个临时消费者,专门消费死信队列,把消息内容记录到日志文件,然后人工排查失败原因(我们发现大部分是因为数据库连接超时,凌晨数据库负载高导致)
- 修复业务代码后,把死信队列的消息重新发送回原队列(用 RocketMQ 的
sendMessageInTransaction
接口,避免重复消费)
经验教训:死信队列是“最后的防线”,平时一定要监控死信队列的堆积量,建议设置报警阈值(比如超过 1000 条就提醒),否则等问题爆发时,又是一场硬仗。
四、积压恢复后的“亡羊补牢”,避免下次再掉坑
经过 6 个小时的奋战,积压量终于归零,看着监控曲线慢慢 flatten 下来,我揉着酸痛的脖子,开始总结这次的教训。其实很多问题都是可以提前预防的,以下是我梳理的“防积压三板斧”,建议写进运维手册:
1. 事前:给 Consumer 装“仪表盘”,实时监控关键指标
别只看“堆积量”,这几个指标更重要:
- 消费延迟(consumeLatency):消息产生到被消费的时间差,超过 10 秒就该警觉
- 拉取吞吐量(pullTPS):如果突然下降 50%,可能是网络或 Broker 有问题
- 消费线程池利用率:用
ThreadPoolExecutor.getActiveCount()
监控,长期接近consumeThreadMax
说明线程不够用
我们后来给每个 Consumer 实例加了 Prometheus 监控,配合 Grafana 仪表盘,现在积压预警比心跳还准。
2. 事中:准备“应急预案模板”,让新人也能快速上手
把这次的急救步骤写成脚本:
记住:应急预案一定要定期演练,我们后来组织了一次“积压模拟演练”,发现脚本里居然有路径错误,还好提前发现,否则实战时又要抓瞎。
3. 事后:给消息处理加“保险丝”,防止单个消息拖垮整体
这次发现,90% 的消费延迟都是因为个别“毒消息”(比如超大消息体、格式错误)导致的。解决方案:
- 设置消息处理超时时间:用
CompletableFuture
包装业务逻辑,超过 500ms 自动放弃,记录到日志 - 隔离消费线程池:给不同类型的消息分配不同的线程池,比如订单消息和日志消息分开处理,避免互相影响
- 增加本地重试机制:业务代码里先重试 3 次,失败后再交给 RocketMQ 的重试队列,减少 Broker 压力
我们给订单消费加了本地重试后,RocketMQ 的重试次数下降了 60%,消费速度稳定多了。
五、总结:千万级积压不可怕,怕的是没有“系统化思维”
回顾这次战斗,我最大的感悟是:处理积压不是“头痛医头脚痛医脚”,而是要从“生产 - 存储 - 消费”全链路分析。比如这次我们只盯着消费者扩容,却忽略了 Producer 端的流量控制,以及 Broker 端的磁盘 IO 瓶颈。