一、先确定剖面要回答的问题
性能问题不是一个“慢”字。帧同步服卡顿、登录服内存上涨、战斗服 P99 抖动、世界服 goroutine 数量飙升,对应的是完全不同的剖面。打开 pprof 之前先写下一句可证伪的问题,例如“CPU 时间是否集中在一个函数”“堆内存是否主要来自临时字符串”“goroutine 是否在等锁”。问题不同,采样方式、采样时长和判读入口都不同。
| 剖面 | 回答的问题 | 默认采样 | 典型开销 | 建议时长 |
|---|---|---|---|---|
| CPU | 哪些函数在消耗 CPU 时间 | 约 100 Hz | 低到中等 | 10–30 秒 |
| heap | 哪些调用路径在分配或持有内存 | 每分配约 512 KiB 采一次 | 低 | 一次快照 |
| goroutine | 当前有多少 goroutine、卡在哪里 | 全量快照 | 短暂影响调度 | 一次快照 |
| block | goroutine 在哪些阻塞点等待 | 默认关闭,按事件采样 | 可调,可能较高 | 10–30 秒 |
| mutex | 哪些锁产生竞争 | 默认关闭,按比例采样 | 可调,可能较高 | 10–30 秒 |
CPU 和 heap 是排查性能问题的起点,goroutine、block、mutex 则用来解释“CPU 不高但请求很慢”。只采 CPU 不看阻塞,很容易把等待误判成计算;只采 heap 不看分配次数,容易把“存活对象多”和“分配速度快”混为一谈。
二、CPU 剖面采的是调用栈样本
CPU profile 不是精确的耗时账本。运行时大约每 10 毫秒中断一次,记录当前 goroutine 的调用栈;采样多久就有多少个样本。-cpuprofile 收集的是这些样本的聚合,因此百分比是统计估计,不是纳秒级计时。样本太少时,火焰图上的一小段噪声也可能被误读成热点。
对 ch13/profile 包采集 CPU 剖面:
cd .local/checks/gs-practice-lab
go test ./ch13/profile/ -run '^$' -bench 'BenchmarkHotPath$' \
-benchtime=3s -cpuprofile cpu.out
go tool pprof -top ch13/profile/cpu.out
go tool pprof -list 'HotPath' ch13/profile/cpu.out
go test 运行测试二进制时的工作目录是包目录,所以 cpu.out 默认落在 ch13/profile/ 下,后续命令要带这个前缀。-run '^$' 表示不跑普通测试,只跑基准,保证采样期间的负载稳定。-benchtime=3s 比默认 1 秒更容易拿到足够样本;生产环境则用固定时长和固定并发。
-top 适合看排序后的函数列表,-list 会把源码和逐行样本对齐。判读时先看 flat(函数自身耗时),再看 cum(含被调函数的总耗时)。一个函数 cum 很高但 flat 很低,说明时间花在它的下游;反过来,flat 高才是真正需要改的计算。
采样时长不是越长越好。十秒左右的窗口足够看出主要热点,超过一分钟的采集更容易把流量变化、定时任务和 GC 周期混进来。需要对比改动前后时,应保证输入规模、并发度和机器负载一致,再比较同一条调用链的占比,而不是比较两次采集的绝对样本数。
三、Heap 剖面要区分分配与存活
Heap profile 默认展示的是采样时刻仍然存活的对象,也就是 inuse_space;加 -alloc_space 才看累计分配量。排查“内存一直涨”应该看 inuse_space,排查“GC 频繁、CPU 被分配拖高”应该看 alloc_space 和 alloc_objects。两者混用会得出相反结论。
go test ./ch13/profile/ -run '^$' -bench 'BenchmarkEncodeSnapshot$' \
-benchtime=2s -memprofile mem.out
go tool pprof -top -alloc_space ch13/profile/mem.out
go tool pprof -list 'EncodeSnapshot' ch13/profile/mem.out
EncodeSnapshot 是故意写出的朴素编码:每位玩家都 make([]byte, 8),再把十进制等级转成字符串后追加。EncodeSnapshotInto 则复用调用方缓冲区,用 binary.LittleEndian.AppendUint64 和 strconv.AppendInt 直接追加。只看代码不容易判断差异,heap profile 会把分配来源精确到行。
采样率由 runtime.MemProfileRate 控制,默认约每分配 512 KiB 记录一次。它适合观察分配路径,不适合精确统计小对象的每一次分配。需要精确定量时,应使用 13.4 的 -benchmem,而不是把 heap profile 的采样值当成绝对分配次数。
如果怀疑内存增长来自某次版本变更,可以保留两个时间点的 profile,用 go tool pprof -base old.pb.gz new.pb.gz 做差集。差集视图只显示新增或减少的分配路径,比在两张完整报告之间来回翻找更可靠。前提是两次采集的负载和采样率一致,否则差集里会混入负载变化的噪声。
四、Goroutine、Block 与 Mutex
线上“CPU 不高但延迟高”时,先看 goroutine 快照。它能看到数量、状态和阻塞位置:大量 goroutine 停在 chan receive,说明下游消费不足;大量停在 sync.(*Mutex).Lock,说明锁竞争;大量停在网络读写,说明连接或对端有问题。
curl -s 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=1'
curl -s -o goroutine.pb.gz 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=0'
go tool pprof -top goroutine.pb.gz
Block profile 记录 goroutine 在阻塞原语上的等待,默认关闭。需要先调用 runtime.SetBlockProfileRate(1);值为 1 表示每次阻塞都采样,开销可能明显上升,生产环境通常用更大的间隔或只在短窗口开启。
curl -s -o block.pb.gz 'http://127.0.0.1:6060/debug/pprof/block?seconds=30'
go tool pprof -top block.pb.gz
Mutex profile 记录锁竞争,默认同样关闭。需要 runtime.SetMutexProfileFraction(1) 才采全量,否则按比例采样。它回答的是“谁在等锁”,不是“谁持有锁最久”,两者要结合业务临界区一起看。
curl -s -o mutex.pb.gz 'http://127.0.0.1:6060/debug/pprof/mutex?seconds=30'
go tool pprof -top mutex.pb.gz
这三类剖面的共同点是“先确认现象再采集”。如果监控已经显示 goroutine 数量随时间线性上涨,直接抓一份 goroutine 快照比采五分钟 CPU 更有价值。
五、火焰图的自顶向下读法
火焰图的横轴是样本占比,不是时间轴;每一层是一层调用栈,宽度表示该栈出现在多少样本里。读图时从根往下找“突然变宽”的帧,而不是从最左边开始逐格看。宽帧说明它或其子调用吃掉了大量样本。
| 现象 | 可能含义 | 下一步 |
|---|---|---|
| 某帧自身很宽,子帧很窄 | 函数内部计算或循环是热点 | 看 -list 的逐行样本 |
| 某帧宽但自身窄,子帧也宽 | 时间在调用链下游 | 继续向下追 |
| 大量样本停在运行时帧 | GC、调度、系统调用 | 结合堆分配和 goroutine 快照 |
| 同一函数在多条栈上出现 | 调用方多,可能是公共路径 | 用 -peek 看谁调用它 |
| 火焰图很平,没有明显宽帧 | 负载分散或样本不足 | 延长采样、固定负载 |
go tool pprof -top -cum 按累计耗时排序,适合先找调用链;-peek '函数名' 查看谁调用它、它又调用谁;-list '函数名' 把样本映射到源码行。分析内联函数时要注意,火焰图里的帧名可能带 (inline),源码行号也可能指向调用点而不是函数体。
还有一种常见误读:把宽帧直接当成“要优化的函数”。如果宽帧是 runtime.mallocgc,真正要改的是调用它的分配路径;如果宽帧是 sync.(*Mutex).Lock,真正要改的是临界区长度或锁粒度。火焰图只告诉你时间花在哪里,不告诉你为什么。
同一份 profile 可以换不同视角看。默认视图按 flat 排序,适合找自身热点;-cum 视图适合找调用链;-peek 适合确认调用关系;-list 适合落到源码行。四种视图交替使用,比盯着同一张图反复猜测更有效。
六、逃逸分析与分配来源
Go 编译器会把能证明生命周期不超过函数的值放在栈上,把可能逃逸的值放到堆上。逃逸分析命令能看到编译器的判断依据:
go build -gcflags='-m=2' ./ch13/profile/
go test -gcflags='-m=2' ./ch13/profile/ 2>&1 | rg 'escapes to heap|moved to heap'
-m 输出内联和逃逸决策,-m=2 更详细。输出里的 escapes to heap 表示该值可能被函数外引用,moved to heap 表示编译器决定把局部变量搬到堆上。看到某行反复出现逃逸,优先检查是否把局部切片、字符串或结构体指针返回、存入全局容器、传给接口。
NewPlayers 返回 []Player,切片底层数组必然逃逸;AllocateSnapshot 返回新字符串切片,分配也是预期行为。真正值得关注的是“本可以复用却每次新建”的路径。EncodeSnapshot 的逐玩家 make([]byte, 8) 就是典型例子:它既增加分配次数,又给 GC 增加扫描压力。
七、生产环境采样的安全边界
pprof 很强,但不能默认把它暴露在公网。端点会泄露函数名、调用栈、内存布局和部分参数,攻击者可以据此推断服务结构。生产环境至少要做到独立端口、内网绑定、访问控制、限时采集和可关闭开关。
| 措施 | 做法 | 目的 |
|---|---|---|
| 独立端口 | 只监听 127.0.0.1:6060 或内网地址 | 不与业务端口混用 |
| 访问控制 | 防火墙、跳板机、反向代理鉴权 | 阻止公网直接访问 |
| 限时 | ?seconds=30,客户端设置超时 | 避免长时间采样 |
| 限频 | 同一实例同一时间只允许一个采集任务 | 避免叠加开销 |
| 低采样率 | 调整 block/mutex 采样率,heap 用默认值 | 控制运行时扰动 |
| 可关闭 | 环境变量或构建标签控制注册 | 不需要时彻底移除 |
采集窗口也要选。高峰期采集能暴露真实热点,但采样开销会叠加在已经紧张的容量上;低峰期安全,却可能采不到有代表性的负载。折中做法是:先在低峰期验证命令和端点,再在受控的短窗口内采集,同时记录 QPS、P99、GC 次数,采集结束后对比前后指标,确认扰动可接受。
CPU profile 的开销通常不高,但 heap profile 会改变分配采样行为,block/mutex 在低采样率下也可能放大竞争路径的观测成本。任何一次采集都要有明确的起止时间和回滚手段,不能“开着忘了关”。
如果使用 net/http/pprof 自带的注册方式,它会挂到默认 mux 上,容易和业务路由混在一起。更稳妥的做法是新建独立 mux,只注册需要的 handler,再把这个 mux 绑到内网地址,并给 http.Server 设置读超时、写超时和空闲超时。采集端点本身也要有并发上限,避免多个采集请求同时把服务拖慢。
7.1 一次失败场景:只采 CPU,结论完全跑偏
某战斗服在晚高峰出现延迟上升,CPU 使用率只有 40%。值班同学在公网 6060 端口开了五分钟 CPU profile,火焰图最宽的是 EncodeSnapshot,于是团队花两天把编码改成复用缓冲区。上线后延迟没有下降,GC 次数反而因为另一处改动略有上升。
复盘时补采 goroutine 和 mutex 才发现,大量战斗协程停在 sync.(*Mutex).Lock,临界区里包含一次同步日志写入;CPU 不高是因为大家在等锁,不是因为计算少。更糟的是,公网 pprof 端点在整个排查期间一直开放,虽然没出事,但已经构成信息泄露风险。
这个案例有三个错误:第一,用 CPU 剖面回答“为什么等待”;第二,在高峰期做长时间全量采集,却没有记录采集前后的业务指标;第三,把 pprof 端点暴露在公网。正确顺序应该是先看 goroutine 和延迟分布,确认是等待还是计算,再选择对应剖面,并且只在受控窗口内采集。
八、Go 实现:可采集的热点与分配函数
ch13/profile 提供两类样本:HotPath 只做整数运算,适合观察 CPU 热点;AllocateSnapshot 和 EncodeSnapshot 构造字符串与字节切片,适合观察分配路径。EncodeSnapshotInto 是复用缓冲区的对照实现,测试会保证两者输出逐字节一致。
// file: ch13/profile/profile.go
// tier: L1
// verify: go test ./ch13/profile/
package profile
import (
"encoding/binary"
"strconv"
"strings"
)
// Player 是剖面实验使用的最小玩家记录。
type Player struct {
ID uint64
Name string
Level int
Score int64
}
// NewPlayers 生成确定性输入,避免每次采集时输入不同导致热点漂移。
func NewPlayers(n int) []Player {
out := make([]Player, n)
for i := range out {
out[i] = Player{
ID: uint64(i + 1),
Name: "player-" + strconv.Itoa(i+1),
Level: i%60 + 1,
Score: int64(i*i%1000) + 10,
}
}
return out
}
// HotPath 模拟战斗结算:多轮遍历同一批玩家,包含取模、乘法和分支。
// 它只做 CPU 计算,不分配内存,适合作为 CPU profile 的热点样本。
func HotPath(players []Player, rounds int) int64 {
var total int64
for round := 0; round < rounds; round++ {
for i := range players {
p := &players[i]
damage := (p.Score*31 + int64(round)*17 + int64(p.Level)*7) % 997
if damage < 0 {
damage = -damage
}
if damage%13 == 0 {
total += damage * 2
continue
}
total += damage
}
}
return total
}
// AllocateSnapshot 每次调用都构造新的字符串切片,便于在 heap profile 中
// 观察 strings.ToUpper 与字符串拼接产生的分配。
func AllocateSnapshot(players []Player) []string {
out := make([]string, 0, len(players))
for _, p := range players {
out = append(out, strings.ToUpper(p.Name)+"#"+strconv.FormatUint(p.ID, 10))
}
return out
}
// EncodeSnapshot 是朴素编码:每个玩家都单独分配临时字节切片。
func EncodeSnapshot(players []Player) []byte {
var out []byte
for _, p := range players {
out = append(out, p.Name...)
out = append(out, 0)
id := make([]byte, 8)
binary.LittleEndian.PutUint64(id, p.ID)
out = append(out, id...)
out = append(out, strconv.Itoa(p.Level)...)
out = append(out, ';')
}
return out
}
// EncodeSnapshotInto 复用调用方缓冲区,字段直接追加,避免逐玩家分配。
func EncodeSnapshotInto(dst []byte, players []Player) []byte {
for _, p := range players {
dst = append(dst, p.Name...)
dst = append(dst, 0)
dst = binary.LittleEndian.AppendUint64(dst, p.ID)
dst = strconv.AppendInt(dst, int64(p.Level), 10)
dst = append(dst, ';')
}
return dst
}
HotPath 不分配内存,因此 CPU 剖面里的样本会集中在循环本身;EncodeSnapshot 的分配则会在 heap profile 中定位到 make([]byte, 8) 和字符串转换。两个函数都不是“优化后的答案”,而是“可稳定复现的观察对象”,这样才能把采样方法和业务代码分开讲。
九、测试覆盖
测试文件 ch13/profile/profile_test.go 覆盖正确性与可重复性:
| 测试 | 断言 |
|---|---|
| TestHotPathDeterministic | 同一输入多次调用结果一致,轮次为 0 时返回 0 |
| TestNewPlayersIsStable | 输入生成器的 ID、名字、等级稳定 |
| TestEncodeSnapshotMatchesInto | 朴素编码与复用缓冲区编码逐字节一致 |
| TestEncodeSnapshotLayout | 空分隔符、小端 ID、等级十进制布局正确 |
| TestAllocateSnapshotUppercasesNames | 字符串转换结果符合预期 |
测试里还放了 BenchmarkHotPath 和 BenchmarkEncodeSnapshot,只用于给 pprof 提供稳定负载,不把基准数字写进本节。剖面文章不应该用一次采集的百分比替代基准测试,两者回答的问题不同。
正确性测试之外,还可以用 -race 跑一遍并发路径。剖面分析经常伴随“把锁改小”“把分配挪走”的改动,这些改动如果引入数据竞争,性能数字再好看也没有意义。本项目包没有共享可变状态,测试本身也不会触发并发写入;但在真实服务里,采集期间和优化之后的 -race 回归应该成为固定步骤。
十、运行验证
cd .local/checks/gs-practice-lab
gofmt -l ch13/profile && go vet ./ch13/profile/ && go test ./ch13/profile/ -count=1
PASS
ok gspractice/ch13/profile 0.234s
如果本机输出时间略有差异,以实际命令为准;关键是 gofmt -l 没有输出、go vet 没有告警、测试全部通过。采集命令还要额外确认 profile 文件确实生成在包目录下,否则 go tool pprof 会报找不到文件。
如果希望把 profile 固定输出到指定目录,可以使用 -outputdir。但要注意,-outputdir 只影响测试二进制产生的文件,不会改变 go tool pprof 解析符号时需要的二进制路径;采集完成后应把 profile 和对应的测试二进制一起保留,避免事后无法复现调用栈。
小结
CPU profile 用采样回答“时间花在哪些调用栈”,heap profile 用采样回答“谁在分配或持有内存”,goroutine、block、mutex 则解释等待。判读火焰图要从宽帧向下追,区分自身耗时与调用耗时;逃逸分析帮助定位本可避免的堆分配。生产环境采集必须限时、限频、低采样率、独立端口,并把采集前后的业务指标一起记录,否则很容易把采样开销或等待误判成业务耗时。
常见错误与边界
- 只采 CPU 不看 heap,忽略 GC 和分配对延迟的影响。
- 只采 heap 不看
inuse_space与alloc_space的区别,结论方向相反。 - 在高峰期做长时间全量采集,采样开销叠加到已经紧张的容量上。
- 把 pprof 端点暴露在公网,泄露调用栈和内存布局。
- 样本太少就下结论,把火焰图噪声当成热点。
- 把
runtime.mallocgc当成根因,不去看调用它的分配路径。 - 在 benchmark 里包含初始化,却把初始化耗时算进被测逻辑。
- 把 block/mutex 采样率设为 1 后长期开启,观测成本变成生产负担。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。