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% |

三个必须记住的推论:

  1. 30 秒采样只够看「占 10% 以上」的函数(±11%);要看 1% 的函数需要 5 分钟(±11%),要看 0.1% 的函数基本没戏。
  2. 两个函数差 2 个百分点,在 30 秒采样里是噪声:别拿「A 占 12%、B 占 10%」下结论说 A 是瓶颈,除非延长采样或多次采集取平均。
  3. 只出现 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 火焰图里最宽的框一定是要优化的地方吗?

▸ 答案:不一定。宽度 = 该函数及其子调用的采样占比,所以最宽的框往往只是「入口」,真正的开销在它下面的子调用里。正确读法是自顶向下找「第一个明显变宽的叶子」(或看倒置火焰图从叶子往上找),并且优先看差分——宽度变化比绝对宽度更能说明问题。

关联