近期处理了一起由边缘业务引发的全局 RocketMQ 集群雪崩事故。故障现象非常典型:核心链路的 Producer 突然出现大量 [TIMEOUT_CLEAN_QUEUE]broker busy 和 system busy 报错,消息发送 P99 延迟从平时的 2ms 飙升到 3000ms 以上,最终触发限流降级,核心业务受损。
直接抛出结论:
这不是集群容量不足的问题,而是一次典型的“业务代码低级失误 + 底层机制连锁反应”引发的惨案。某业务团队滥用 MessageListenerOrderly(顺序消费),且在 Listener 中未做全局异常捕获。一条“毒药消息”(Poison Pill)触发空指针异常,导致该 MessageQueue 无限重试并被死锁。
随着积压加剧,Consumer 触发冷读(Cold Read),疯狂从磁盘拉取历史数据,引发底层 PageCache 颠簸(Thrashing)。这直接导致 Broker 写 CommitLog 时发生严重的 Major Page Fault(缺页中断),写入线程被阻塞,集群为了自我保护触发了 BrokerFastFailure 机制,全盘拒绝了所有 Producer 的写入请求。
解决这种问题,光靠扩容 Broker 是没用的,必须从业务消费逻辑兜底和 Broker 存储层防御两端同时下刀。
故障现场与排查推演
排查过程中,我们首先查阅了核心 Producer 的报错日志,满屏都是这个极其刺眼的异常:
MQBrokerException: CODE: 2 DESC: [TIMEOUT_CLEAN_QUEUE]broker busy, start flow control for a while, period in queue: 205ms, size of queue: 876
at org.apache.rocketmq.client.impl.MQClientAPIImpl.processSendResponse(MQClientAPIImpl.java:682)
看到 TIMEOUT_CLEAN_QUEUE,有经验的架构师脑子里应该立刻条件反射出它的触发机制:RocketMQ 的 BrokerFastFailure 后台线程会定时清理发送队列,如果发现请求在队列中等待处理的时间超过 200ms(默认值),就会直接丢弃该请求并返回 broker busy。
为什么会等待超过 200ms?说明 Broker 处理写请求的线程池卡住了。
我立即登录主 Broker 节点,用 vmstat 1 和 iostat -xz 1 扫了一眼,Load Average 飙到了 80+,CPU 使用率并不高,但 %wa (IO Wait) 高达 60%,磁盘 util 长时间顶在 100%。
查看 Broker 的 store.log,果不其然,刷盘耗时严重超标:
WARN flush disk log [CommitLog] cost: 450 ms
WARN flush commit log cost: 455 ms
RocketMQ 是基于 mmap 实现的高效顺序写,CommitLog 直接写入 PageCache,通常在微秒级。这种几百毫秒的延迟,说明 PageCache 被污染了,触发了严重的缺页中断,导致同步等待磁盘 I/O。
顺藤摸瓜,查看监控大盘的 Consumer Lag 指标,发现某非核心服务的滞后量达到了数百万条。
登录该业务的 Pod 抓取线程栈(jstack),发现大量的 ConsumeMessageThread 处于阻塞状态。
愚蠢的 Root Cause
翻看该业务的代码,血压直接飙升。他们为了保证所谓的“严格顺序”,使用了 MessageListenerOrderly,代码如下:
consumer.registerMessageListener(new MessageListenerOrderly() {
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
// 没有任何 try-catch 兜底逻辑
String payload = new String(msgs.get(0).getBody());
processStrictly(payload); // 这里抛出了 NullPointerException
return ConsumeOrderlyStatus.SUCCESS;
}
});
为什么这在普通消费中不是致命问题,但在顺序消费中却是灾难?
在普通并发消费(MessageListenerConcurrently)中,如果抛出异常或返回 RECONSUME_LATER,RocketMQ 会将消息发往 %RETRY%Group 的重试队列,并带有阶梯重试间隔,重试 16 次后进入死信队列(DLQ),当前队列会继续消费下一条消息。
但在顺序消费(MessageListenerOrderly)中,底层逻辑是严格保序的。为了防止乱序,如果 Listener 抛出异常或返回 SUSPEND_CURRENT_QUEUE_A_MOMENT,RocketMQ 会认为这条消息没处理完,绝对不会跳过它。它会将当前 MessageQueue 挂起,默认等待 1 秒后,再次投递这条一模一样的消息,陷入死循环(无限重试)。
在这个场景下:
-
队列被锁死:毒药消息无限重试,后续几万条正常消息全部被阻塞在该队列后面。
-
K8S 重启风暴:业务方发现积压,习惯性地去删 Pod 重启。Pod 的频繁上下线导致 Consumer Group 疯狂触发 Rebalance。在顺序消费模式下,Rebalance 需要向 Broker 申请分布式锁,频繁的锁争抢进一步增加了 Broker 的 CPU 压力。
-
冷读触发雪崩:因为消息积压时间太长,这些数据早就从 OS PageCache 中淘汰。当积压的队列试图拉取消息时,触发了大量的磁盘随机读取(冷读)。这些大量的冷读数据挤占了宝贵的 PageCache,导致 CommitLog 写入时找不到空闲页,触发 Major Fault 落盘,最终阻塞了全局的发送请求。
一段没有写 try-catch 的几十行边缘代码,直接干翻了整个大集群,这就是缺乏防御性编程意识的代价。
修复与底层防御加固
对于这种问题,必须实施双端改造。
1. 业务侧:顺序消费的防御性兜底
严禁在 MessageListenerOrderly 中裸奔。必须全局捕获异常,并设定自定义的最大重试次数(利用 Message 的 ReconsumeTimes 属性)。当重试超过阈值时,手工将其告警并写入本地死信表或旁路处理,强制返回 SUCCESS 让位给后续消息。
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
MessageExt msg = msgs.get(0);
try {
process(msg);
return ConsumeOrderlyStatus.SUCCESS;
} catch (Exception e) {
log.error("Consume orderly error, msgId: {}", msg.getMsgId(), e);
// 防御性编程:判断重试次数,避免无限阻塞队列
if (msg.getReconsumeTimes() >= 3) {
moveToCustomDLQ(msg); // 降级处理
return ConsumeOrderlyStatus.SUCCESS; // 强行放行
}
return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
}
}
2. Broker 侧:隔离冷热读写,保护 PageCache
即使业务再拉胯,基础设施也必须坚挺。调整 OS 和 Broker 配置以提升抗雪崩能力。
-
OS 层内核参数调优: 调整
vm.extra_free_kbytes和vm.min_free_kbytes,强制内核保留一定的空闲内存用于应对突发的 IO 请求分配,避免 Page Reclaim 引发阻塞。bash sysctl -w vm.zone_reclaim_mode=0 sysctl -w vm.swappiness=1 -
Broker 存储层调优: 强制开启预热和 mmap 内存锁定。 “`properties # 强制将 mmap 映射的内存锁定在物理内存中,避免被 Swap 出去 (mlockall) warmMapedFileEnable=true
开启异步刷盘下额外的堆外内存池。
写请求先写入 DirectByteBuffer,再异步 commit 到 PageCache。
极大地缓冲了 PageCache 抖动对 Producer 写入请求的影响。
transientStorePoolEnable=true “`
-
开启冷热分离(RocketMQ 5.x 推荐,或 4.x SSD+HDD 架构): 如果磁盘条件允许,将 CommitLog 和 ConsumeQueue 部署在高性能 NVMe 上,或者利用 RocketMQ 的 Cold Data 机制,将长期积压的数据下沉,确保热点读取完全命中内存。
排查清单 (同类问题速查)
-
[TIMEOUT_CLEAN_QUEUE] broker busy报错:意味着 Broker 处理写入请求的耗时超过 200ms。不要急于怀疑网络,第一优先级检查 Broker 磁盘%wa和store.log中的 Flush Cost,大概率是 PageCache 抖动导致 mmap 写入缺页阻塞。 -
顺序消费死锁陷阱:
MessageListenerOrderly不受最大重试 16 次的限制。Listener 抛出未捕获异常或返回SUSPEND_CURRENT_QUEUE_A_MOMENT会导致该队列无限重试。必须由业务层判断ReconsumeTimes进行主动放行。 -
冷读风暴污染内存:Consumer 拉取长时间积压的历史消息(冷读),会将磁盘文件重新加载到 PageCache,直接挤占 CommitLog 的内存页空间。可通过启用
transientStorePoolEnable=true彻底解耦业务冷读对热点发送写入的直接冲击。 -
K8S Rebalance 抖动:顺序消费依赖向 Broker 侧申请全局锁。Pod 的频繁起停会导致 Consumer 假死,引发长时间的 Rebalance 等待(锁续期与超时机制),表现为队列有堆积但没有消费速率。