Java 线上 CPU 飙高排查实战:从告警到根因
作者:梅琳marlin2026.11.07 02:18浏览量:0简介:服务每次发版后 CPU 飙至近 100%、接口大面积超时,是许多 Java 团队的共同痛点。本文完整还原一次线上问题的排查过程:从现象画像、假设排除,到线程剖析、火焰图定位与根因确认,并给出缓存预热、降低锁粒度、灰度放量等治理方案,沉淀出一套可复用的线上问题排查方法论。
一、问题背景:规律性的发版告警
某线上服务在每次发布或重启后,都会触发一波密集告警:从发版动作开始,一直持续到发版结束后几分钟,时间规律非常明显。这种“每逢发版必告警”的模式说明问题并非偶发的线上抖动,而是与发布流程本身强相关。
第一反应是怀疑流量接入过快:发布流水线(CICD)通过健康检查接口确认服务就绪后,会立即把线上流量切换到新实例。新实例刚完成启动,各类资源尚未“热身”,瞬间承接全量流量,容易出现超时。于是团队与运维沟通,将流量接入时机整体延后 30 秒。结果告警依旧,说明“晚一点接流量”并不能解决问题——这提示我们:根因可能不在外部流量节奏,而在服务内部。
二、现象画像:把模糊告警变成清晰证据
以线上某一台机器为例,启动时间线如下:
16:09:50 应用启动完成16:12:36 健康检查接口返回 ok(就绪探活通过)16:13:07 等待 30 秒后,恢复 RPC 流量16:13:39 恢复 HTTP 流量
围绕这条时间线,把多维监控指标对齐,可以提炼出四类关键现象:
- RPC 调用大面积超时:恢复 HTTP 流量后,大量调用下游的 RPC 接口发生超时。以画像类接口为例,告警从 16:14:07 持续到 16:17:31,约三分半钟内累计超时请求 578 次。
- HTTP 接口 P95 飙升:P95 响应时间从平时的几十毫秒升至数秒,16:17:30 之后才逐渐回落到正常水位。
- CPU 异常:启动阶段 CPU 出现突刺;接入线上流量后 CPU 使用率接近 100%,同样在 16:17:30 后逐步回落。
- 线程数突刺:发布期间活跃线程持续增加,启动期间线程剧增(启动期业务侧大量任务会创建线程池,属正常现象),接入流量后线程继续攀升,16:17 后趋于平稳。其中 16:12:30–16:12:40 这 10 秒内,活跃线程数从 249 涨到 1026。
四类现象共享同一个恢复终点(16:17:30 前后),说明它们大概率是同一个根因的不同表现,而非四个独立问题。排查的第一步,就是把“告警很吵”翻译成“证据成链”。
三、建立假设:用排除法收窄范围
现象齐了,接下来是假设与排除:
- 假设一:流量冲击过大。已被“延迟 30 秒接入流量仍告警”初步证伪——若是纯粹的流量冲击,30 秒缓冲至少应带来明显缓解。
- 假设二:GC 压力。若 CPU 高主要由 GC 引起,监控中应能看到 GC 频率与耗时的同步异常,线程观测中也应出现大量 GC 相关线程。排查后未发现 GC 是主导因素。
- 假设三:锁竞争 + 懒加载初始化。“CPU 高 + Blocked 线程高”是典型组合信号:大量请求线程阻塞在同一把锁上,锁内又在执行重量级初始化或计算,既堆积了等待队列(线程数暴涨、接口超时),又让持锁线程长时间占满 CPU。
- 假设四:JIT 编译与类加载。JVM 启动初期需要解释执行并即时编译热点代码,本身会带来 CPU 抬升,但通常不足以让 CPU 长时间贴近 100%,更像是放大器而非根因。
由此,排查重点锁定在“热点方法 + 锁竞争”上,需要工具来验证。
四、工具链:线上诊断的三板斧
业界主流的 JVM 在线诊断手段(开源诊断工具与云上观测类产品普遍支持)具备三类核心能力,本次排查正好各用一次:
- 线程剖析:实时查看所有线程的状态分布(RUNNABLE / BLOCKED / WAITING)与堆栈。在 CPU 高的时段可以直观看到:大量业务线程处于 BLOCKED,等待同一把锁;持有锁的线程栈顶稳定指向本地缓存的加载与计算逻辑。
- CPU 火焰图:对 CPU 做采样生成 on-CPU 火焰图,把“CPU 到底花在哪”可视化。火焰图显示热点集中在缓存加载、数据转换等初始化路径,与线程栈结论互相印证。
- 方法级追踪与验证:对可疑方法做执行耗时观测,并反编译线上字节码确认实际运行逻辑与源码一致,避免“改了代码没生效”之类的乌龙。
示意性的线程栈证据如下(脱敏简化):
"biz-worker-12" RUNNABLEat demo.cache.LocalCache.loadAndCompute(...) // 持锁线程:重量级计算"biz-worker-15" BLOCKED- waiting to lock <0x0000...> (demo.cache.LocalCache)"biz-worker-16" BLOCKED- waiting to lock <0x0000...> (demo.cache.LocalCache)
火焰图回答“CPU 花在哪”,线程栈回答“谁在等谁”,两者相互印证,是这类问题最可靠的定位方式。
五、根因确认:懒加载 + 粗粒度锁
综合全部证据,根因链条可以完整还原:
- 服务重启后,一批本地缓存为空,采用“请求到达时懒加载”的初始化方式;
- 流量接入后,几百个请求线程同时发现缓存缺失,全部竞争同一把初始化锁;
- 锁内包含重量级的加载与计算逻辑,单次执行以秒计;
- 等待队列越排越长(活跃线程从 249 涨到 1026、Blocked 突刺),下游 RPC 调用与 HTTP 接口随之大面积超时;
- 持锁线程持续满负荷计算,CPU 贴近 100%,叠加启动初期的 JIT 编译进一步放大了 CPU 曲线;
- 约三分多钟后缓存逐步填充完成,锁竞争消散,各项指标在 16:17:30 前后同步恢复——与现象时间点完全吻合。
一条完整的因果链,能够同时解释全部四类现象、并吻合恢复时间点,才算真正闭环。
六、解决方案:让服务“热着上线”
针对上述根因,治理方案分三层:
6.1 启动预热:在流量进来之前把“冷”的变热
@PostConstructpublic void warmUp() {// 启动完成后、流量接入前,异步预热本地缓存warmupExecutor.submit(() -> {for (String key : hotKeyProvider.hotKeys()) {localCache.get(key); // 提前触发加载与计算}});}
预热要覆盖本地缓存、连接池、序列化框架等“首次访问最贵”的路径。若服务托管在云上,可借助百度智能云的云监控、日志服务等通用观测能力,确认预热真正完成后再开放流量。
6.2 降低锁粒度:即便冷启动也不至于雪崩
把“一把大锁管全部 key”改为按 key 分段加锁,或采用“计算—提交”模式保证同一 key 只被计算一次。这样即使预热遗漏部分 key,并发加载也不会堵死整条链路。
6.3 流量渐进:给系统留缓冲
把“就绪即全量接入”改为按比例灰度放量:先小流量观察,再逐步放大;并配合固定的预热窗口,让 JIT、类加载、连接池建连有时间完成。这不是根因的解药,却能显著降低启动期各类问题的爆炸半径。
落地后效果显著:发布窗口内的超时告警基本清零,P95 全程平稳,发版从“每逢必响”变成“无感上线”,为服务的高可用加了一道实实在在的保障。
七、方法论沉淀
本次排查可抽象为五步:
- 现象聚类:把告警翻译成多维指标时间线(接口、CPU、线程、GC),找共同的起点与终点;
- 假设排除:用已有证据排除明显不成立的假设,收窄范围;
- 工具验证:线程剖析看“谁在忙谁在等”,火焰图看“CPU 花在哪”,必要时做方法级追踪与字节码验证;
- 因果闭环:根因链必须能解释全部现象并吻合恢复时间点;
- 治理分层:短期改行为(预热、放量),长期改结构(锁粒度、初始化设计)。
对 CPU 类线上问题而言,火焰图与线程剖析是最有效的两件兵器;遇到“CPU 高 + Blocked 高”的组合,优先怀疑锁竞争与懒加载初始化。把这套思路练熟,多数启动期抖动问题都能在一两次发版窗口内定位并根治。

登录后可评论,请前往 登录 或 注册