从 MQ 积压追到事件总线:诊断 4K 线程吃光 7G 内存的实战

早上 7:40 钉钉炸了:「RocketMQ 消息积压触发告警」。

上机器一看 top------java 进程 150% CPU、4.5G 内存、整机 free 仅 335MB。

我以为这是个 RocketMQ 客户端泄漏的剧本,跟一个月前那个 LangFuse 线程泄漏一模一样。结论是错的

4000 个线程不是泄漏------是设计的代价。从消息积压到揭开整个事件总线设计,这次排查跨越了 5 个工具、3 个反推公式、1 次大型自我打脸。

这篇讲怎么用线程名后缀反推系统架构,以及怎么避免把"设计选型"误判成"代码 bug"。


一、问题现象:MQ 积压只是个开始

7:40,监控告警炸群:

text 复制代码
RocketMQ 消息积压        触发告警
开始时间: 2026-05-08 07:40:00

【P1】线程池[biz-async-task] 任务队列堆积
当前值: 5063 → 13948 (3 分钟内翻倍)
阈值: 0

存活监控告警
- ai-service-A: 8m42s 失活
- gateway-2: 3m42s 失活
- consumer-2: 6m42s 失活

四个告警来自不同角度,但时间线高度重合------典型的"一个根因,多重投影"。先抓最严重的 consumer-2 节点上机器看。


二、第一层排查:top 找进程 → top -Hp 找线程

2.1 进程级 top

text 复制代码
top - 09:12:56 up 638 days,  load average: 2.25, 3.16, 2.95
Tasks: 277 total
%Cpu(s): 27.9 us, 16.2 sy, 0.0 ni, 52.9 id, 0.0 wa, 2.9 si

MiB Mem : 7727 total, 335 free, 6058 used, 1332 buff/cache
MiB Swap: 0 total, 0 free, 0 used

  PID USER  %CPU  %MEM   COMMAND
14751 app   150.0 57.3   java

三个反常点:

信号 含义
单进程 150% CPU 多线程在烧
单进程占系统 57% 内存 4.3G / 7.7G,物理内存几乎榨干
free 仅 335M OOM-Killer 触发线只差临门一脚

2.2 钻进进程:top -Hp <pid>

text 复制代码
$ top -Hp 14751
Threads: 3950 total, 5 running, 3945 sleeping
%Cpu(s): 38.1 us, 15.7 sy, 44.2 id

   PID  %CPU  %MEM  TIME+      COMMAND
164540  15.9  57.3  95:37.92   java
164501  13.3  57.3  37:04.10   java
166839  12.3  57.3   9:42.57   java
166914   5.2  57.3  21:20.62   java

3950 个线程 ,是我至今见过最大的数字。CPU top10 都只占 1~5%,没有一个明显的热点------这是个"分布式吃 CPU"的特征:大量线程各占一点点,加起来就把机器压垮了。

2.3 反推热点:把 nid 喂回 jstack

bash 复制代码
$ printf '%x\n' 164501
28295

$ jstack 14751 | grep '28295' -A 2
"Gang worker#0 (G1 Parallel Marking Threads)" os_prio=0 nid=0x28295 runnable

热点 #2 是 G1 GC 标记线程 。再看一眼,热点 #4-#10 里好几个是 G1 Concurrent Refinement Thread ------ GC 在持续 marking

到这里我已经有个初步判断:不是业务死循环,是 GC 被对象产出速度打崩了。但为什么会有这么多对象产出?------线索回到那 3950 个线程。


三、第二层排查:jstat + jinfo 看 GC 与堆配置

3.1 jstat:GC 频率高得吓人

text 复制代码
$ jstat -gcutil 14751 1000 10
  S0    S1     E      O      M     CCS    YGC    YGCT    FGC   GCT
  0.00 100.00 33.80  59.43  91.25  87.13  11815  207.68    0   207.68
  0.00 100.00 57.75  59.43  91.25  87.13  11815  207.68    0   207.68
  0.00 100.00 90.14  59.43  91.25  87.13  11815  207.68    0   207.68
  0.00 100.00 27.78  60.08  91.26  87.15  11817  207.71    0   207.71
  0.00 100.00 91.67  60.08  91.26  87.15  11817  207.71    0   207.71
  0.00 100.00 36.11  60.08  91.31  87.22  11818  207.73    0   207.73

