在这篇文章中,我将结合最近一次真实的生产事故,分享一套经过实战检验的CPU排查方法论。这不是教科书上那些”用jstack查看线程栈”的泛泛之谈,而是我们在72小时连续作战中总结出来的血泪经验。

一、告警响应该做什么?别急着重启!

很多团队的第一反应是:”重启大法好”。但在没有定位问题之前重启,就像是捂住温度计来”治疗”发烧——问题暂时消失了,但根因还在,而且你失去了最宝贵的现场信息。

黄金5分钟应该做的事:

  1. 保留现场:立即执行 top -H -p <pid> 保存线程CPU占用快照
  2. 线程Dump:连续执行3次 jstack <pid> > thread_dump_$(date +%s).txt,间隔5秒
  3. 堆Dump:如果CPU高伴随内存异常,立即 jmap -dump:live,format=b,file=heap.hprof <pid>
  4. 监控数据:截图或导出该时间段的JVM监控指标(GC次数、堆使用率、线程数)

我们上次事故中,就是因为运维同学”好心”地立即重启,导致只能从日志中反推问题,多花了4小时。

二、定位CPU热点的三板斧

2.1 第一板斧:top + jstack 定位高CPU线程

# 1. 找出占用CPU最高的进程
top -c
# 假设Java进程PID是12345

# 2. 查看该进程中各线程的CPU占用
top -H -p 12345
# 假设发现线程ID 12456占用CPU 80%

关键技巧:将十进制的线程ID转换为16进制(因为jstack中的线程ID是nid=0x十六进制)

# Linux/Mac
printf "%x\n" 12456
# 输出: 30a8

# 然后在jstack输出中搜索 nid=0x30a8
grep -A 20 "nid=0x30a8" thread_dump_*.txt

真实案例中的发现

"payment-async-pool-32" #156 prio=5 os_prio=0 tid=0x00007f8a4c0a8000 nid=0x30a8 runnable [0x00007f89e1b4d000]
   java.lang.Thread.State: RUNNABLE
    at com.example.service.PaymentService.processPayment(PaymentService.java:187)
    at com.example.async.AsyncPaymentTask.run(AsyncPaymentTask.java:45)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
    at java.lang.Thread.run(Thread.java:748)

看起来是支付异步处理线程在运行,但这正常吗?继续深挖。

2.2 第二板斧:arthas profiler 生成火焰图

jstack只能看到某一时刻的快照,要找到真正的CPU热点,需要采样分析。这里强烈推荐阿里开源的 arthas

# 启动arthas
java -jar arthas-boot.jar

# 选择目标Java进程后,执行profiler
profiler start --event cpu --duration 30
# 等待30秒采样...

profiler getSamples
# 查看采样数

profiler stop --format flamegraph --file cpu_flamegraph.html
# 生成火焰图

如何解读火焰图?

  • 宽度:代表CPU时间占比,越宽说明占用CPU越多
  • 高度:代表调用栈深度
  • 顶部平顶山:通常是热点代码的标志

在我们的案例中,火焰图暴露了一个惊人的事实:

[Top 5 CPU consumers]
1. com.example.service.PaymentService.processPayment() - 45.2%
   └─ JSON.parseObject() - 38.7%
      └─ com.alibaba.fastjson.parser.deserializer.MapDeserializer.parseMap() - 35.1%
2. java.util.concurrent.LinkedBlockingQueue.poll() - 12.3%
3. java.lang.String.hashCode() - 8.9%

问题定位PaymentService.processPayment() 方法消耗了45%的CPU,其中38.7%花在了JSON反序列化上!

2.3 第三板斧:代码审查 + 性能分析

打开 PaymentService.java:187 的代码:

// 问题代码(已脱敏)
public class PaymentService {
    private static final ObjectMapper objectMapper = new ObjectMapper();
    
