性能分析与优化方法论(六条排查线)
自顶向下 vs 自底向上 · 观测入口决策树 · CPU / 内存 / 锁 / GC / IO / 网络六条排查线 · eBPF 工具箱 · 优化决策链
一句话结论
性能分析先方法后工具:自顶向下 USE/RED 定位是哪一段慢 → 自底向上火焰图定位具体热点。六条排查线(CPU / 内存 / 锁 / GC / IO / 网络)各有工具箱与决策树入口。优化决策链四条铁律:先测量后优化 / 先架构后微观 / 先热点后长尾 / 先无损后有损。GC-STW、极限低延迟、极限发压这些"点上打透"的详细展开都交由单一事实源,本篇讲方法论骨架与入口决策树。
本篇的红线:不重复展开
- GC 内部原理与 STW 优化详见 gc-stw,本篇只讲入口指标与联动排查动作。
- 网络全栈延迟预算详见 net-lowlatency-fullstack、tcp-net,本篇只讲抓包排障入口。
- 极限压测与容量倒推详见 loadgen-extreme,本篇只讲"压测数据如何倒推系统瓶颈"。
- 并发陷阱与锁选型详见 concurrency,本篇只讲锁竞争的信号与工具。
场景问题
线上一个真实的排查现场往往是这样开局:
- P99 长尾:Grafana 上 P99 从 20ms 突刺到 300ms,均值只涨到 25ms——用平均值根本看不出。
- CPU 飙高:某台机 CPU 100%,其他机器 30%,负载均衡看着均匀——热点具体在哪个函数?
- 内存慢泄漏:RSS 每天涨 200MB,一周后 OOM——是分配率高还是真泄漏?
- 吞吐掉了:QPS 从 5000 掉到 3000,日志无异常——瓶颈是 CPU / IO / 锁 / GC 哪一段?
- 系统抖动:几十秒一次,无规律,指标看不到,玩家投诉——是 GC / 页回收 / 内核抢占 / 网络重传?
- 网络异常:请求慢,
ping正常,curl正常,但业务就是慢——TCP retrans / 队头阻塞 / DNS?
三个真实的性能事故
- 只看均值漏掉 P99:均值 25ms 大家觉得没事,P99 已经 300ms 卡到玩家;应盯直方图分位数而非平均值(详见 observability 的分位数陷阱)。
- 直接改代码不看剖析:凭经验优化"这个循环肯定慢",改完发现瓶颈根本在别处;先测量后优化是铁律。
- 优化了长尾放弃了热点:花两周优化 P99,主流程 P50 反而变慢——先热点后长尾,别本末倒置。
性能分析的核心不是"这里能不能加个 cache",而是先用方法论定位是哪一段慢,再决定用什么工具打透。方法论错了,工具再多都是白搭。
实现方案
一:方法论主线——自顶向下 vs 自底向上
两种正交路径,何时切换很重要:
| 方法 | 从哪里入手 | 适用场景 | 工具 |
|---|---|---|---|
| 自顶向下(USE / RED) | 业务指标 / 资源指标 | 有告警、有明确症状 | Grafana / Prometheus / OpenTelemetry |
| 自底向上(火焰图) | 采样剖析 | 症状模糊、抖动无规律 | pprof / perf / bpftrace / Flame Graph |
USE 方法(Brendan Gregg):针对资源(CPU / Mem / Disk / Net):
- Utilization(利用率)
- Saturation(饱和度:排队长度)
- Errors(错误数)
RED 方法(Weave / Tom Wilkie):针对请求(每个服务):
- Rate(QPS)
- Errors(错误率)
- Duration(延迟分布,含 P50/P99)
两个方法的实际组合
- 有告警时先走 RED 定位是哪个服务/接口异常 → 再用 USE 看这个服务的资源是不是撞墙 → 再用火焰图看具体热点在哪段代码。这条链路是最常用的,绝大多数事故照它排。
- 症状极度模糊(比如"游戏抖动无规律")时反过来:火焰图 + off-CPU 分析看谁在等锁 / 睡眠 → 定位到具体点后回头验 USE。
二:观测入口决策树
线上出问题的六类症状 → 对应的首个排查动作:
三:六条排查线
① CPU 热点
判定信号:top 单核 100% / 火焰图某函数 >20% 帧占比 / P99 与 CPU 时间成正比。
工具箱:
| 工具 | 用法 | 场景 |
|---|---|---|
top / htop | H 键切换线程视图,P 排序 CPU | 定位哪个线程/进程 |
perf top | perf top -p <pid> | 内核态 + 用户态实时热点 |
perf record + report | perf record -F 99 -p <pid> -g -- sleep 30 | 生成带调用栈的采样 |
go tool pprof | go tool pprof http://localhost:6060/debug/pprof/profile | Go 服务 CPU 剖析 |
| 火焰图 | perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg | 直观看热点 |
on-CPU vs off-CPU:
- on-CPU 火焰图:谁在用CPU(真忙)
- off-CPU 火焰图:谁在等CPU / 等锁 / 等 IO(假闲)——症状模糊时更有用
伪共享(False Sharing):不同核修改同一 cache line 内不同字段,导致 cache line 反复失效。信号:perf c2c 报告 HITM 高。修复:字段间用 [64]byte 填充或 alignas(64) 分离。
② 内存分配
判定信号:RSS 长期上涨 / P99 与 GC 频率正相关 / 分配率 (allocs/sec) 高。
核心区别:分配率高 ≠ 内存泄漏:
- 分配率高:短生命周期对象多,压 GC。优化方向:对象池、栈上分配、逃逸分析优化。
- RSS 增长(真泄漏):长期不释放。排查方向:heap dump + 对比不同时间点的 Retained Size。
- RSS 增长(非泄漏):堆碎片、大对象分配失败回退分配器、glibc arena 缓存。排查方向:
jemalloc/tcmalloc换分配器、MALLOC_ARENA_MAX限 arena。
工具箱:
| 工具 | 用法 | 场景 |
|---|---|---|
pprof heap | go tool pprof -alloc_space (分配率) / -inuse_space (存活) | Go 内存剖析 |
jmap / jstat | jmap -histo:live / jstat -gc | Java 堆快照与 GC 统计 |
| Heap Dump 对比 | 两个时间点 dump 对比 Retained Size | 定位真泄漏点 |
pmap -x <pid> | 查看进程内存段分布 | 定位是堆 / 匿名映射 / 文件映射 |
MALLOC_CONF (jemalloc) | MALLOC_CONF="prof:true,lg_prof_sample:19" | 分配器级 profiling |
逃逸分析(Go):go build -gcflags="-m" 看哪些变量从栈逃到堆。核心五因:变量地址被返回 / 接口装箱 / 闭包捕获 / 大对象 / 编译期不能确定大小。
三种"RSS 涨"如何分辨
- 持续单调涨、不 GC 后回落:泄漏。
- 锯齿状涨落但均值上移:分配率过高 + GC 跟不上,看 GC 频率与堆大小是否成正比。
- 突增后不回落,但 pprof heap live 不涨:分配器 arena 缓存或碎片,看
malloc_stats。
③ 锁竞争
判定信号:QPS 上不去但 CPU 未满 / 单核 100% 其他核空闲 / mutex profile 显示某锁等待时间高。
工具箱:
| 工具 | 用法 | 场景 |
|---|---|---|
Go mutex profile | runtime.SetMutexProfileFraction(1) + go tool pprof mutex | Go 锁竞争剖析 |
Go block profile | runtime.SetBlockProfileRate(1) + go tool pprof block | Go 阻塞时间剖析(channel、Cond 等) |
Java jstack | jstack -l <pid> | 看线程栈找 BLOCKED / WAITING |
perf lock | perf lock record + report | 内核锁 |
bpftrace | bpftrace -e 'kfunc:mutex_lock { ... }' | 定制锁跟踪 |
锁优化决策链:
- 能不锁就不锁:分片、无锁数据结构(sync.Map、Ring Buffer + CAS)
- 锁粒度:粗锁 → 细锁(每 shard 一把)
- 锁类型:Mutex → RWMutex(读多写少)→ Atomic / CAS(简单原语)
- 协议:Lock-Free(CAS + ABA 防护)→ Wait-Free
- RCU(Read-Copy-Update):读永不阻塞
详见 concurrency 的锁选型章节。
④ GC-STW
判定信号:P99 突刺 / GC 频率高 / 堆大小锯齿 / STW 时长超预期。
入口指标(本篇只讲入口,详见 gc-stw):
- P99 突刺周期与 GC 触发周期是否吻合?吻合 → GC 是元凶。
- GC 频率(Go:
GOGC影响,Java: 每分钟 Full GC 次数) - 堆大小 & Allocation Rate(分配率过高 → GC 跟不上)
- STW 时长(Go 亚毫秒 / ZGC 亚毫秒 / G1 几十 ms / CMS 数百 ms)
联动排查动作:
- Go:
GODEBUG=gctrace=1+ pprof allocs → 优化对象池 - Java:
jstat -gc 1s+ GC 日志 + gceasy.io 分析
⑤ IO / 系统调用
判定信号:CPU 空闲但请求慢 / iowait 高 / strace 看到大量 syscall。
工具箱:
| 工具 | 用法 | 场景 |
|---|---|---|
iostat -x 1 | 磁盘 IO 利用率、队列深度 | 定位慢盘 |
iotop | 按进程排序 IO | 定位哪个进程写盘多 |
strace -c -p <pid> | 统计 syscall 分布 | 定位 syscall 热点 |
strace -T -p <pid> | 每个 syscall 耗时 | 定位慢 syscall |
dstat -tam | 综合 IO / 网络 / 内存 | 快速排查 |
bpftrace | biosnoop.bt 生物级 IO 跟踪 | 精确到每次 IO |
慢盘 vs 慢文件系统:
- 慢盘:
iostat看%util长时间 100%、await高。 - 慢文件系统:磁盘
%util不高但业务慢,看strace里open/close/fstat占比——目录树大、inode 命中率低。
mmap 陷阱:
- Page fault 是同步阻塞的,一次未命中 = 一次磁盘 IO 卡当前线程;
madvise(MADV_SEQUENTIAL/MADV_WILLNEED)可预取;- 大文件 mmap 撞 vsize 限制,
MAP_ANONYMOUS内存也不算 RSS 但算 vsize。
fsync / sync 频率过高 → 写吞吐骤降;批量写 + 定时 fsync 是常规优化。
⑥ 网络
判定信号:请求慢、ping 正常、curl 短请求正常但业务长连接慢 / netstat -s 看到 retrans 增长。
工具箱:
| 工具 | 用法 | 场景 |
|---|---|---|
ss -tin | 看每个 TCP 连接的 cwnd / rtt / retrans | 单连接级排障 |
netstat -s | grep -i retrans | TCP 重传统计 | 网络质量 |
tcpdump -i eth0 -w cap.pcap host x.x.x.x | 抓包 | 详细排障 |
wireshark / tshark | 分析 pcap | 深入协议细节 |
bpftrace | tcpretrans.bt / tcpconnlat.bt | eBPF 一线工具 |
mtr | mtr -rn -c 100 <host> | 路径每跳延迟与丢包 |
常见抓包场景:
- P99 长尾 + retrans 增长:接入网络质量或 buffer 不够,看 cwnd。
- connect 慢:SYN 丢或 backlog 打满,
ss -lnt看 Recv-Q。 - 业务耗时长,抓包快:问题在应用层(GC、锁、慢查询)。
- 业务耗时短,抓包慢:问题在网络传输链路。
详见 tcp-net 和 net-lowlatency-fullstack。
四:eBPF 工具箱(现代排障利器)
eBPF 是内核可编程的观测/追踪机制,无需改内核也无需重启。常用 one-liner:
# 进程调度延迟(等 CPU 的时间)
bpftrace -e 'kfunc:sched_wakeup { @start[args->p->pid] = nsecs; }
kfunc:finish_task_switch { $delta = nsecs - @start[pid];
@sched_lat = hist($delta / 1000);
delete(@start[pid]); }'
# TCP 重传溯源(哪个连接在重传)
bpftrace -e 'kfunc:tcp_retransmit_skb { printf("retrans %d -> %s\n",
args->sk->__sk_common.skc_num,
ntop(args->sk->__sk_common.skc_daddr)); }'
# 系统调用延迟分布
bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @start[tid] = nsecs; }
tracepoint:raw_syscalls:sys_exit /@start[tid]/ {
@syscall_lat[args->id] = hist(nsecs - @start[tid]);
delete(@start[tid]); }'
# off-CPU 火焰图(谁在睡)
bpftrace -e 'kfunc:finish_task_switch { @off_cpu[kstack, ustack] = count(); }'
bcc-tools 常用工具:biolatency(块 IO 延迟)、tcplife(TCP 连接生命周期)、runqlat(运行队列延迟)、profile(采样调用栈)、funccount(函数调用计数)。
五:压测倒推系统瓶颈
压测数据不是"跑出个数字",是"倒推系统瓶颈"(详见 loadgen-extreme):
关键姿势:吞吐不线性 + 水位未满 = 被测饱和;水位满 = 发压机瓶颈。这是分辨"到底谁先撞墙"的唯一姿势。
六:优化决策链
四条铁律,按优先级:
- 先测量后优化:无数据的优化 = 猜。做 A/B 对照实验,改前改后同一压测复现。
- 先架构后微观:算法从 O(n²) 降到 O(n log n) 的收益 >> 循环内加
__builtin_prefetch。 - 先热点后长尾:优化 20% 时间占比的热点 > 优化 1% 时间的长尾。
- 先无损后有损:无损优化(对象池、批量 syscall、算法替换)走完再谈有损(降级 / 采样 / 精度让步)。
有损优化清单
"无损"路走完还不够时的有损选项:
- 降级:非核心功能关闭(如统计、审计、日志级别);
- 采样:日志/Trace 从全量降到 10%/1%;
- 精度让步:
float64→float32、时间戳分辨率降低; - 兜底:缓存降级到本地静态、返回上次结果。
有损优化是协商出来的,不是"技术自己决定"的。
为什么这么做
- 为什么先方法后工具:工具永远学不完(perf / pprof / bpftrace / strace / ss / tcpdump 一大堆),但方法只有两条——自顶向下和自底向上。方法定了工具就位。反过来先学工具,出事时脑子会乱,不知道从哪里入手。
- 为什么六条排查线并列:真实事故的表现往往是"P99 长尾"这种症状级信号,但根因可能在六条线里任一条。不并列列出、不给决策树,排障就变成"经验英雄主义"(老兵靠感觉,新人抓瞎)。
- 为什么四条优化决策链有严格优先级:违反其中任一条都会踩坑——不测量就是猜、不看架构就是修修补补、不看热点就是白干、不做无损直接有损就是甩锅给业务。这四条是花钱买教训学到的。
- 为什么单独讲 eBPF:传统工具(perf / strace / gdb)多为侵入式(
SIGSTOP或ptrace会影响进程),生产环境不敢开。eBPF 是低开销、生产可用的观测机制,是现代排障的核心利器;不会 eBPF 相当于放弃一半排障能力。 - 为什么最后讲压测倒推:性能优化不能只在事后排障——压测本身就是"提前把瓶颈暴露出来"的手段。压测和排障用的是同一套工具箱,前者是主动、后者是被动。
为什么别的选择不行
- "凭经验优化,不看剖析":改完发现瓶颈根本在别处,白干。先测量后优化不是原则,是铁律。
- "只看 CPU / 只看内存 / 只看网络":真实事故经常跨多条排查线(比如 GC 引发 CPU 高,锁竞争引发 P99 长尾),单线思维会漏掉根因。
- "直接上 eBPF 打透一切":eBPF 有学习曲线、低层信号需要经验解读。先走 USE/RED 定位到具体服务/接口,再上 eBPF 才有针对性。
- "追求 P50 优化 / 忽略 P99 长尾":用户感知的是 P99(尾延迟),P50 优化不掉 P99 = 用户仍卡。
- "追求 P99 极致 / 放弃 P50 主流程":反过来也一样坑——为 P99 加复杂缓存/预取,P50 主流程反而变慢,得不偿失。
- "直接上有损(降级/采样)跳过无损优化":把技术问题甩给业务是最偷懒的做法。无损优化路走完再谈有损。
三个绝对不做的动作
- 不做基线对比就宣布"优化了":改前改后必须同一压测复现,数字才作数;
- 只在测试环境优化不在生产验证:测试环境的负载/依赖/数据量与生产不同;
- 一次改多处然后不知道哪个起作用:改一处、测一次、留基线,串行验证。
沉淀结论
- 方法先行:自顶向下(USE/RED 定位是哪一段)→ 自底向上(火焰图定位具体热点);症状模糊时反过来。
- 观测入口决策树:错误率高 → 错误码分布;P99 长尾 → 单机 vs 多机;吞吐下降 → 下游 vs 资源;CPU 高 → on-CPU 火焰图;内存涨 → 分配率 vs RSS;抖动 → off-CPU 火焰图 + GC 日志。
- 六条排查线:CPU 热点 / 内存分配 / 锁竞争 / GC-STW / IO 系统调用 / 网络——各有工具箱与信号。
- eBPF 是现代排障利器:进程调度、TCP retrans、syscall 延迟、off-CPU 都能一行搞定,且生产可用。
- 压测倒推:吞吐不线性 + 水位未满 = 被测饱和;水位满 = 发压机瓶颈。
- 优化决策链四铁律:先测量后优化 / 先架构后微观 / 先热点后长尾 / 先无损后有损。
- 有损优化清单:降级 / 采样 / 精度让步 / 兜底——最后手段,且必须与业务协商。
记忆口诀
两条方法:自顶向下 USE/RED 定位 / 自底向上火焰图打透
USE 三要素:Utilization / Saturation / Errors(资源视角)
RED 三要素:Rate / Errors / Duration(请求视角)
六条排查线:CPU / 内存 / 锁 / GC / IO / 网络——各有工具箱
内存三看:分配率高 vs RSS 涨 vs 碎片——别混
锁优化四步:不锁 → 细粒度 → 换类型 → Lock-Free/RCU
四条决策链:测量 → 架构 → 热点 → 无损(再有损)
压测判据:不线性+未满=被测饱和 / 已满=发压机瓶颈
eBPF 四把刀:runqlat / tcpretrans / syscall lat / off-CPU
内容来源
综合整理。参考方向:Brendan Gregg 的 USE 方法与 Systems Performance 一书、Weave 的 RED 方法、火焰图工具链(FlameGraph GitHub)、Go pprof 官方文档、Java jstat/jmap/gceasy、eBPF 工具箱(bcc-tools、bpftrace)、以及站内 gc-stw / net-lowlatency-fullstack / tcp-net / loadgen-extreme / concurrency / observability 的方法论视角。
自测:合上资料能说清楚吗?
Q1: 一条 P99 长尾告警来了,你的前 3 步是什么?
- 看 P99 与均值的差:均值涨得少但 P99 涨很多 → 是尾延迟问题,而非整体劣化;先确认告警不是均值的假象。
- 看单机还是多机:单机 → 走 USE 排查该机资源饱和;多机 → 走 RED 排查下游依赖或 GC 类全局因子。
- 看 P99 突刺周期与 GC 触发周期是否吻合:吻合 → 直奔 GC 排查线;不吻合 → 走火焰图看具体热点。
之后:Metrics → Trace → Log 三支柱定位到接口 + span → 上 pprof/perf 或 eBPF 打透。
Q2: RSS 上涨到底是分配率高还是真泄漏?
看三个信号:
- 持续单调涨、GC 后不回落 → 真泄漏,用 heap dump 两点对比定位 Retained Size 涨的对象类型。
- 锯齿状涨落但均值上移 → 分配率过高 + GC 跟不上,看 GC 频率是否随堆增大。此时优化对象池、减少分配比找泄漏更有效。
- 突增后不回落但 pprof heap live 不涨 → 分配器 arena 缓存或碎片。看
malloc_stats、MALLOC_ARENA_MAX,或换 jemalloc/tcmalloc。
关键:RSS 只是操作系统视角的信号,得配合 pprof -inuse_space 和 GC 日志才能分辨。
Q3: on-CPU 火焰图和 off-CPU 火焰图有什么区别?各自用在什么场景?
on-CPU 火焰图:谁在用CPU(真忙)。x 轴宽度 = CPU 占用比例。定位热点函数。
off-CPU 火焰图:谁在等(阻塞 / 等锁 / 等 IO / 等调度)。x 轴宽度 = 等待时间。定位抖动源。
CPU 满、想找热点 → on-CPU。
CPU 空闲但慢、找不到热点 → off-CPU(生成方式:
bpftrace -e 'kfunc:finish_task_switch'采样调度切换时的调用栈)。抖动无规律、症状模糊 → 优先 off-CPU,它经常揭示 on-CPU 看不到的问题(锁等待、页回收、GC 睡眠)。
Q4: 压测时 QPS 上不去,怎么判断瓶颈在发压机还是被测系统?
四步(详见 loadgen-extreme):
- 回环基线:发压机压 localhost,排除网络。
- 看发压机资源:某核 CPU 100%?FD/端口耗尽?网卡饱和?
- 看被测资源:CPU / 内存 / 网卡是否还有余量。
- 多发压机对照(最有力):加一台发压机,总 QPS 线性增长 = 发压机是瓶颈;不线性增长 = 被测饱和。
铁律:吞吐不线性 + 水位未满 = 被测饱和;水位满 = 发压机瓶颈。
Q5: 优化决策链的四条铁律为什么按这个顺序?违反哪条最坑?
顺序:测量 → 架构 → 热点 → 无损。
- 不测量最坑:改完发现瓶颈根本在别处,全白干。这是最基础的错误。
- 不看架构最费:微观优化(内联、消除边界检查)能省 5%,架构优化(换算法、加缓存)能省 50%。先做微观优化 = 白花时间。
- 不看热点最亏:优化 1% 时间占比的长尾对整体没意义,用户感知不到。
- 直接有损最偷懒:绕开技术问题让业务牺牲。无损路走完再谈有损,且有损必须协商。
四条独立可违反,但违反测量是根本性错误——其他三条至少还能"改对了",测量错就是"改假了"。
Q6: eBPF 相比 strace / gdb 有什么优势?为什么说是"生产可用"?
- strace:基于
ptrace,会同步阻塞被追踪进程,性能开销大(10x+),生产环境不敢开。 - gdb:会
SIGSTOP进程,且中断业务;调试用可以,观测用不行。 - perf:采样式,开销较低,但功能相对固定。
- eBPF:内核可编程,程序在内核 VM 里以受限方式执行,开销通常 <1%,且不阻塞被追踪进程。可以:定制追踪任意 syscall/kfunc、聚合直方图、生成 flame graph 数据、TCP retrans 溯源等。是生产环境唯一可以随时开的深度追踪工具。
代价:学习曲线、需要 4.9+ 内核(BPF Type Format 需要 5.2+),需要 CAP_BPF 权限。