3、应用层监控:JVM、Go Runtime 与 Node.js Event Loop

应用层监控,说白了就是盯着你的程序运行时到底在干嘛。我见过太多团队,基础设施监控做得漂漂亮亮,一到应用层就抓瞎——CPU 跑满了不知道是 GC 引起的,内存飙了不知道是哪个线程在作妖。这一章,咱们就把 JVM、Go Runtime 和 Node.js Event Loop 这三个主流运行时监控讲透。

3.1 JVM 监控:堆内存、GC 与线程

Java 应用的内存管理,核心就是堆内存和 GC。我个人习惯先看堆内存使用曲线,再看 GC 频率和停顿时间,最后才去排查线程问题。这个顺序能帮你快速定位 80% 的性能问题。

3.1.1 堆内存监控

堆内存分新生代和老年代。新生代满了触发 Minor GC,老年代满了触发 Full GC。监控时重点关注这几个指标:

  • 堆内存使用率:当前已用堆 / 最大堆。超过 80% 就要警惕。
  • 老年代使用率:老年代持续增长,说明对象无法被回收,可能有内存泄漏。
  • Metaspace 使用率:元空间满了会触发 Full GC,常见于动态类加载过多的场景。

核心指标:老年代使用率 + Full GC 频率,这两个指标组合能判断 90% 的 JVM 内存问题。

我在项目中遇到过一个问题:堆内存使用率一直稳定在 70%,但每隔 2 小时就会有一次长时间的 Full GC。排查后发现是某个定时任务创建了大量临时对象,这些对象因为引用链问题被晋升到了老年代。嗯,这里要注意——堆内存使用率平稳不代表没有 GC 问题。

3.1.2 GC 监控

GC 监控的核心是停顿时间和频率。我建议至少监控以下指标:

指标 正常范围 告警阈值
Young GC 频率 每秒 1-5 次 > 10 次/秒
Young GC 停顿 < 50ms > 200ms
Full GC 频率 每小时 < 1 次 > 5 次/小时
Full GC 停顿 < 1s > 3s

我曾经遇到过一个线上事故:Full GC 停顿时间达到了 12 秒,导致所有请求超时。查下来是因为堆内存设置了 32GB,但使用了 CMS 收集器,老年代碎片化严重。后来换成 G1,问题就解决了。所以,GC 收集器的选择也很关键。

小技巧:使用 -XX:+PrintGCDetails -XX:+PrintGCDateStamps 开启 GC 日志,配合 gcviewer 或 gceasy 分析,能直观看到 GC 停顿分布。

3.1.3 线程监控

线程监控主要看线程数、线程状态和死锁。我常用的命令是 jstack 和 jvisualvm。重点监控:

  • 线程总数:超过 1000 就要注意,可能线程泄漏。
  • BLOCKED 线程数:持续大于 0,说明有锁竞争。
  • WAITING 线程数:过多可能是有线程池配置问题。

注意:线程 dump 是快照,只能看到瞬间状态。要分析线程趋势,建议用 Prometheus + JMX Exporter 采集线程指标。

3.2 Go Runtime 监控

Go 的运行时监控和 JVM 不太一样。Go 没有传统的堆内存分代,它的 GC 是并发三色标记清除。我个人习惯先看 Goroutine 数量,再看 GC 和内存分配。

3.2.1 Goroutine 监控

Goroutine 是 Go 的轻量级线程,但轻量不代表无限。我见过一个项目,Goroutine 数量飙到 50 万,直接把调度器压垮了。监控指标:

  • Goroutine 总数:正常服务几千到几万,超过 10 万要警惕。
  • Goroutine 创建速率:每秒创建过多,说明可能有 Goroutine 泄漏。
  • Goroutine 状态分布:Running、Runnable、Waiting 的比例。

核心指标:Goroutine 总数 + 创建速率。如果总数持续增长且不下降,基本可以判定有 Goroutine 泄漏。