    public PaymentResult processPayment(PaymentRequest request) {
        // 踩坑点1:每次请求都解析JSON配置
        String configStr = redisTemplate.opsForValue().get("payment:config:" + request.getChannel());
        Map<String, Object> config = JSON.parseObject(configStr, new TypeReference<Map<String, Object>>() {});
        
        // 踩坑点2:在循环中进行JSON序列化
        for (OrderItem item : request.getItems()) {
            String itemJson = JSON.toJSONString(item);
            log.debug("Processing item: {}", itemJson);  // 生产环境开了DEBUG日志!
        }
        
        // 踩坑点3:大量字符串拼接
        String key = "payment:" + request.getMerchantId() + ":" + request.getChannel() + ":" + System.currentTimeMillis();
        
        // ... 业务逻辑
    }
}

三个致命问题

  1. 配置重复解析:每笔支付请求都从Redis读取并解析JSON配置,而这个配置每分钟才变一次
  2. DEBUG日志生产开启log.debug() 虽然不会输出,但参数计算(JSON序列化)仍然执行
  3. 字符串拼接性能差:在高频场景下,字符串拼接产生大量临时对象

三、性能优化的三把手术刀

3.1 第一刀:缓存+预加载

// 优化后代码
public class PaymentService {
    private static final ObjectMapper objectMapper = new ObjectMapper();
    
    // 使用Guava Cache缓存配置,1分钟刷新一次
    private final LoadingCache<String, Map<String, Object>> configCache = Caffeine.newBuilder()
            .maximumSize(100)
            .expireAfterWrite(1, TimeUnit.MINUTES)
            .build(this::loadConfig);
    
    private Map<String, Object> loadConfig(String channel) {
        String configStr = redisTemplate.opsForValue().get("payment:config:" + channel);
        return JSON.parseObject(configStr, new TypeReference<Map<String, Object>>() {});
    }
    
    public PaymentResult processPayment(PaymentRequest request) {
        // 优化1:从本地缓存读取,避免重复解析
        Map<String, Object> config = configCache.get(request.getChannel());
        
        // 优化2:生产环境避免JSON序列化,使用占位符
        if (log.isDebugEnabled()) {
            for (OrderItem item : request.getItems()) {
                log.debug("Processing item: {}", item);  // 直接打印对象,由日志框架处理
            }
        }
        
        // 优化3:使用StringBuilder或String.format
        String key = String.format("payment:%s:%s:%d", 
                request.getMerchantId(), 
                request.getChannel(), 
                System.currentTimeMillis());
        
        // ... 业务逻辑
    }
}

性能对比数据(基于JMeter 1000并发压测):

指标 优化前 优化后 提升
平均RT 2876ms 89ms 97%
CPU使用率 900% 120% 87%
QPS 347 8920 25倍
99%延迟 5632ms 156ms 97%

3.2 第二刀:异步化 + 线程池隔离

在排查过程中,我们还发现另一个问题:支付回调处理是同步阻塞的,导致Tomcat线程池耗尽。

// 优化方案:使用线程池隔离 + 异步处理
@Configuration
public class AsyncConfig {
    
