

一、引子:三个指标同时报警#
「CPU 打满了怎么排查」是一道标准面试题,标准答案是一串命令:
top # 找到占 CPU 的进程
top -Hp <pid> # 找到占 CPU 的线程
printf '%x\n' <tid> # 线程 ID 转十六进制
jstack <pid> | grep -A 30 <hex-tid> # 找到线程栈bash我背过,也用过,确实能定位到某个具体的方法。
直到有一次线上出问题,监控面板上三个指标同时变红:
- CPU 使用率 95%;
- Full GC 每分钟 12 次;
- 数据库慢查询数量涨了 40 倍。
我按流程走了一遍,jstack 抓到大量线程卡在数据库驱动的 socket read 上。看起来是数据库慢导致的。但 DBA 那边说数据库负载正常,慢查询是从应用侧变慢开始才出现的。
于是问题变成:
CPU 高、GC 频繁、DB 慢,这三个是同时发生的。
哪个是原因,哪个是结果?
我答不上来。而那串命令完全帮不上忙——它能告诉我「线程正在做什么」,但回答不了「为什么会变成这样」。
后来定位到根因是一个接口返回的数据量突然暴涨(上游改了查询条件,单次返回从几百行变成几十万行)。因果链是这样的:
返回数据量暴涨
→ 大对象频繁分配 → 老年代快速填满 → Full GC 频繁 (GC 指标红)
→ Full GC 占用 CPU + 序列化大对象占用 CPU (CPU 指标红)
→ 应用线程被 GC 反复暂停 → 数据库连接持有时间变长
→ 连接池耗尽 → 后续查询排队 (DB 指标红)text三个红灯,一个根因,而且根因不在任何一个红灯上。
这件事让我意识到那套「症状 → 命令」的映射有个根本问题:它假设症状和根因是一一对应的。 而真实故障里,症状是一片相互关联的现象,根因往往藏在没有报警的地方。
这篇文章拆三个说法:
| 说法 | 问题出在哪 |
|---|---|
| 出问题先看日志 | 日志是粒度最细的证据,从它开始等于大海捞针 |
| CPU 高就 jstack,内存高就 dump | 症状到工具不是一一对应,而且这两个工具都有盲区 |
| 复现不了就没法排查 | 生产故障大多不可复现,排查靠的是取证不是复现 |
二、三支柱的顺序:从范围到细节#
2.1 三支柱是按数据形态分的,不是按用途#
可观测性的标准框架是「三支柱」:Metrics(指标)、Traces(链路)、Logs(日志)。
这个划分是按数据形态做的:
| 支柱 | 数据形态 | 存储成本 | 能回答的问题 |
|---|---|---|---|
| Metrics | 聚合后的数值时间序列 | 极低 | 「有没有问题」「什么时候开始的」 |
| Traces | 单次请求的调用链 | 中 | 「问题在哪一环」 |
| Logs | 离散的文本事件 | 高 | 「具体发生了什么」 |
问题在于,这个划分本身不告诉你该按什么顺序用它们。而顺序恰恰是排查效率的关键。
大多数人的习惯是先看日志——因为日志最熟悉,也最容易拿到。但日志是粒度最细的数据。在一个每秒几万条日志的系统里,从日志开始查问题,等于在没有坐标的情况下大海捞针。
正确的顺序应该由范围收缩驱动:
Metrics ──► 确定「哪个服务、什么时间、什么指标」异常 范围:全系统 → 单服务
↓
Traces ──► 找到一条慢的/失败的完整调用链,看卡在哪一环 范围:单服务 → 单环节
↓
Logs ──► 用 trace ID 精确捞出那一环的详细日志 范围:单环节 → 具体细节text每一步都把搜索范围缩小一到两个数量级。跳过前两步直接看日志,就是放弃了这个收缩过程。
2.2 Metrics 该看什么:USE 和 RED#
指标面板通常有几十上百个图。哪些先看?
有两套成熟的方法论,各自适用于不同对象。
Brendan Gregg 提出的方法,用于资源类对象(CPU、内存、磁盘、网络、连接池、线程池)。
对每个资源看三个量:
- Utilization 使用率:资源有多忙(CPU 使用率、连接池占用率);
- Saturation 饱和度:有多少工作在排队(运行队列长度、连接等待数);
- Errors 错误数:失败次数(磁盘 I/O 错误、连接超时)。
关键在于饱和度比使用率更早暴露问题。CPU 使用率 80% 可能完全正常,但如果运行队列长度持续大于核数,说明已经开始排队了——延迟一定在上升。
连接池同理:使用率 90% 不一定有问题,但只要「等待连接的线程数」大于 0,就说明已经有请求在排队。
用于请求驱动的服务:
- Rate 请求速率:每秒多少请求;
- Errors 错误率:失败请求的比例;
- Duration 耗时分布:响应时间,必须看分位数,不能看平均值。
平均值会骗人。假设 99% 的请求 10 毫秒,1% 的请求 5 秒:
平均值看起来还行,但每 100 个用户里就有 1 个等了 5 秒。而这 1% 往往是数据量最大、最重要的那批用户。
所以要看 P50 / P95 / P99 / P999。P99 从 100 毫秒涨到 3 秒,而平均值只从 30 涨到 60——这是最典型的、被平均值掩盖的故障。
两套方法论合起来用:RED 告诉你「服务有问题」,USE 告诉你「哪个资源是瓶颈」。
回到引子那个案例,如果我当时按 USE 检查,会发现「连接池等待线程数」这个饱和度指标早就不为零了——它比 CPU 使用率更早开始异常,因为它是更靠近根因的一环。
2.3 为什么 Traces 是最被低估的一支#
三支柱里,链路追踪是投入产出比最高、但也最容易被跳过的。
原因是它需要提前埋点,而埋点在没出事的时候看不出价值。等到出事了才发现没有 trace,只能靠日志硬拼。
一条完整的 trace 能直接回答「时间花在哪」:
GET /api/order/detail 总耗时 2340ms
├── AuthService.verify 12ms
├── OrderService.getDetail 2280ms
│ ├── SELECT * FROM orders WHERE id = ? 8ms
│ ├── ItemService.batchGet ← 这里 2250ms
│ │ ├── SELECT ... WHERE id = ? 6ms
│ │ ├── SELECT ... WHERE id = ? 7ms
│ │ └── ...(重复 300 次)
│ └── SELECT * FROM logistics WHERE order_id = ? 14ms
└── 序列化响应 48mstext这张图一眼就能看出问题:ItemService.batchGet 名字叫 batch,实际上在循环里单条查询了 300 次。经典的 N+1 问题。
如果只有日志,要发现这一点需要在几万条日志里注意到「同一个 trace 下有 300 条相似的 SQL 日志」——理论上可行,实际上没人会注意到。
2.4 日志:结构化和 trace ID#
到日志这一层时,需要的是精确定位,不是搜索。前提是两件事:
第一,结构化。 文本日志没法做条件查询:
// 不好:纯文本
2026-08-16 10:23:45 用户 12345 下单失败,商品 67890 库存不足
// 好:结构化
{"ts":"2026-08-16T10:23:45Z","level":"WARN","event":"order_failed",
"user_id":12345,"item_id":67890,"reason":"insufficient_stock",
"trace_id":"a1b2c3d4"}text结构化之后能做的事完全不同:按 reason 分组统计失败原因分布、按 item_id 找出哪个商品出问题最多、按 trace_id 关联到完整调用链。
第二,trace ID 贯穿全链路。 这是把三支柱粘起来的东西。有了它,从一条慢 trace 跳到对应的详细日志是一次精确查询,而不是「在这个时间段附近找找看」。
在 Java 里通常用 MDC(Mapped Diagnostic Context)实现,注意异步场景下 MDC 不会自动传递——线程池里的任务拿不到提交方的 MDC,需要手动包装:
// 提交任务时捕获当前 MDC
Map<String, String> context = MDC.getCopyOfContextMap();
executor.submit(() -> {
MDC.setContextMap(context); // 在新线程里恢复
try {
doWork();
} finally {
MDC.clear(); // 必须清理,线程会被复用
}
});javafinally 里的清理不能省。线程池的线程是复用的,不清理会导致下一个任务带上前一个任务的 trace ID——这种日志比没有日志更有害,因为它会把排查引向完全错误的方向。
三、症状到工具的映射,以及工具的盲区#
3.1 「CPU 高」不是一个原因#
引子里那串命令的问题在于,它把「CPU 高」当成了一个可以直接查的原因。但 CPU 高至少有五种完全不同的成因,证据和处理方式都不一样:
| 成因 | 特征 | 关键证据 |
|---|---|---|
| 业务计算密集 | user CPU 高,GC 正常 | 火焰图上是业务方法 |
| GC 占用 | user CPU 高,GC 时间占比高 | jstat -gcutil 的 GCT 列快速增长 |
| 锁竞争 / 自旋 | CPU 高但吞吐不涨 | 大量线程 BLOCKED,或自旋在 park 附近 |
| 系统调用密集 | sys CPU 高 | vmstat 的 sy 列高,通常是 I/O 或频繁上下文切换 |
| 死循环 / 正则回溯 | 单个线程持续 100% | 多次 jstack 栈顶相同 |
区分这几种的第一步不是 jstack,而是先分清 CPU 花在哪一类:
vmstat 1 5
# us sy id wa
# 90 3 7 0 → user 主导:业务计算或 GC
# 30 60 10 0 → sys 主导:系统调用、上下文切换、I/O
jstat -gcutil <pid> 1000 10
# 看 YGCT / FGCT / GCT 的增长速度
# 如果 10 秒内 GCT 涨了 8 秒,说明 80% 的时间在 GCbash先分类,再取证。 跳过分类直接 jstack,很容易看到一堆卡在 I/O 上的线程,然后误判成下游慢——就像引子里我犯的错。
3.2 jstack 的盲区:safepoint bias#
jstack 是最常用的工具,但它有一个很少被提及的系统性偏差。
要抓取所有线程的栈,JVM 需要让线程停在安全点(safepoint)——一个 JVM 能准确知道所有对象引用位置的执行点。jstack 触发的实际上是一次全局停顿:所有线程跑到最近的安全点,停下,采样,恢复。
问题是:安全点不是均匀分布的。
JVM 会在方法返回、循环回边等位置插入安全点检查。但 JIT 编译器有一个优化:对于计数明确的 int 型循环(counted loop),会省略掉循环内的安全点检查,因为编译器认为它会很快结束。
// 这种循环内部可能没有安全点
for (int i = 0; i < array.length; i++) {
sum += compute(array[i]); // 如果 compute 被内联,整个循环可能无安全点
}java于是这段代码即使占用了 90% 的 CPU,jstack 也采不到它——因为线程在到达安全点之前不会停下来,等它停下来时已经跑出这段代码了。采样结果会偏向那些「安全点密集」的代码,而不是真正耗时的代码。
这就是 safepoint bias。它的后果是:jstack 采样出来的热点,可能和真实热点不一致。
3.3 jmap 的陷阱:它可能让故障更严重#
内存问题的标准做法是 jmap 导出堆快照。但有几个变体的行为差别很大,而这个差别在生产环境是致命的:
| 命令 | 是否触发 Full GC | 生产可用性 |
|---|---|---|
jmap -histo <pid> | 否 | 相对安全 |
jmap -histo:live <pid> | 是 | 危险 |
jmap -dump:format=b,file=x.hprof <pid> | 否(但会 STW) | 谨慎 |
jmap -dump:live,format=b,file=x.hprof <pid> | 是 | 危险 |
带 :live 的版本会先做一次 Full GC,因为它要「只统计存活对象」——而判断存活就需要一次完整的可达性分析。
在一个已经因为频繁 Full GC 而濒临崩溃的系统上,手动再触发一次 Full GC,可能就是压垮它的最后一根稻草。
即使不带 :live,导出一个 8 GB 的堆也需要几十秒的 STW,期间服务完全无响应——健康检查会失败,负载均衡会把这台机器摘掉,如果是集群里的最后一台,就是全站故障。
3.4 更轻量的选择#
生产环境的排查工具,选择标准是开销和是否需要重启。按这两个维度排一下:
Java Flight Recorder,JDK 11 之后免费(JDK 8 需要商业授权,8u262+ 也开放了)。
# 运行时开启,记录 60 秒
jcmd <pid> JFR.start duration=60s filename=rec.jfr settings=profile
# 或者启动参数里常态开启,滚动保留
-XX:StartFlightRecording=disk=true,maxsize=1g,maxage=24hbash开销 1% 左右,记录的内容极其全面:方法采样、内存分配、GC 事件、锁竞争、I/O、线程状态、异常。
最大的价值在于它可以常态开启。故障发生后回看过去 24 小时的记录,这是任何「事后去抓」的工具都做不到的。
阿里开源的工具,最大特点是不需要重启、不需要改代码就能观察运行中的应用。
# 最忙的 3 个线程及其栈
thread -n 3
# 找出 BLOCKED 线程和持锁者
thread -b
# 追踪方法内部各步骤耗时(找出慢在哪一行调用)
trace com.example.OrderService getDetail '#cost > 200'
# 观察方法的入参、返回值、异常
watch com.example.ItemService batchGet '{params, returnObj}' -x 2
# 反编译运行中的类(确认线上代码是不是你以为的版本)
jad com.example.OrderServicebashjad 那条被严重低估。「线上代码和我本地看的不是同一个版本」是排查里最浪费时间的一类误会,直接反编译确认能省下几小时。
代价是 Arthas 用字节码增强实现观测,trace 和 watch 在高 QPS 接口上有明显开销,用完必须 stop 恢复。
有些问题在 JVM 层看不见:
# 网络连接状态统计:CLOSE_WAIT 堆积说明没关连接
ss -s
ss -ant | awk '{print $1}' | sort | uniq -c
# 磁盘 I/O:util 接近 100% 说明磁盘饱和
iostat -x 1
# 上下文切换:cs 列异常高说明线程太多或锁竞争激烈
vmstat 1
# 系统调用追踪(开销大,谨慎使用)
strace -c -p <pid>bashCLOSE_WAIT 堆积是个典型例子:它意味着对端关闭了连接而你的应用没有调用 close()。这在 JVM 内部完全看不出来,但会持续泄漏文件描述符,最终 Too many open files。
3.5 相关不等于因果#
回到引子的核心问题:三个指标同时变红,怎么判断因果?
有几个可操作的判据:
判据一:看时间的先后顺序。
把几个指标画在同一张时间轴上,放大到秒级。最先开始异常的那个,更可能靠近根因。
引子那个案例里,如果我把「响应数据大小」这个指标也画上去(它当时没有报警,因为没设阈值),会看到它比 CPU 早了大约 40 秒开始上涨。
这也说明:报警阈值只覆盖你想到的指标,而根因常常在没设阈值的指标上。 所以排查时要看的面板,应该比报警的面板宽得多。
判据二:看依赖方向。
A 依赖 B,那么 B 出问题会导致 A 出问题,反过来通常不成立。所以在依赖链上,越靠下游的异常越可能是根因。
但要小心一个例外:上游的行为变化会伪装成下游故障。引子里就是这样——应用请求数据量暴涨,表现出来是数据库慢。数据库是下游,但根因在上游。
判断方法是看下游自身的资源指标:数据库的 CPU、I/O、慢查询日志。如果下游资源完全正常但响应慢,那多半是上游的请求变了(量变大、条件变复杂、并发变高)。
判据三:做一次干预。
如果观察不出因果,就制造一个变化:把某个服务的流量摘掉一半、把某个功能降级、回滚最近的一次发布。看哪个动作让指标恢复。
这是最可靠但成本最高的方法,通常留到最后用。
四、不可复现才是常态#
4.1 「复现问题」这个前提在生产环境不成立#
开发环境的调试流程是:复现 → 打断点 → 单步 → 找到 bug。这套流程有一个隐含前提:问题可以被稳定触发。
生产故障大多不满足这个前提:
- 依赖特定数据:某个用户的某条脏数据,触发了一个从没走过的分支;
- 依赖并发时序:两个线程以特定顺序交错才出现,概率千分之一;
- 依赖累积状态:连续运行 72 小时后内存碎片化到某个程度才发作;
- 依赖真实流量:QPS 到 5000 才出现,压测环境到不了这个量;
- 依赖环境差异:生产的内核版本、JDK 小版本、网络拓扑和测试环境不同。
对这些问题,「先复现」不是一个可行的起点。排查的重心必须从「复现」转移到「取证」。
两者的区别是:复现是让问题再次发生以便观察;取证是在问题发生的那一刻,尽可能多地保留现场,事后分析。
4.2 三类取证手段#
在某个时刻抓取系统的完整状态:堆转储、线程转储、GC 日志。
优点:信息完整,一个堆转储包含了那一刻所有对象和引用关系。 缺点:开销大,只有一个时间点,看不到变化过程。
适合状态类问题——内存泄漏、死锁、连接池耗尽。这类问题的特征是「某个状态积累到了不该有的程度」,一个快照就能看出来。
关键是要自动化。故障发生时人往往不在场,或者忙于恢复服务,等想起来取证时现场已经没了:
# OOM 时自动 dump
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dumps/
# GC 日志常态开启(JDK 9+),带滚动
-Xlog:gc*:file=/data/logs/gc.log:time,uptime:filecount=10,filesize=100MbashGC 日志的开销可以忽略,但没开的时候,事后想分析 GC 行为就完全没有数据。这是成本收益比最悬殊的一个配置。
按固定频率抓取部分信息:CPU 采样、分配采样、JFR。
优点:开销低,能看到一段时间内的趋势和分布。 缺点:是统计结果,不保证抓到具体某次异常。
适合性能类问题——哪个方法慢、哪里分配了大量对象、锁竞争在哪。这类问题的特征是「有统计规律」,采样正好能捕捉规律。
前面说的 JFR 常态开启属于这一类,也是我认为最值得配置的一项:
-XX:StartFlightRecording=disk=true,maxsize=1g,maxage=24h,\
settings=profile,filename=/data/jfr/app.jfrbash24 小时滚动,1 GB 上限,约 1% 开销。故障后可以回看故障前后的完整记录。
复制一份真实流量到另一套环境,在那里随便折腾。
- 流量录制回放:把生产请求录下来,在测试环境重放;
- 流量镜像:网关把请求同时转发到影子环境(不返回结果给用户);
- 影子表:写操作打到影子表,不影响真实数据。
优点:能用真实流量和真实数据触发问题,还能开高开销的诊断工具。 缺点:搭建成本高,写操作的隔离很难做干净。
适合难以在生产观测但需要真实负载的问题。也是压测和容量评估的基础设施。
4.3 保留现场 vs 快速恢复#
这是故障处理中最真实的冲突。
服务挂了,重启能立刻恢复。但重启会清空所有现场——堆里的对象、线程状态、连接池状态,全没了。下次再出,还是不知道原因。
我见过团队反复重启同一个服务半年,每次都能「解决」,但从来没找到根因。
平衡点通常是这样的:
| 情况 | 优先 | 做法 |
|---|---|---|
| 单机故障,集群有冗余 | 保留现场 | 摘掉这台机器的流量,保留进程,慢慢分析 |
| 全站不可用 | 快速恢复 | 先重启,但重启前抓一份最小现场 |
| 反复出现的老问题 | 保留现场 | 这次必须查清楚,否则会一直反复 |
「重启前抓一份最小现场」是关键技巧。完整堆转储太慢,但下面这些只要几秒:
# 一次性抓取,总共几秒钟
jstack <pid> > /tmp/jstack.$(date +%s).log
jstat -gcutil <pid> > /tmp/jstat.log
jcmd <pid> GC.heap_info > /tmp/heap_info.log
jmap -histo <pid> | head -50 > /tmp/histo.log # 注意不加 :live
ss -ant | awk '{print $1}' | sort | uniq -c > /tmp/conn.log
top -Hp <pid> -b -n 1 > /tmp/threads.logbash这些加起来不到 10 秒,但能覆盖大部分场景的初步定位。写成一个脚本放在机器上,故障时直接跑,比临时回忆命令快得多。
4.4 那些从一开始就该配好的东西#
排查能力的上限,在故障发生之前就被决定了。故障当下能做的,只是使用已经存在的数据。
一份最小清单:
| 项目 | 配置 | 为什么 |
|---|---|---|
| GC 日志 | -Xlog:gc* 滚动保留 | 开销为零,不开就永远分析不了 GC |
| OOM 自动 dump | -XX:+HeapDumpOnOutOfMemoryError | OOM 现场只有一次机会 |
| JFR 常态录制 | maxage=24h 滚动 | 能回看故障前的状态 |
| trace ID 全链路 | MDC + 网关注入 | 三支柱之间的粘合剂 |
| 结构化日志 | JSON 格式输出 | 否则无法做条件查询 |
| 尾部采样 | 慢/错请求全保留 | 头部采样在关键时刻没数据 |
| 健康检查区分 | liveness vs readiness | 避免夯住的实例继续接流量 |
| 关键指标看板 | RED + USE,含饱和度 | 报警只覆盖想到的,看板要更宽 |
最后一项值得强调。报警是给「已知的坏情况」设的,而根因常常在没有报警的指标上。 所以看板上要有大量不报警但会被查看的指标——比如引子里那个「响应数据大小」。
五、方法论:怎么排查一个没见过的问题#
5.1 二分,而不是猜#
面对一条长链路,最有效的策略是二分:在中间某个点插入观测,判断问题在前半段还是后半段。
用户 → CDN → 网关 → 应用A → 应用B → 数据库
↑
先在这里测:到 A 之前正常吗?text具体做法:
- 在中间节点看它记录的耗时。如果 A 记录的「自身处理耗时」正常但「总耗时」高,说明慢在 A 之后;
- 直接绕过前半段:从应用 A 所在的机器上直接
curl应用 B,看是否慢。如果不慢,问题在 A 到 B 之间的网络或调用层,而不是 B 本身。
每次二分把范围减半。10 个环节的链路,3~4 次就能定位。
比二分更糟的是「从头顺着查」,那是线性的;比线性更糟的是「凭印象猜」——猜中了是运气,猜不中就浪费了一轮时间,而且会形成心理锚定,后面看到的证据都会被往那个方向解读。
5.2 先看变更,再看代码#
上一节说过,但值得作为独立的一条。超过一半的故障能由最近的变更解释。
排查开始的前五分钟应该用来问:
- 最近一次发布是什么时候,改了什么?
- 配置中心最近有推送吗?(这个最容易被忘记,因为它不走发版流程)
- 依赖的服务最近发布了吗?
- 有没有定时任务、大促、批量作业正好在这个时间点?
- 基础设施有变更吗?扩缩容、网络策略、证书?
第 2 条我踩过坑。一个动态配置把线程池核心数从 200 改成 20,推送后十分钟服务开始超时。因为不走发版流程,所有人都在查代码,而代码半个月没动过。
把「变更时间线」和「指标时间线」叠在一起看,是效率最高的一个动作。很多故障在这一步就结束了。
5.3 记录你的推理,而不只是结论#
排查过程中会形成很多假设。人的记忆很不可靠,尤其在压力下——半小时后你会忘记自己排除过哪些可能,然后重复验证。
我的做法是边查边记,格式很简单:
10:23 现象:订单接口 P99 从 80ms 涨到 3s,错误率 0
10:25 假设1:数据库慢 → 查 DBA 面板,DB 侧 CPU/IO 正常 → 排除
10:28 假设2:GC 问题 → jstat 显示 FGC 12次/分钟 → 可能,继续
10:31 假设3:为什么 FGC 变多?→ 看堆内对象分布 → byte[] 占 70%
10:35 发现:上游接口返回体大小从 20KB 涨到 8MB(此指标无报警)
10:37 验证:回滚上游发布 → 指标恢复 → 确认text这份记录有三个作用:
- 避免重复验证已排除的假设;
- 交接时能让别人快速接上,不用从头讲一遍;
- 复盘时是最好的材料——能看出哪一步走了弯路,下次改进。
第 3 点最有价值。回头看这份记录,我会发现「假设 1 花了 3 分钟才排除,因为要找 DBA 要面板权限」——那么下次的改进项就是提前把 DB 面板加到自己的看板里。
5.4 区分「止血」和「根治」#
故障处理有两个目标,它们经常冲突:
- 止血:让服务尽快恢复。手段是重启、回滚、扩容、降级、切流量;
- 根治:找到并修复根因。需要时间,需要现场。
这两件事应该由不同的人同时做。 一个人负责恢复服务,另一个人负责保留现场和分析。如果只有一个人,那顺序是:先抓最小现场(10 秒),再止血,然后慢慢分析。
最糟的模式是一边重启一边分析——重启把现场清了,分析永远得不出结论,然后下周同样的故障再来一次。
5.5 最后#
回到引子那个案例。我当时的错误不在于不会用 jstack,而在于我以为排查是一个查找过程——找到那个出问题的地方。
实际上排查是一个推理过程:从一组相互关联的现象出发,构造一条能解释所有现象的因果链,然后验证它。
「CPU 高 → jstack → 找到线程」这种映射之所以不够用,是因为它跳过了推理,直接从症状跳到了工具。而症状和根因之间隔着一整条因果链。
三句话总结这次拆解:
- 三支柱要按范围收缩的顺序用:Metrics 定范围,Traces 定环节,Logs 定细节。直接从日志开始是在放弃已有的收缩手段;
- 工具都有盲区:
jstack有 safepoint bias,jmap -histo:live会触发 Full GC。知道盲区在哪,比多记几个命令重要; - 排查能力在故障之前就决定了:GC 日志、JFR、trace ID、结构化日志,这些没提前配好,故障当下再想要就来不及了。
最后一句可能是最重要的:排查不是一项临场技能,而是一项准备工作。
参考#
- Gregg, Brendan. Systems Performance: Enterprise and the Cloud, 2nd ed. Addison-Wesley, 2020.(USE 方法与系统性能分析的经典)
- Gregg, Brendan. The Flame Graph. Communications of the ACM, 2016.
- Wilkie, Tom. The RED Method: How to Instrument Your Services. Grafana Labs, 2018.
- Majors, Charity; Fong-Jones, Liz; Miranda, George. Observability Engineering. O’Reilly, 2022.
- Nitsan Wakart, Why (Most) Sampling Java Profilers Are Fucking Terrible. 2013.(safepoint bias 的经典论述)
- async-profiler 项目文档, AsyncGetCallTrace 与各 event 模式.
- Arthas 官方文档, trace / watch / jad 命令.
- OpenJDK, JDK Flight Recorder 与 Unified Logging (JEP 158).
本站相关: