SpringBoot3 线程数暴增到1590的排查与解决:RocketMQ MQClientInstance 复用失效
环境信息
- SpringBoot 3.x
- JDK 17
- rocketmq5.x
- rocketmq-spring-boot-starter 2.3.2
- Arthas(线上诊断工具)
问题现象
生产环境巡检时,通过 Arthas 发现线程数异常:
Threads Total: 1590, NEW: 0, RUNNABLE: 130, BLOCKED: 0, WAITING: 1033, TIMED_WAITING: 427, TERMINATED: 0
1590 个线程对于一个普通的 SpringBoot 应用来说远超正常水平,需要立即排查。
排查过程
第一步:统计线程池分布
以下命令在系统终端(Linux Shell)中执行,非 Arthas 控制台。
用 jstack 导出线程 dump,<pid> 替换为实际进程号(可通过 jps -l 查看):
# 导出线程 dump
jstack <pid> > /tmp/td.txt
# 按线程名前缀分组统计
grep '^"' /tmp/td.txt \
| sed 's/^"([^"]*)".*/\1/' \
| sed 's/[0-9][0-9]*$//' \
| sed 's/[-_ ]*$//' \
| sort | uniq -c | sort -rn | head -40
Windows 用户:将输出路径改为系统实际路径,如
C:\tmp\td.txt,统计命令需要在 Git Bash 或 WSL 中执行,PowerShell 不支持grep/sed。
输出结果:
784 NettyClientPublicExecutor
100 http-nio-10086-exec
98 NettyClientWorkerThread
51 LatencyFaultToleranceScheduledThread
50 RebalanceService
50 PullMessageService
50 NettyEventExecutor
50 MQClientFactoryScheduledThread
50 ClientHouseKeepingService
49 PullMessageServiceScheduledThread
49 NettyClientSelector
32 redisson-netty-5
32 redisson-netty-1
16 redisson-6
16 redisson-3
...
结论非常清晰: 绝大多数线程都来自 RocketMQ 相关的线程池(NettyClientPublicExecutor、RebalanceService、PullMessageService 等),而且这些线程池各有约 50 个,明显不正常。
第二步:确认 MQClientInstance 数量
以下命令均在 Arthas 控制台中执行。对 Arthas 不熟悉的需要自行学习
正常情况下,同一个 JVM 进程内所有 Consumer 应该共用 1个 MQClientInstance,通过 Arthas 确认实际数量。
1. 找到入口类
从第一步的线程名可以看出线程池都来自 RocketMQ,但不知道该查哪个类。用 sc 搜索所有 RocketMQ 相关的类缩小范围:
sc *MQClient*
输出:
org.apache.rocketmq.client.impl.MQClientManager
org.apache.rocketmq.client.impl.factory.MQClientInstance
org.apache.rocketmq.client.impl.factory.MQClientInstance$1
...
可以看到两个关键类:
MQClientInstance:每个连接实例,持有一套线程池MQClientManager:单例管理器,内部维护所有MQClientInstance的 Map
所以查 MQClientManager 就能知道当前进程里有多少个 MQClientInstance。
2. 确认类路径
sc *MQClientManager*
# 输出:org.apache.rocketmq.client.impl.MQClientManager
3. 执行 ognl 查询
# 查询 factoryTable 的 size
ognl "#field=@org.apache.rocketmq.client.impl.MQClientManager@class.getDeclaredField("factoryTable"), #field.setAccessible(true), #field.get(@org.apache.rocketmq.client.impl.MQClientManager@getInstance()).size()"
# 查看所有 clientId
ognl "#field=@org.apache.rocketmq.client.impl.MQClientManager@class.getDeclaredField("factoryTable"), #field.setAccessible(true), #field.get(@org.apache.rocketmq.client.impl.MQClientManager@getInstance()).keySet()"
4. 如果报错 ClassNotFoundException,需要先指定类加载器
某些应用(如使用了自定义类加载器、fat jar 部署等)直接执行 ognl 会找不到类,需要先获取类加载器的 hash:
# 找到 MQClientManager 的类加载器 hash
sc -d org.apache.rocketmq.client.impl.MQClientManager
# 输出中找到 classLoaderHash 字段,例如:classLoaderHash: 5e481248
# 带上类加载器 hash 重新执行(-c 参数指定 classLoaderHash)
ognl -c 5e481248 "#field=@org.apache.rocketmq.client.impl.MQClientManager@class.getDeclaredField("factoryTable"), #field.setAccessible(true), #field.get(@org.apache.rocketmq.client.impl.MQClientManager@getInstance()).size()"
ognl -c 5e481248 "#field=@org.apache.rocketmq.client.impl.MQClientManager@class.getDeclaredField("factoryTable"), #field.setAccessible(true), #field.get(@org.apache.rocketmq.client.impl.MQClientManager@getInstance()).keySet()"
输出:
@KeySetView[
@String[169.254.232.186@23196#659521387638600],
@String[169.254.232.186@23196#659482978990200],
@String[169.254.232.186@23196#659487435896100],
... (共50个,末尾全是不同的时间戳)
@String[169.254.232.186@producer],
]
关键发现: 50 个 Consumer 创建了 50 个 MQClientInstance,每个 clientId 末尾是不同的时间戳。而正常的 clientId 格式应该是 ip@instanceName,时间戳出现意味着 instanceName 被设置成了 "DEFAULT"。
第三步:定位根本原因
RocketMQ 在生成 clientId 时有一段特殊逻辑:
// RocketMQ 源码
public String buildMQClientId() {
StringBuilder sb = new StringBuilder();
sb.append(this.clientConfig.getClientIP());
sb.append("@");
if ("DEFAULT".equals(this.clientConfig.getInstanceName())) {
// instanceName 是 DEFAULT 时,用时间戳替换!
sb.append(UtilAll.getPid()).append("#").append(this.bootTimestamp);
} else {
sb.append(this.clientConfig.getInstanceName());
}
return sb.toString();
}
"DEFAULT" 是一个特殊值,RocketMQ 会将其替换为 pid#启动时间戳。由于每个 Consumer 的启动时间不同,时间戳各异,导致 50 个 Consumer 产生 50 个不同的 clientId,无法复用同一个 MQClientInstance。
每个 MQClientInstance 都会创建一套独立的线程(Netty线程、心跳线程、拉消息线程等),50 个实例就产生了约 1400 个 RocketMQ 相关线程,这就是线程数暴增的直接原因。
第四步:找到 starter 的 bug
项目使用的是 rocketmq-spring-boot-starter 2.3.2,按理说可以通过 yaml 全局配置 instanceName:
rocketmq:
consumer:
instance-name: consumer
但配置后问题依然存在,通过 jad 反编译源码(或者在idea中查看源码)找到原因:
jad org.apache.rocketmq.spring.support.RocketMQMessageListenerContainerRegistrar
关键代码(createRocketMQListenerContainer 方法):
private DefaultRocketMQListenerContainer createRocketMQListenerContainer(...) {
DefaultRocketMQListenerContainer container = new DefaultRocketMQListenerContainer();
container.setNameServer(nameServer);
container.setTopic(...);
container.setConsumerGroup(...);
container.setNamespace(...);
container.setNamespaceV2(...);
// ❌ 这里根本没有设置 instanceName!
// rocketMQProperties.getConsumer().getInstanceName() 完全没有被读取
return container;
}
这是 rocketmq-spring-boot-starter 2.3.2 的 bug:RocketMQMessageListenerContainerRegistrar 在创建 Container 时,没有将 rocketMQProperties.getConsumer().getInstanceName() 应用到 container,导致 yaml 配置的 consumer.instance-name 完全无效。
解决方案
方案一:注解指定 instanceName(直接但繁琐)
@RocketMQMessageListener 注解提供了 instanceName 属性,可以直接在每个 Consumer 上指定:
@RocketMQMessageListener(
topic = "test-topic",
consumerGroup = "test-group",
consumeThreadNumber = 1,
instanceName = "your-app-consumer" // 所有 Consumer 统一设置同一个值
)
public class XxxConsumer implements RocketMQListener<XxxMessage> {
// ...
}
注意:所有 Consumer 的 instanceName 必须设置成同一个值,才能共用同一个 MQClientInstance。
缺点是项目里有多少个 Consumer 就要改多少处,且后续新增 Consumer 时容易遗漏,不推荐作为长期方案。
方案二:BeanPostProcessor 统一设置(推荐)
只需新增一个类,不修改任何 Consumer 注解,在容器初始化完成后统一将 instanceName 从 "DEFAULT" 覆盖为固定值:
@Component
public class RocketMqListenerInstanceNameProcessor implements BeanPostProcessor {
private static final String INSTANCE_NAME = "your-app-consumer";
@Override
public Object postProcessAfterInitialization(@NotNull Object bean, @NotNull String beanName)
throws BeansException {
if (bean instanceof DefaultRocketMQListenerContainer container) {
DefaultMQPushConsumer consumer = container.getConsumer();
if (consumer != null && "DEFAULT".equals(consumer.getInstanceName())) {
consumer.setInstanceName(INSTANCE_NAME);
}
}
return bean;
}
}
优点:
- 不需要修改任何
@RocketMQMessageListener注解 - 新增 Consumer 自动生效,无需额外操作
- 注解里已手动指定了
instanceName的不会被覆盖(值不是"DEFAULT"则跳过)
两种方案对比:
| 方案一:注解指定 | 方案二:BeanPostProcessor | |
|---|---|---|
| 改动范围 | 每个 Consumer 都要改 | 只加一个类 |
| 新增 Consumer | 需要手动加 instanceName | 自动生效 |
| 灵活性 | 每个 Consumer 可单独控制 | 统一管理 |
| 推荐度 | ⭐ 临时验证用 | ⭐⭐⭐ 生产推荐 |
验证效果
以下命令在 Arthas 控制台中执行。
重启后再次执行:
ognl "#field=@org.apache.rocketmq.client.impl.MQClientManager@class.getDeclaredField("factoryTable"), #field.setAccessible(true), #field.get(@org.apache.rocketmq.client.impl.MQClientManager@getInstance()).keySet()"
输出:
@KeySetView[
@String[169.254.232.186@your-app-consumer],
@String[169.254.232.186@producer],
]
50 个实例合并为 1 个,线程数从 1590 降至 352,恢复正常。
原理总结
线程数 1590
↓ 统计线程池分布
RocketMQ 相关线程占 ~1200 个
↓ 确认 MQClientInstance 数量
factoryTable.size() = 50(应为 1)
↓ 查看所有 clientId
每个 clientId 末尾都是不同时间戳
↓ 分析原因
instanceName = "DEFAULT" → RocketMQ 用时间戳替换 → 每个 Consumer clientId 唯一 → 无法复用
↓ 排查 starter 源码
2.3.2 的 createRocketMQListenerContainer() 未读取 consumer.instance-name 配置(bug)
↓ 修复
BeanPostProcessor 统一将 instanceName 覆盖为固定值
↓ 结果
50 个 MQClientInstance → 1 个,线程数恢复正常
注意事项
为什么所有 Consumer 共用一个 MQClientInstance 是安全的?
MQClientInstance 本质上是一个连接管理和路由层,内部通过 consumerGroup 区分不同的 Consumer,共用实例不会导致消息串投或消费混乱,这也是 RocketMQ 官方推荐的用法。
instanceName 的作用是什么?
instanceName 用于在同一台机器上区分不同的 MQClientInstance,格式为 ip@instanceName。同一进程内的 Consumer 应该共用相同的 instanceName,不同进程之间自然因为 ip 或 pid 不同而隔离。