    @Bean("paymentExecutor")
    public ThreadPoolTaskExecutor paymentExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(20);
        executor.setMaxPoolSize(100);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("payment-async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

@Service
public class PaymentCallbackService {
    
    @Async("paymentExecutor")
    public CompletableFuture<CallbackResult> processCallback(CallbackRequest request) {
        // 耗时的回调处理逻辑
        return CompletableFuture.supplyAsync(() -> {
            // 1. 验签
            // 2. 更新订单状态
            // 3. 发送通知
            return new CallbackResult();
        });
    }
}

3.3 第三刀:JVM参数调优

针对高并发场景,我们还需要调整JVM参数:

# 优化后的JVM参数
-Xms4g -Xmx4g  # 堆内存固定,避免动态扩容
-XX:+UseG1GC  # 使用G1垃圾收集器
-XX:MaxGCPauseMillis=200  # 目标GC暂停时间
-XX:+ParallelRefProcEnabled  # 并行处理Reference
-XX:+DisableExplicitGC  # 禁止显式GC
-XX:+HeapDumpOnOutOfMemoryError  # OOM时自动dump
-XX:HeapDumpPath=/data/logs/heapdump.hprof

四、避坑指南:这些坑我们替你踩过了

坑1:fastjson的autoType坑

// 危险代码
JSON.parseObject(jsonString, Object.class);  // 可能触发autoType漏洞

// 安全做法
ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
JSON.parseObject(jsonString, new TypeReference<PaymentRequest>() {}, Feature.DisableCircularReferenceDetect);

坑2:线程池队列无界导致OOM

// 危险:LinkedBlockingQueue默认Integer.MAX_VALUE
new ThreadPoolExecutor(10, 100, 60L, TimeUnit.SECONDS, 
    new LinkedBlockingQueue<>());  // 队列长度21亿!

// 正确:限制队列大小
new ThreadPoolExecutor(10, 100, 60L, TimeUnit.SECONDS, 
    new LinkedBlockingQueue<>(1000));  // 最多1000个待处理任务

坑3:arthas在线调试影响性能

arthas的watchtrace等命令会对方法调用进行增强,生产环境慎用!我们曾经因为在生产开启trace命令,导致性能下降30%。

正确做法

  • 使用 profiler 命令(基于async-profiler,性能影响%)
  • 限制采样时间(30秒~1分钟)
  • 调试完成后立即 stop 或 quit

五、监控体系建设:让问题可视化

光有排查手段还不够,我们需要在问题发生前就建立监控体系:

5.1 JVM监控指标

# Prometheus + Grafana 监控配置示例
jvm_metrics:
  - jvm_memory_used_bytes{area="heap"}
  - jvm_gc_pause_seconds_sum
  - jvm_threads_current
  - jvm_cpu_usage_ratio
  
alert_rules:
  - alert: HighCPUUsage
    expr: jvm_cpu_usage_ratio > 0.8
    for: 2m
    annotations:
      summary: "CPU使用率超过80%"
      
  - alert: HighThreadCount
    expr: jvm_threads_current > 500
    for: 1m
    annotations:
      summary: "线程数超过500"

5.2 应用层监控

// 使用Micrometer埋点
@Service
public class PaymentService {
    private final MeterRegistry meterRegistry;
    private final Timer processTimer;
    
    public PaymentService(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        this.processTimer = Timer.builder("payment.process.duration")
                .description("Payment processing time")
                .register(meterRegistry);
    }
    
    public PaymentResult processPayment(PaymentRequest request) {
        return processTimer.recordCallable(() -> {
            // 业务逻辑
            return doProcess(request);
        });
    }
}

六、总结经验:建立排查SOP

经过这次事故,我们团队总结了CPU问题的排查SOP(标准操作流程):

Phase 1: 紧急响应(0-5分钟)

  1. 保留现场(thread dump, heap dump, 监控截图)
  2. 通知相关人(开发、运维、业务)
  3. 评估影响范围(是否影响核心业务)

Phase 2: 快速止血(5-15分钟)

  1. 如果有重启窗口,灰度重启1台服务器观察
  2. 如果有降级开关,立即降级非核心功能
  3. 扩容(如果是资源不足)

Phase 3: 根因分析(15分钟-2小时)

  1. 使用top + jstack定位高CPU线程
  2. 使用arthas生成火焰图
  3. 代码审查 + 日志分析

Phase 4: 长期优化(事后)

  1. 修复代码问题
  2. 完善监控告警
  3. 压测验证
  4. 复盘总结

七、工具箱推荐

最后,分享一下我们的CPU排查工具箱:

工具 用途 性能影响
top/htop 实时查看进程/线程CPU占用
jstack 线程栈快照 低(会触发Safepoint)
arthas 在线诊断(profiler/watch/trace) profiler %, 其他命令较高
async-profiler 生成火焰图(arthas底层使用) %
JProfiler IDE级性能分析(需离线分析) 高(不适合生产)
Prometheus + Grafana 监控 + 告警

结语

CPU飙升问题并不可怕,可怕的是遇到问题时手忙脚乱、无从下手。建立一套标准化的排查流程,配备合适的工具,才能在关键时刻快速定位问题。

记住:重启不是解决方案,保留现场才能根治问题

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