读数:

指标 实测 解读
S0=0 / S1=100 Survivor 严重失衡 对象产出太快,YGC 时来不及在两个 Survivor 间均衡
E 33% → 91% 反复 10s 内 +3 次 YGC 每 3-7s 触发一次 YGC,频率非常高
O 60% 缓慢上涨 老年代逐步累积 大量对象提前晋升到老年代
M=91 / CCS=87 元空间快满 隐藏的 Metaspace OOM 定时炸弹
YGCT=207s 累计 GC 时间 207 秒 进程跑了 24h,5% 的时间在 GC
FGC=0 没发生过 Full GC G1 还在勉强支撑

3.2 jinfo:GC 配置在跟业务对着干

text 复制代码
$ jinfo -flags 14751 | tr ' ' '\n' | grep -E 'Xmx|GC|Heap'

-Xms3G  -Xmx3G
-XX:+UseG1GC
-XX:G1HeapRegionSize=16m
-XX:InitiatingHeapOccupancyPercent=30   ← 老年代到 30% (≈900MB) 就触发 mixed GC
-XX:ConcGCThreads=1                      ← 只给 1 个并发标记线程
-XX:G1ReservePercent=25

1 个并发标记线程 要追 3950 个业务线程产出的对象速率,IHOP=30% 又让 mixed GC 提前触发。这不是 GC 病了,是 GC 资源被严重低估了。

但即便如此,问题的本质还是"为什么会有这么多对象产出"。GC 配置只是放大器,不是源头。继续往下挖。


四、第三层排查:jmap histo 看内存大户

