在日常使用 RocketMQ 的过程中,你可能遇到过这样的报错:CODE: 2 DESC: [TIMEOUT_CLEAN_QUEUE]broker busy。这行日志通常意味着 Broker 端触发了自我保护机制,拒绝了你的消息发送请求。
很多开发者看到“Broker Busy”的第一反应是服务器负载过高,但事实真的如此吗?更有趣的是,明明默认超时阈值是 200ms,为什么日志里记录的却是 401ms、799ms 这种远超阈值的数字?
今天,我们将深入 RocketMQ 的源码,一探究竟,看看 Broker 在什么情况下会拒绝服务,以及它是如何做出决策的。
异常现象分析
首先,让我们看一段典型的异常日志:
[ERROR] ... org.apache.rocketmq.client.exception.MQBrokerException: CODE: 2 DESC: [TIMEOUT_CLEAN_QUEUE]broker busy, start flow control for a while, period in queue: 401ms, size of queue: 34
日志中的关键信息如下:
- CODE: 2:这是 RocketMQ 定义的系统繁忙错误码。
- TIMEOUT_CLEAN_QUEUE:这是触发流控的具体原因,意为“在队列中等待超时”。
- period in queue: 401ms:请求在 Broker 端的排队耗时。
- size of queue: 34:当前阻塞队列中的积压请求数量。
结论:并不是 Broker 的 CPU 或内存爆了,而是消息发送请求在 Broker 的处理队列中等待的时间超过了阈值,被 Broker 主动“清理”并拒绝了。
核心解惑:为什么日志时间是 401ms 而不是 200ms?
在深入源码之前,必须先解决一个最反直觉的疑问:既然 RocketMQ 的默认流控阈值(waitTimeMillsInSendQueue)是 200ms,为什么日志里显示的排队时间(period in queue)经常是 400ms、800ms 甚至更多?
这涉及到 RocketMQ 流控机制的核心原理:它是“被动检测”,而非“主动中断”。
1. 200ms 是“门槛”,不是“熔断线”
如果你的理解是:“系统里有一个监控线程,盯着每个请求,一旦它呆了 200ms 就把它踢出去”,那么日志确实应该都在 200ms 左右。
但 RocketMQ 的逻辑是:请求进入队列后,只能乖乖排队。只有当业务线程终于轮到处理这个请求(从队列里把它拿出来)时,才会回头计算它已经等了多久。
2. 积压越严重,超时越离谱
想象一下医院挂号:
- 规则:排队超过 200ms 的病人直接退号。
- 现实:你前面排了 1000 个人,医生处理速度很慢。
- 结果:哪怕你实际上已经排了 799ms,医生(业务线程)才终于叫到你的号。这时医生一看时间:“哎呀,你都排了 799ms 了,超过 200ms 了,退号(抛出异常)。”
结论:日志中的 401ms、799ms 代表的是请求实际在队列中“坐牢”的时长。这个数值越大,说明 Broker 端的消息积压越严重,处理速度远远跟不上写入速度,导致请求在队列里“睡”了很久才被处理(或被清理)。
源码深度解析:Broker 端的流控逻辑
RocketMQ 的通信层基于 Netty 实现。当 Producer 发送消息时,请求会被封装成 RemotingCommand,通过 Netty 传输到 Broker。Broker 端接收到请求后,会将其放入一个业务线程池的阻塞队列中等待处理。
这个“排队超时”的检测逻辑,就发生在请求被线程池取出并执行的瞬间。
关键类:NettyRemotingAbstract
在 Broker 端的 NettyRemotingAbstract 类中,有一个核心方法 processRequestCommand。当请求被 Netty 线程接收后,会被包装成一个任务提交给业务线程池。
真正执行请求的逻辑在 RequestTask 的 run 方法中(这是 NettyRemotingAbstract 的内部类):
// NettyRemotingAbstract.java
class RequestTask implements Runnable {
// ... 省略其他字段
private final long createTimestamp = System.currentTimeMillis(); // 1. 记录请求进入队列的时间
private final long beginTimestamp = System.currentTimeMillis();
@Override
public void run() {
try {
// 2. 如果是发送消息的请求,且开启了流控检查
if (requestHeader != null && requestHeader.isOneway() == false) {
// 计算排队耗时:当前时间 - 请求创建时间
long waitTime = System.currentTimeMillis() - createTimestamp;
// 3. 核心判断:如果排队时间 > 阈值 (默认200ms)
if (waitTime > brokerController.getMessageStoreConfig().getWaitTimeMillsInSendQueue()) {
// 4. 拒绝请求,返回 SYSTEM_BUSY (CODE: 2)
log.warn("process request over long time, {} ms, release", waitTime);
RemotingCommand response = RemotingCommand.createResponseCommand(RemotingSysResponseCode.SYSTEM_BUSY,
String.format("[TIMEOUT_CLEAN_QUEUE]broker busy, start flow control for a while, period in queue: %sms, size of queue: %d",
waitTime,
brokerController.getSendThreadPoolQueue().size()));
// 写回响应给客户端
ctx.writeAndFlush(response);
return;
}
}
// ... 正常处理请求
} catch (Exception e) {
// ...
}
}
}
源码逻辑拆解
- 记录时间戳:当请求刚进入 Netty 层,准备提交给线程池时,
createTimestamp被记录为当前时间。 - 入队等待:请求任务进入
BlockingQueue。此时,如果 Broker 负载高,线程池繁忙,这个任务就会在队列里积压。 - 取出执行:假设过了 401ms,线程池终于有空闲线程了,把这个任务从队列里拿出来,开始执行
run()方法。 - 事后诸葛亮:代码执行到
long waitTime = System.currentTimeMillis() - createTimestamp;,此时算出来的waitTime就是 401ms。 - 判定流控:
401ms > 200ms(默认阈值waitTimeMillsInSendQueue),条件成立。 - 抛出异常:Broker 构造一个
SYSTEM_BUSY的响应返回给客户端,客户端收到后抛出MQBrokerException。
解决方案与调优建议
既然知道了原理,解决 broker busy 的思路就清晰了:要么让请求别排队(扩容),要么让队列别太挤(限流/调参)。
1. 临时方案:调大阈值
如果你确认 Broker 的机器负载(CPU、IO)并不高,只是因为偶尔的毛刺导致排队,可以尝试调大 Broker 的配置参数:
- 参数名:
waitTimeMillsInSendQueue - 默认值:200
- 建议:可以调整为 300 或 400,但这只是治标不治本。
2. 根本方案:提升处理能力
日志里的 period in queue 很大,说明处理速度跟不上。
- 增加 Broker 节点:分摊流量。
- 检查磁盘 IO:RocketMQ 是写磁盘的,如果磁盘 IO 达到瓶颈(如
%util接近 100%),处理速度会急剧下降,导致队列积压。 - 检查 PageCache:确保操作系统的 PageCache 充足,避免频繁的磁盘换页。
3. 客户端优化
- 重试机制:Producer 端遇到
broker busy时,RocketMQ 客户端默认会自动重试。确保你的重试配置合理。 - 降级处理:在业务层捕获该异常,进行适当的降级或异步缓冲。
总结
RocketMQ 的 broker busy 异常是 Broker 端的一种自我保护机制。
- 触发原因:请求在 Broker 的发送线程池队列中等待时间过长。
- 时间悖论:日志中的时间(如 401ms)是请求实际排队时长,它远超阈值(200ms)恰恰证明了队列积压的严重性。
- 核心逻辑:Broker 不会在请求等待时主动杀掉它,而是在请求终于轮到被处理时,发现它已经“过期”了,于是拒绝服务。
理解了这一点,下次再看到 period in queue 很大时,你就知道该去检查 Broker 的处理性能瓶颈了。