SpringBoot3 线程数暴增到1590的排查与解决:RocketMQ MQClientInstance 复用失效

0 阅读6分钟

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 相关的线程池(NettyClientPublicExecutorRebalanceServicePullMessageService 等),而且这些线程池各有约 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 字段,例如:classLoaderHash5e481248

# 带上类加载器 hash 重新执行(-c 参数指定 classLoaderHashognl -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 的 bugRocketMQMessageListenerContainerRegistrar 在创建 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.2createRocketMQListenerContainer() 未读取 consumer.instance-name 配置(bug)
  ↓ 修复
  BeanPostProcessor 统一将 instanceName 覆盖为固定值
  ↓ 结果
  50 个 MQClientInstance → 1 个,线程数恢复正常

注意事项

为什么所有 Consumer 共用一个 MQClientInstance 是安全的?

MQClientInstance 本质上是一个连接管理和路由层,内部通过 consumerGroup 区分不同的 Consumer,共用实例不会导致消息串投或消费混乱,这也是 RocketMQ 官方推荐的用法。

instanceName 的作用是什么?

instanceName 用于在同一台机器上区分不同的 MQClientInstance,格式为 ip@instanceName。同一进程内的 Consumer 应该共用相同的 instanceName,不同进程之间自然因为 ip 或 pid 不同而隔离。