我曾经排查过一个 Go 服务内存持续增长的问题。用 pprof 一看,发现某个 HTTP 请求处理函数里,每次都会启动一个 Goroutine 去做日志写入,但忘了加 sync.WaitGroup 控制生命周期。结果这些 Goroutine 全卡在 channel 发送上,越积越多。修复后内存直接降了 40%。

3.2.2 GC 与内存监控

Go 的 GC 是并发标记清除,停顿时间通常很短。但如果你分配了大量小对象,GC 压力也会很大。监控指标:

指标 说明 告警阈值
GC 次数 每分钟 GC 执行次数 > 10 次/分钟
GC 停顿时间 STW 时间 > 100ms
堆内存分配速率 每秒分配字节数 > 1GB/s
堆内存使用率 当前堆 / 最大堆 > 80%

小技巧:使用 runtime.ReadMemStats 可以获取内存统计信息。生产环境建议用 net/http/pprof 暴露监控端点,配合 Prometheus 采集。

3.3 Node.js Event Loop 监控

Node.js 是单线程事件循环模型。说白了,所有 JavaScript 代码都在一个线程上跑。如果某个任务阻塞了事件循环,整个服务就卡住了。我见过一个 Node 服务,因为某个 API 里做了大量 CPU 密集型计算,导致事件循环延迟从 2ms 飙升到 5 秒。

3.3.1 Event Loop 延迟监控

Event Loop 延迟是衡量 Node 服务健康度的核心指标。它表示事件循环被阻塞的时间。监控方法:

  • 使用 process.hrtime:在事件循环开始和结束时记录时间差。
  • 使用 monitor-event-loop-delay:npm 包,专门采集延迟指标。
  • 使用 Node.js 内置的 perf_hooks:可以获取事件循环的详细统计。

核心指标:Event Loop 延迟的 P99 值。如果 P99 超过 100ms,说明有任务阻塞了事件循环。

我曾经用 clinic.js 分析过一个 Node 服务。发现某个路由处理函数里,用 JSON.parse 解析了一个 10MB 的 JSON 字符串。这个操作阻塞了事件循环 800ms,导致其他请求全部排队。后来改成流式解析,问题就解决了。

3.3.2 内存与 GC 监控

Node.js 的 GC 和 V8 引擎紧密相关。监控指标:

指标 说明 告警阈值
堆内存使用量 V8 堆内存 > 1.4GB(默认最大堆)
GC 暂停时间 每次 GC 的停顿 > 200ms
GC 频率 每分钟 GC 次数 > 20 次/分钟
外部内存 Buffer、ArrayBuffer 等 > 500MB

注意:Node.js 的默认最大堆内存是 1.4GB(64 位系统)。如果堆内存持续接近这个值,考虑用 --max-old-space-size 调整,或者排查内存泄漏。

3.4 知识体系总览

下面这张图总结了三个运行时监控的核心要点。你想想看,不管是 JVM、Go 还是 Node,最终都要落到内存、GC 和并发模型这三个维度上。

应用层运行时监控知识体系 JVM 监控 堆内存:新生代 / 老年代 / Metaspace GC:频率 / 停顿时间 / 收集器 线程:总数 / BLOCKED / 死锁 Go Runtime 监控 Goroutine:总数 / 创建速率 / 状态 GC:次数 / 停顿 / 分配速率 内存:堆使用率 / 外部内存 Node.js Event Loop 监控 Event Loop 延迟:P50 / P99 堆内存:V8 堆 / 外部内存 GC:暂停时间 / 频率 核心思路:内存 → GC → 并发模型,三个维度覆盖 90% 的应用层问题

3.5 监控工具选型建议

工具选型上,我个人习惯按场景来分:

运行时 快速排查 持续监控 深度分析
JVM jstat, jstack, jmap Prometheus + JMX Exporter GCeasy, JProfiler
Go pprof, trace Prometheus + Go client pprof 火焰图
Node.js clinic.js, 0x Prometheus + node_exporter Chrome DevTools

小技巧:不管用什么工具,先把基础指标采集起来。我见过太多团队,出了问题才想起来加监控,那时候已经晚了。先把数据拿到手,后面分析才有依据。