50.8 持续性能剖析 (Profiling)
一句话
链路告诉你「哪一跳慢」,剖析告诉你「那一跳的 CPU 花在哪个函数」;持续剖析把这个动作从「出事后登机器采 30 秒」变成「7×24 按分钟采样、事后按版本回看」,代价不是存储(很小),而是采样开销与符号化。
四类剖析数据,回答四个不同的问题
| 类型 | 回答什么 | Go 里的入口 | 采样方式 | 相对开销 |
|---|---|---|---|---|
| CPU(on-CPU) | 时间花在哪些函数上 | pprof.StartCPUProfile / /debug/pprof/profile | 100 Hz 定时中断采调用栈 | 通常 1%–5%(量级) |
| 堆(heap) | 谁在占内存(inuse)/ 谁在分配(alloc) | /debug/pprof/heap | 按分配量概率采样(默认 512 KB 采样一次) | 低,随 GC 触发 |
| goroutine | 谁在阻塞、谁在等锁、协程是否泄漏 | /debug/pprof/goroutine | 抓当前全部栈(?debug=1 可读) | 极低,但只反映瞬间 |
| 阻塞/互斥(block / mutex) | 等锁、等 channel、等 IO 的时间 | /debug/pprof/block、/mutex | 概率采样(runtime.SetBlockProfileRate) | 中,需显式开启 |
缺的那一类是 off-CPU(不在 CPU 上但也没睡够的等待):Go 的 CPU profile 只采「正在执行」的栈,线程阻塞在 syscall/锁/网络时,要么不采、要么以运行时函数(如 runtime.kevent)出现。「CPU 不高但很慢」的问题,靠 CPU 火焰图永远查不出来——要用 block/mutex profile、链路里的 span 耗时、或系统级工具(eBPF、perf)。
采样是有统计误差的,别把 1% 当精确值
100 Hz 采样、持续 T 秒得到 100T 个样本。一个真实占比 p 的函数,命中数服从二项分布,95% 置信区间可以算出来:
package main
import (
"fmt"
"math"
)
// 100Hz 采样、时长 d 秒 → n 个样本;函数真实占比 p
// 命中数 ~ Binomial(n, p),用正态近似给 95% 置信区间
func ci(n int, p float64) (exp, lo, hi float64) {
exp = float64(n) * p
sigma := math.Sqrt(float64(n) * p * (1 - p))
lo = math.Max(0, (exp-1.96*sigma)/float64(n)*100)
hi = math.Min(100, (exp+1.96*sigma)/float64(n)*100)
return exp, lo, hi
}
func main() {
fmt.Println("| 采样时长 | 样本数 | 函数真实占比 | 期望命中 | 95% 置信区间 | 相对误差 |")
fmt.Println("|---|---|---|---|---|---|")
for _, secs := range []int{10, 30, 300} {
n := secs * 100 // 100Hz
for _, p := range []float64{0.5, 0.1, 0.01, 0.001} {
exp, lo, hi := ci(n, p)
fmt.Printf("| %ds | %d | %.1f%% | %.0f | %.2f%%–%.2f%% | ±%.0f%% |\n",
secs, n, p*100, exp, lo, hi, (hi-lo)/2/(p*100)*100)
}
}
}
输出(本机实测):
| 采样时长 | 样本数 | 函数真实占比 | 期望命中 | 95% 置信区间 | 相对误差 |
|---|---|---|---|---|---|
| 10s | 1000 | 50.0% | 500 | 46.90%–53.10% | ±6% |
| 10s | 1000 | 10.0% | 100 | 8.14%–11.86% | ±19% |
| 10s | 1000 | 1.0% | 10 | 0.38%–1.62% | ±62% |
| 10s | 1000 | 0.1% | 1 | 0.00%–0.30% | ±148% |
| 30s | 3000 | 50.0% | 1500 | 48.21%–51.79% | ±4% |
| 30s | 3000 | 10.0% | 300 | 8.93%–11.07% | ±11% |
| 30s | 3000 | 1.0% | 30 | 0.64%–1.36% | ±36% |
| 30s | 3000 | 0.1% | 3 | 0.00%–0.21% | ±107% |
| 300s | 30000 | 50.0% | 15000 | 49.43%–50.57% | ±1% |
| 300s | 30000 | 10.0% | 3000 | 9.66%–10.34% | ±3% |
| 300s | 30000 | 1.0% | 300 | 0.89%–1.11% | ±11% |
| 300s | 30000 | 0.1% | 30 | 0.06%–0.14% | ±36% |
三个必须记住的推论:
- 30 秒采样只够看「占 10% 以上」的函数(±11%);要看 1% 的函数需要 5 分钟(±11%),要看 0.1% 的函数基本没戏。
- 两个函数差 2 个百分点,在 30 秒采样里是噪声:别拿「A 占 12%、B 占 10%」下结论说 A 是瓶颈,除非延长采样或多次采集取平均。
- 只出现 1–3 个样本的函数不是热点:那是噪声,优化它等于没优化(但它可能是偶发但致命的慢路径——那要靠链路和日志去抓,不是靠 CPU 火焰图)。
火焰图怎么读
| 看什么 | 怎么读 | 常见误读 |
|---|---|---|
| 横向宽度 | 该函数(含其子调用)占的 CPU 比例 | 把宽度当「耗时」——它是采样点数,不是墙钟时间 |
| 纵向深度 | 调用栈深度,不是「越深越慢」 | 认为深栈就是坏代码 |
| 顶层叶子 | 真正在执行的函数(自顶向下看最上层) | 只看最宽的框,其实它下面全是子调用 |
| 倒置火焰图 | 根在顶部、叶子在下,适合看「谁被谁调用」 | 与普通火焰图混用导致因果读反 |
| 差分火焰图 | 两次采样相减(发布前 vs 发布后),红色变宽 = 新增开销 | 用绝对值比较两个不同负载的 profile |
⚠️ 一个本机实测的坑:代码里加了
time.Sleep或大量分配后,CPU profile 的头号热点可能变成runtime.kevent、runtime.madvise、runtime.memclrNoHeapPointers。它们不是「业务热点」,而是系统调用与内存管理的代价——看到 runtime.* 占大头,先问「是不是在频繁分配/频繁等待」,而不是去优化 runtime。
两行开启,三条命令用起来
// 方式一:暴露 HTTP 端点(仅内网/本地,不要暴露到公网)
import _ "net/http/pprof"
go func() { _ = http.ListenAndServe("127.0.0.1:6060", nil) }()
# 采 30 秒 CPU:页面在 /debug/pprof/
go tool pprof -top -seconds=30 http://127.0.0.1:6060/debug/pprof/profile
# 看内存:inuse_space 是当前占用,alloc_space 是累计分配
go tool pprof -http=:8081 http://127.0.0.1:6060/debug/pprof/heap
# 差分:发布前采一份,发布后再采一份,直接看新增开销
go tool pprof -top -diff_base=before.pprof after.pprof
-http=:8081 会打开一个带火焰图的 Web UI(Flame Graph 视图),比 -top 适合看调用关系;-top 适合快速确认「前三名是谁」。
生产开启清单
- 只在内网或带鉴权的管理端口暴露
/debug/pprof,绝不挂公网 - pprof 的采集细节(五种 profile、采样率、命令)见 15.5 pprof (CPU / 内存 / 阻塞 / 互斥)
- CPU profile 采样频率 100Hz 时开销约 1%–5%;持续剖析用 1%–5% 的时间片,不要 100%
block/mutex剖析要显式runtime.SetBlockProfileRate/SetMutexProfileFraction,长期开启有额外开销- profile 按天保留并记录版本(commit),否则火焰图对不上代码
- 留一个关掉开关:环境变量或管理端口,出问题能立刻停采
# 采 30 秒 CPU,验证端点可达、符号能解析
go tool pprof -top -seconds=30 http://127.0.0.1:6060/debug/pprof/profile
# 对比两个版本:diff 火焰图才看得出“变慢在哪”
go tool pprof -top -diff_base=before.pprof after.pprof
验证“剖析本身没把服务拖慢”:开启前后各看一次 P99 与 CPU,差值应落在上面写的开销区间内;超了就先降采样率,而不是继续加机器。
持续剖析:把「事后采一次」变成「一直在采」
每个 Pod:agent(或 SDK 内置)每 10 分钟采 10 秒 CPU / 每小时采一次 heap
│ 附带标签:service、version、pod、region
▼
汇聚端(Collector / 厂商后端):去重、符号化、按标签存成「profile 时间序列」
▼
查询:按 service+version 看火焰图;两次发布之间做 diff;按时间回看「上周三 14:00 的 CPU 分布」
四个设计要点:
| 要点 | 做法 | 为什么 |
|---|---|---|
| 采样频率 | CPU 每 10 分钟采 10 秒(约 1.7% 的时间覆盖) | 覆盖够定位回归,又不至于长期占用 1%–5% 的 CPU |
| 标签 | 必须带 service + version + pod | 没有 version 就无法做「发布前后 diff」,等于白采 |
| 符号化 | 在汇聚端做,或用构建产物里的符号表 | 生产二进制通常 strip 过,符号缺失会让火焰图只剩地址 |
| 保留 | 原始 profile 留 7–14 天,聚合后的火焰图留更久 | 存的是「分布」,聚合后体积极小 |
存储量级(按本机实测:2 秒 CPU profile = 187 个样本,gzip 后 2.2 KB,约 12 B/样本):
单次 10 秒采样 ≈ 1000 样本 ≈ 12 KB
100 个 Pod × 每天 144 次 ≈ 170 MB/天 ≈ 5 GB/月
持续剖析的瓶颈从来不是存储,而是采样开销与符号化链路——这也是为什么它比日志/链路更容易「开了就忘」:账单不涨,但 CPU 一直在被偷。
与指标、链路的分工
| 你要回答 | 用什么 | 说明 |
|---|---|---|
| 延迟涨了没有、涨了多少 | 指标(P99 分位) | 见 50.4 度量 |
| 慢在哪一跳 | 链路(span 树 + self time) | 见 50.3 链路 |
| 那一跳的 CPU 花在哪个函数 | CPU 火焰图 | 本节 |
| 那一跳为什么「不占 CPU 却慢」 | block/mutex profile、off-CPU、链路 span | CPU 火焰图看不见等待 |
| 这次发布引入了哪个热点 | 差分火焰图 | 这是持续剖析最大的价值:回归定位从「猜」变成「看」 |
链路与剖析的关联方式:靠时间窗 + 标签(同一 service/version/pod,同一分钟),而不是 trace_id——主流 profile 格式里没有 trace 维度。这决定了它俩是「先链路定位、再按标签找同期 profile」的两步流程,不要期待点一下 span 就跳到火焰图(除非你的平台自建了关联)。
反模式
| 反模式 | 后果 |
|---|---|
| 生产环境常开 block/mutex 高采样率 | 锁竞争被放大,profile 本身成为瓶颈 |
| 用 CPU 火焰图分析「等待型」延迟 | 火焰图里一片空白,误判为「没有问题」 |
| 30 秒采样就下结论「A 是 B 的 1.2 倍」 | 噪声当信号,优化错对象 |
profile 不带 version 标签 | 无法 diff,持续剖析退化成「偶尔看一眼」 |
把 /debug/pprof 暴露到公网 | 泄漏调用栈、函数名、内存内容(部分端点可 dump) |
| 只看最宽的框,不看调用链 | 优化了父函数(其实是子调用慢),改错地方 |
用 alloc_space 判断内存占用 | 累计分配 ≠ 当前占用,要用 inuse_space |
自检
Q1 CPU 使用率只有 20%,但 P99 是 2 秒。用 30 秒 CPU profile 能查到原因吗?
▸ 答案:大概率不能。CPU profile 只采「正在执行」的栈,80% 的时间进程在等待(IO、锁、下游 RPC、GC 停顿之外的调度),这些不会出现在 CPU 火焰图里。正确路径:先用链路看慢在哪个 span,再用 block/mutex profile 或 off-CPU 工具(perf、eBPF)看等待;同时检查连接池、队列、下游超时。
Q2 30 秒的 CPU profile 显示函数 A 占 12%、B 占 10%,能不能说 A 是瓶颈?
▸ 答案:不能。3000 个样本下,10% 的 95% 置信区间是 8.9%–11.1%(±11%),12% 与 10% 的差异在统计噪声内。要区分需要:延长采样(5 分钟把误差压到 ±3%)、多次采集取均值、或者直接看差分(发布前后对比,同一函数的变化更可靠)。
Q3 为什么持续剖析必须给 profile 打上 version 标签?
▸ 答案:因为持续剖析的核心用法是差分——「这次发布让哪个函数变热了」。没有版本标签,你只能看到「当前的热点」,无法回答「是不是这次发布引入的」,也就无法在 10 分钟内定位回归。有了 version,diff(version N-1, version N) 就能直接给出新增/消失的火焰,这是它相对「事后采一次」的唯一结构性优势。
Q4 一次 10 秒的 CPU profile 大概多大?100 个 Pod 持续采一年要多少存储?
▸ 答案:按本机实测约 12 B/样本 × 1000 样本 ≈ 12 KB/次(压缩后)。100 Pod × 144 次/天 ≈ 170 MB/天 ≈ 5 GB/月 ≈ 60 GB/年——对存储来说可以忽略。真正的成本是:每个 Pod 长期占用 1%–5% 的 CPU、符号化计算、以及采集/传输的工程复杂度。
Q5 火焰图里最宽的框一定是要优化的地方吗?
▸ 答案:不一定。宽度 = 该函数及其子调用的采样占比,所以最宽的框往往只是「入口」,真正的开销在它下面的子调用里。正确读法是自顶向下找「第一个明显变宽的叶子」(或看倒置火焰图从叶子往上找),并且优先看差分——宽度变化比绝对宽度更能说明问题。