text 复制代码
$ jmap -histo:live 14751 | head -30

 num     #instances         #bytes  class name
   1:    1202279      363604408   [C
   2:     324769      255772808   [Ljava.lang.Object;
   3:     131430       86218080   io.netty.util.internal.shaded.org.jctools.queues.MpscArrayQueue
   4:      32321       35045304   [B
   5:    1339245       32141880   java.lang.String
   ...
   9:     232860       11177280   org.aspectj.weaver.reflect.ShadowMatchImpl
  12:     232860        7451520   org.aspectj.weaver.patterns.ExposedState
  ...
  23:     121320        3882240   io.netty.buffer.PoolThreadCache$SubPageMemoryRegionCache

两个数字特别扎眼:

4.1 13 万个 Netty 队列 + 12 万个线程缓存

MpscArrayQueue 13.1w + PoolThreadCache$SubPageMemoryRegionCache 12.1w。

关键算式

复制代码
PoolThreadCache$SubPageMemoryRegionCache: 121,320 个
÷ Netty 默认 SubPage 数: 32(每线程持有 32 个 SubPage)
≈ 3791 个线程
≈ 实测总线程数 3950 ✓

线程数和 Netty 线程缓存实例数 1:1 对应 。这印证了 Netty 内部的 PoolThreadCache 是按线程数线性增长的------每多一个线程,就多一份 32 个 SubPage 缓存。

4.2 23 万个 AspectJ 切点匹配对象

ShadowMatchImpl: 232,860 + ExposedState: 232,8601:1 严格配对

这是 Spring AOP / AspectJ 在每次方法调用时重复做切点匹配没缓存的特征。后果是每次方法多走一遍 match 逻辑(直接拖慢业务),同时持续创建临时对象喂 YGC(加剧 GC 频率)。

但这条还是配角。主咖仍然是那个 3950 线程


五、关键转折:grafana 揭示了一个反常事实

到这里有个困惑:线程数为啥不降?

跑去看 grafana 近 2 天的线程曲线([live + daemon + peak] 三条线叠图):

text 复制代码
live :  3.9K ━━━━━━━━━━━━━━━━━ 2 天稳定在 3.9K,重启后立刻爬升回来
daemon: 431  ━━━━━━━━━━━━━━━━━ 几乎不变
peak :  3.9K ━━━━━━━━━━━━━━━━━ 跟 live 完全重合

live ≈ peak 这条规律 2 天没破过

正常应用 peak 会比 live 高很多------高峰期创建一批线程,闲时回收一部分。这里 live = peak 意味着:

线程池常态保持在最大线程数。要么是池配的 fixed,要么是负载持续到所有池都满载、没有空闲触发回收。

更进一步:non-daemon 线程数 = live - daemon = 3470 个 。non-daemon 线程会让 JVM shutdown hook 走完后还在等这些线程结束,运维只能 kill -9------这跟图里几次重启线程数掉到 ~2.7K(不是 0)也对得上。

现在唯一的问题是:3470 个 non-daemon 线程到底是哪些?


六、决定性的一步:从线程名后缀反推架构

到这步如果还按"客户端泄漏"的思路找代码,是大海捞针。换个思路:让 jstack 自己说话------按线程名分组统计

6.1 一行命令分组统计

bash 复制代码
$ jstack 14751 | grep -E '^"' | sed -E 's/^"([^-]+(-[^-0-9]+)?).*/\1/' \
    | sort | uniq -c | sort -rn | head -20

 1200  ConsumeMessageThread
  700  MQTraceSendThread
  300  event-executor
  280  AsyncSenderExecutor
  200  biz-async-task
  140  RequestHouseKeepingService
  140  CleanExpireMsgScheduledThread
  140  MQ-AsyncTraceDispatcher-Thread
   80  pool-N-thread     ← 匿名池
   37  Thread-N          ← 匿名 Thread
   ...

76% 的线程都是 RocketMQ 家族。但每类后缀的数字都很大,且互不相同------这不像"客户端没复用"的特征(那种特征是某一类线程几千个,其他正常)。

6.2 反推公式:从后缀分布看实例数

进一步看 ConsumeMessageThread_<i> 的后缀:

bash 复制代码
$ jstack 14751 | grep -oE 'ConsumeMessageThread_[0-9]+' \
    | sed -E 's/^.*_([0-9]+)$/\1/' | sort -n | uniq -c

  60 1
  60 2
  60 3
  ...
  60 19
  60 20      ← 后缀范围 1~20,每个出现约 60 次

重点来了------RocketMQ 的 DefaultMQPushConsumer 默认 consumeThreadMax=20,每个 Consumer 实例独立编号 1~20。所以:

后缀 i=1 出现的次数 ≈ Consumer 实例数 = 60 个

60 × 20 = 1200,正好等于 ConsumeMessageThread 总数 ✓

这个项目的代码里居然有 60 个独立的 RocketMQ Consumer?同样手法看 Producer:

bash 复制代码
$ jstack 14751 | grep -oE 'MQTraceSendThread_[0-9]+' \
    | sed -E 's/^.*_([0-9]+)$/\1/' | sort -n | uniq -c

  70 1
  70 2
  ...
  70 10      ← 70 个 Producer × 10 trace 线程 = 700 ✓

70 个 Producer 实例 。加上 140 个 MQClientInstance(每个实例 1 个 RequestHouseKeepingService),整个进程里至少有 270 个 RocketMQ 客户端对象。

到这里我以为找到泄漏了------肯定是 Producer/Consumer Bean 没单例化,每次注入都新建一个。

这个判断错了


七、原来不是 bug 是 feature:揭开事件总线设计

抓代码 grep -rn "DefaultMQPushConsumer\|DefaultMQProducer" .,找到这么一段:

java 复制代码
@Configuration
public class RemoteEventListenerAutoCreator
        implements ApplicationContextAware, SmartInitializingSingleton {

    @Override
    public void afterSingletonsInstantiated() {
        // 启动时遍历所有 @RemoteEvent 注解 Bean
        Map<String, Object> beans = applicationContext
            .getBeansWithAnnotation(RemoteEvent.class);

        beans.forEach((beanName, instance) -> {
            String tag   = beanName + "_tag";
            String group = beanName + "_group_" + suffix;
            String topic = beanName + "_" + suffix;

            // 给每个 Bean 注册一个独立的 DefaultRocketMQListenerContainer
            // → 每个 Container 内部一个 DefaultMQPushConsumer
            registerBean(containerName, DefaultRocketMQListenerContainer.class,
                () -> buildContainer(topic, tag, group));
        });
    }
}

读完这段代码的时候我在工位上沉默了 5 秒。

这不是泄漏,是设计

启动时扫描所有 @RemoteEvent 注解 Bean,给每个 Bean 自动注册一个独立的 RocketMQ Consumer Container(独立 topic / group / tag)。这是个典型的"远程事件总线"实现------业务代码只需要在事件 Bean 上加注解,Consumer 自动创建、订阅、消费。

项目里有 60 个 @RemoteEvent Bean → 启动时自动注册 60 个 Container → 每个 Container 默认 20 个消费线程 → 1200 个 ConsumeMessageThread 是设计的代价

设计目的是事件按业务隔离 topic + 不同 consumer group 各自维护 offset,避免一个慢消费拖累其他事件。代价是线程数随 @RemoteEvent Bean 数量线性增长

回到 grafana 那个 live = peak 的现象:60 个 Consumer 都在持续消费消息(事件总线一直有事件),所有池都长期满载------所以 live 永远等于 peak。不是池配错了,是工作集本身就这么大


八、重新定位优化方向

既然不是泄漏,"找 leak 修代码"这条路就废了。但优化空间还有,按收益从高到低排:

优化点 实现 预计收益
调小单 Consumer 线程数 RemoteEventListenerAutoCreator 里显式 setConsumeThreadMin/Max(4, 8) 60 × 12 = 720 → 砍掉 ~480 ConsumeMessageThread
共享 MQClientInstance 给所有 Consumer/Producer 设统一 instanceName 140 → 1~2,省整套 NettyClient/HouseKeeping
关闭低 QPS topic 的 trace producer 级 enable-msg-trace=false 砍 ~700 trace 线程的大头
合并低频事件到同 topic 用 tag 区分 @RemoteEvent 注解定义 减少 Container 数(架构改动)

这些优化叠加起来,能从 4000 线程砍到 1500 以内。物理内存(4G 栈空间)立刻有富余,GC 压力自动缓解,AspectJ 那 23 万 ShadowMatch 的次生问题也会减轻。

至于 GC 配置(ConcGC=1 → 2~4、IHOP=30% → 45~50%)和 Metaspace 上限(加 -XX:MaxMetaspaceSize=512m),可以同步调整,但根因解决后这些都是边际优化


九、举一反三:从这次诊断里能沉淀什么

9.1 线程名后缀反推架构 ------ 通用方法

任何用线程池的中间件,线程命名规则都隐含着内部结构。掌握几个常见公式:

中间件 线程命名规则 反推公式
RocketMQ Consumer ConsumeMessageThread_<i> i 范围 = consumeThreadMax;i=1 出现次数 = Consumer 实例数
RocketMQ Producer MQTraceSendThread_<i> i 范围 = trace 池大小;i=1 出现次数 = Producer 实例数
Netty nioEventLoopGroup-<g>-<i> g = EventLoopGroup 实例编号;i 范围 = 该 group 线程数
Tomcat http-nio-<port>-exec-<i> i 范围 = maxThreads;port 数 = 监听端口数
Kafka `kafka-producer-network-thread <client-id>`
任意 ThreadPoolExecutor pool-<N>-thread-<M> N 是池序号,N 数 = 匿名池总数

口诀 :看到大量同前缀线程时,先 grep -oE 抓后缀分布,后缀的最大值 × 后缀=1 的出现次数 ≈ 总线程数。如果对得上,就是设计;对不上,才是泄漏。

9.2 警惕"看似泄漏,实为设计代价"

写完 LangFuse 那篇线程泄漏复盘后,我下意识觉得"线程数大 = 泄漏"。这次差点就掉进去

判断框架:

复制代码
线程数大
   ├─ 同类线程后缀分布均匀 → 大概率是设计代价(多实例 × 池大小)
   │     └─ 看代码里是否有"按 N new 客户端"的合理设计
   └─ 某类线程后缀只有 1 / 几个,但数量爆炸 → 大概率是泄漏
         └─ MAT 看 GC Roots 找引用源

9.3 多数据源交叉验证才靠谱

这次排查涉及 5 个数据源,缺一不可:

数据源 关键贡献
top / top -Hp 锁定进程 + 热点线程(GC marking 提示了"对象产出过快")
jstat 量化 GC 频率和 Survivor 失衡
jinfo / ps -ef 看 GC 配置和启动参数
jmap histo 内存大户提示"Netty 缓存 = 线程数"的 1:1 关系
grafana 历史曲线 live = peak 揭露了"线程不退"
jstack 分组统计 决定性的反推架构步骤

任何一个工具单独看都不够。真正破案的是跨数据源的算式对应 :jmap 算出"3791 线程",top -Hp 看到"3950",jstack 统计算出"60 × 20 = 1200 + 70 × 4 = 280 + ..."加起来也是接近 3950------三个数字互相印证,结论才稳


十、总结

维度 改前 改后预期
总线程数 3950 1500 以内
物理内存压力 RES 4.3G / 系统 7.7G(57%) RES ~2.5G(32%)
GC 频率 每 3-7s 一次 YGC 显著缓解
优雅停机 non-daemon 线程多,靠 kill -9 正常 SIGTERM 即可

核心教训

  1. 不要想当然套用"上一次问题"的诊断模板------这次跟 LangFuse 那次表面像,根因完全不同
  2. 看似简单的线程命名隐藏着架构信息------会读就是免费的代码考古工具
  3. 设计代价不是 bug,但仍可优化------一个事件总线设计能开 60 个 Consumer 不是错,把每个 Consumer 默认 20 线程不调成 8 才是错
  4. 多工具交叉,让数字互相印证------不要只信一个数据源,三个数字对得上的结论才可靠

至于事件总线的设计本身------为什么要给每个事件 Bean 一个独立 topic?这个设计的取舍是什么?什么场景下值得这么做?详见姊妹篇 《@RemoteEvent 自动事件总线:1 个注解换 60 个 Consumer,赚还是亏?》

而本次排查里如果你看完发送侧代码,会发现还有一个90% Java 项目都踩过的 Spring 事务消息陷阱 ------事务还没 commit 就发了消息、事务回滚了消息却发出去了,详见 《事务回滚了消息却发出去:Spring 事务消息的 3 种姿势对比》

最后一个甩给读者的小问题:如果你的 JVM 跑在 K8s 里、HPA 按内存阈值扩缩容,4G 线程栈这件事会怎么把你坑了? 评论区聊。

相关推荐
小旭95272 小时前
商品详情实现与缓存问题(穿透、击穿、雪崩)解决方案
java·数据库·spring boot·后端·缓存
2501_920047032 小时前
iptables防火墙
linux·运维·网络安全
苦逼的猿宝3 小时前
基于springboot的课程作业管理系统(源码+论文)
java·毕业设计·springboot·计算机毕业设计
我本楚狂人www3 小时前
Spring 两大核心思想(一):IoC
java·数据库·spring
Anthony_2313 小时前
Linux 防火墙完全指南:从 iptables 到 firewalld
linux·运维·服务器
月走乂山3 小时前
Linux 服务器安装 CC Switch GUI 工具 + VNC 远程桌面完整教程
linux·运维·服务器
前端 贾公子3 小时前
基于 Nginx 实现一个灰度上线系统
运维·nginx
九皇叔叔3 小时前
高斯性能分析【第一天】单表执行计划分析
java·数据库·性能分析·执行计划·gauss
苦逼的猿宝3 小时前
基于springboot的社区团购系统设计(源码+论文)
java·毕业设计·springboot·计算机毕业设计