早上 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,860,1: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 个
@RemoteEventBean → 启动时自动注册 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 即可 |
核心教训:
- 不要想当然套用"上一次问题"的诊断模板------这次跟 LangFuse 那次表面像,根因完全不同
- 看似简单的线程命名隐藏着架构信息------会读就是免费的代码考古工具
- 设计代价不是 bug,但仍可优化------一个事件总线设计能开 60 个 Consumer 不是错,把每个 Consumer 默认 20 线程不调成 8 才是错
- 多工具交叉,让数字互相印证------不要只信一个数据源,三个数字对得上的结论才可靠
至于事件总线的设计本身------为什么要给每个事件 Bean 一个独立 topic?这个设计的取舍是什么?什么场景下值得这么做?详见姊妹篇 《@RemoteEvent 自动事件总线:1 个注解换 60 个 Consumer,赚还是亏?》。
而本次排查里如果你看完发送侧代码,会发现还有一个90% Java 项目都踩过的 Spring 事务消息陷阱 ------事务还没 commit 就发了消息、事务回滚了消息却发出去了,详见 《事务回滚了消息却发出去:Spring 事务消息的 3 种姿势对比》。
最后一个甩给读者的小问题:如果你的 JVM 跑在 K8s 里、HPA 按内存阈值扩缩容,4G 线程栈这件事会怎么把你坑了? 评论区聊。