Go:性能分析入门,pprof、benchstat、基准测试到底怎么用

Go 写到一定阶段后,最容易出现一种很熟悉的场景:

  • 功能是对的
  • 测试也过了
  • 线上吞吐却上不去
  • CPU 飙高,延迟抖动
  • 一改代码又说不清到底变快了还是变慢了

如果这时直接开始“凭感觉优化”,代码通常会很快走向另一种混乱:

  • 先改一批循环
  • 再改一批对象创建
  • 顺手把接口和结构也一起动了
  • 最后提交里看起来做了很多事
  • 但没人能证明到底哪一步真的带来了收益

Go 的性能分析更适合走一条明确链路:

  1. 先用基准测试稳定复现
  2. 再用 pprof 找热点
  3. 再用 benchstat 比较前后差异

这一篇就围绕一个实际小场景来讲:做一个日志聚合器的性能分析和优化闭环

这个聚合器要做几件事:

  1. 读取一批日志行
  2. 提取服务名和状态码
  3. 按服务聚合计数
  4. 输出慢请求和错误统计

这个场景不大,但足够把基准测试、CPU 分析、内存分配和结果对比串成一条完整链。

一、这篇文章要解决什么问题

读完这一篇,应该能独立回答这些问题:

  1. Go 里的基准测试和普通单元测试有什么区别
  2. pprof 到底在看什么,最先该看哪几张图
  3. benchstat 解决的是什么问题
  4. 什么情况下性能优化是有效的,什么情况下只是“看起来像优化”
  5. 怎么把性能分析做成一条可重复的工程链路
  6. 什么时候不该过早优化

如果这些问题能答清,后面再做服务端热点分析、接口性能排查和压测后优化,路径会稳很多。

二、先看这篇文章里的实际场景

假设现在有一个最小日志聚合器,输入类似下面这样的日志:

1
2
3
4
order-api,200,18
order-api,500,24
user-api,200,9
billing-api,503,61

要输出这些结果:

  • 每个服务总请求数
  • 每个服务错误数
  • 超过阈值的慢请求数

第一版实现很直接:

  • strings.Split
  • strconv.Atoi
  • map[string]Stat
  • 一行一行处理

功能没问题,但数据量一大就出现两个现象:

  1. CPU 时间明显上升
  2. 分配次数偏多,GC 压力也上来了

这个时候最容易犯的错不是“不会优化”,而是还没定位就开始乱改。

三、先给一个最小可运行版本

aggregator.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package aggregator

import (
"strconv"
"strings"
)

type Stat struct {
Total int
Error int
Slow int
}

func Aggregate(lines []string, slowThreshold int) map[string]Stat {
result := make(map[string]Stat)

for _, line := range lines {
parts := strings.Split(line, ",")
if len(parts) != 3 {
continue
}

service := parts[0]
code, err := strconv.Atoi(parts[1])
if err != nil {
continue
}
cost, err := strconv.Atoi(parts[2])
if err != nil {
continue
}

stat := result[service]
stat.Total++
if code >= 500 {
stat.Error++
}
if cost >= slowThreshold {
stat.Slow++
}
result[service] = stat
}

return result
}

这段代码先别急着优化。
当前更重要的是:先把它变成一个可测、可 benchmark、可 profile 的对象。

四、性能分析里最容易走偏的地方

性能问题最常见的几种坏开局是:

  1. 先改代码,再想怎么证明收益
  2. 线上慢一次,就开始凭经验大改
  3. 看一眼 CPU 高,就默认是算法问题
  4. 跑一次 benchmark,就把偶然结果当结论

更稳的顺序通常是:

  1. 先固定输入
  2. 先写 benchmark
  3. 先拿到基线数据
  4. 再用 pprof 看热点
  5. 改完以后再用 benchstat 对比

五、基准测试到底在解决什么问题

基准测试解决的是:把某段逻辑的性能表现稳定地量出来。

和普通测试不同,基准测试不是回答“对不对”,而是回答:

  • 慢在哪
  • 分配多不多
  • 改完后是否真的更好

最小 benchmark 写法:

1
2
3
4
5
6
7
8
9
10
11
12
func BenchmarkAggregate(b *testing.B) {
lines := []string{
"order-api,200,18",
"order-api,500,24",
"user-api,200,9",
"billing-api,503,61",
}

for i := 0; i < b.N; i++ {
_ = Aggregate(lines, 20)
}
}

执行:

1
go test -bench=.

六、为什么 benchmark 里不能边测边造场景

如果在 benchmark 循环体里一边测一边准备数据,例如:

1
2
3
4
for i := 0; i < b.N; i++ {
lines := buildLargeInput()
_ = Aggregate(lines, 20)
}

那最后测到的就不只是聚合逻辑,而是:

  • 输入构造
  • 聚合逻辑
  • 临时分配

这会把信号搅浑。

更稳的方式通常是:

  • 循环外准备输入
  • 循环内只跑目标逻辑

七、先补一组更像样的基准测试

aggregator_test.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package aggregator

import (
"fmt"
"testing"
)

func buildInput(size int) []string {
lines := make([]string, 0, size)
for i := 0; i < size; i++ {
service := "order-api"
if i%3 == 1 {
service = "user-api"
}
if i%3 == 2 {
service = "billing-api"
}

code := 200
if i%10 == 0 {
code = 500
}

cost := 12 + i%80
lines = append(lines, fmt.Sprintf("%s,%d,%d", service, code, cost))
}
return lines
}

func BenchmarkAggregate(b *testing.B) {
lines := buildInput(10000)
b.ReportAllocs()
b.ResetTimer()

for i := 0; i < b.N; i++ {
_ = Aggregate(lines, 40)
}
}

这里先建立了三件事:

  1. 输入规模固定
  2. 循环内只跑目标逻辑
  3. 把分配信息也打出来

八、b.ReportAllocs() 为什么重要

只看耗时很容易漏掉另一个关键问题:分配。

例如同样是 1ms:

  • 一个版本 alloc 很少
  • 另一个版本 alloc 很多

在长时间运行的服务里,后者往往会带来:

  • 更多 GC 压力
  • 更明显的尾延迟抖动

所以 Go 性能分析里,至少先同时看:

  • ns/op
  • B/op
  • allocs/op

九、pprof 到底在解决什么问题

如果基准测试解决的是“量出来”,那 pprof 解决的是:

到底哪一段代码在吃时间或吃内存。

对这篇文章的场景,最先常用的是:

  • CPU profile
  • memory / alloc profile

执行方式通常是:

1
go test -bench=BenchmarkAggregate -cpuprofile cpu.out -memprofile mem.out

然后再看:

1
2
go tool pprof cpu.out
go tool pprof mem.out

十、第一次进 pprof,先看什么

很多性能分析第一次卡住,不是因为工具难,而是因为入口太多。

更稳的顺序通常是:

  1. 先看 top
  2. 再看 list 函数名
  3. 最后再决定要不要看 web

例如:

1
(pprof) top

这个命令先回答:

  • 哪些函数最耗 CPU
  • 哪些函数分配最多

如果 Aggregate 本身不在前排,说明热点可能在它调用的更底层函数里。

十一、toplist 分别在看什么

top 看的是函数级热点。
例如:

1
2
3
4
5
Showing nodes accounting for 1.80s, 90% of 2.00s total
flat flat% sum% cum cum%
0.70s 35.0% 35.0% 0.90s 45.0% strings.Split
0.50s 25.0% 60.0% 0.55s 27.5% strconv.Atoi
0.30s 15.0% 75.0% 1.90s 95.0% aggregator.Aggregate

list Aggregate 看的是源码级热点:

1
(pprof) list Aggregate

它更适合回答:

  • 是哪一行在烧时间
  • 哪一行分配最多

十二、这个场景里最可能先看到什么热点

对当前这个日志聚合器,第一次常见热点往往是:

  1. strings.Split
  2. strconv.Atoi
  3. fmt.Sprintf 如果 benchmark 输入构造也进了测量

如果 CPU top 里 strings.Split 很高,通常说明:

  • 当前字符串切分的分配成本偏重

如果 alloc top 里也有它,就更值得先看这里。

十三、先做一个小优化,再比较前后

例如把:

1
parts := strings.Split(line, ",")

改成:

1
parts := strings.SplitN(line, ",", 3)

或者进一步改成基于索引的手写解析。

这里先不要纠结哪种一定更快。
更关键的是:改完以后要证明。

十四、为什么不能只看一次 benchmark 数字

如果只跑一次:

1
go test -bench=BenchmarkAggregate

你拿到的只是一次样本。
这时最容易出现两种误判:

  1. 抖动被当成优化收益
  2. 偶然变慢被误判成回归

更稳的方式通常是重复采样。

例如:

1
go test -bench=BenchmarkAggregate -count=10 > old.txt

改完以后:

1
go test -bench=BenchmarkAggregate -count=10 > new.txt

十五、benchstat 到底在解决什么问题

benchstat 解决的是:把多轮 benchmark 结果做统计对比。

也就是说,它不是替代 benchmark,而是替代“肉眼比数字”。

例如:

1
benchstat old.txt new.txt

输出可能像这样:

1
2
3
4
5
6
7
8
name              old time/op    new time/op    delta
Aggregate-8 1.20ms ± 3% 0.95ms ± 2% -20.8%

name old alloc/op new alloc/op delta
Aggregate-8 320kB ± 0% 240kB ± 0% -25.0%

name old allocs/op new allocs/op delta
Aggregate-8 10000 ± 0% 7000 ± 0% -30.0%

这时你才能比较有底气地说:

  • 优化是真的
  • 不是偶然抖动

十六、一个更完整的小项目版本

现在把日志聚合器改成更像工程代码的结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package aggregator

import (
"strconv"
"strings"
)

type Stat struct {
Total int
Error int
Slow int
}

func Aggregate(lines []string, slowThreshold int) map[string]Stat {
result := make(map[string]Stat, 8)

for _, line := range lines {
service, code, cost, ok := parseLine(line)
if !ok {
continue
}

stat := result[service]
stat.Total++
if code >= 500 {
stat.Error++
}
if cost >= slowThreshold {
stat.Slow++
}
result[service] = stat
}

return result
}

func parseLine(line string) (string, int, int, bool) {
first := strings.IndexByte(line, ',')
if first == -1 {
return "", 0, 0, false
}
second := strings.IndexByte(line[first+1:], ',')
if second == -1 {
return "", 0, 0, false
}
second += first + 1

service := line[:first]
code, err := strconv.Atoi(line[first+1 : second])
if err != nil {
return "", 0, 0, false
}
cost, err := strconv.Atoi(line[second+1:])
if err != nil {
return "", 0, 0, false
}
return service, code, cost, true
}

这个版本的目标不是“写得更炫”,而是:

  • 先让热点明确
  • 再看分配是否下降

十七、怎么把 pprof 和 benchmark 串成一条分析链

更完整的一轮动作通常是:

  1. 先写 benchmark
  2. go test -bench ... -count=10 > old.txt
  3. go test -bench ... -cpuprofile cpu.out -memprofile mem.out
  4. go tool pprof cpu.out
  5. 改代码
  6. 再跑 -count=10 > new.txt
  7. benchstat old.txt new.txt

这样做的好处是:

  • benchmark 证明有没有变化
  • pprof 解释变化来自哪里
  • benchstat 证明变化是不是稳定

十八、一个高频误区:只看 CPU,不看分配

例如某次优化后:

  • ns/op 下降不明显
  • allocs/op 大幅下降

这不一定代表优化没价值。
在长时间运行的服务里,分配下降带来的收益可能体现在:

  • GC 次数下降
  • 尾延迟更稳
  • 高并发时抖动更小

所以不要把“性能”窄化成只看 CPU 时间。

十九、一个高频误区:只看 benchmark,不看真实热点

另一种常见偏差是:

  • benchmark 改了很多
  • 真实服务热点根本不在这里

这说明当前优化对象选错了。
所以更稳的路径不是“只做 benchmark”,而是:

  • benchmark 用来复现和量化
  • pprof 用来确认热点是否真在这里

二十、一个更接近项目现场的完整案例

假设现在有一个巡检平台,每分钟汇总一次接口日志:

  • 读取 5 万行日志
  • 聚合每个服务的错误和慢请求
  • 生成一份摘要

现象是:

  • 平台 CPU 周期性抬高
  • 汇总任务偶发超过调度窗口

第一轮 benchmark 显示:

  • Aggregate 大约 4ms
  • allocs 偏多

CPU profile 里最显眼的是:

  • strings.Split
  • strconv.Atoi

于是先做两步小改动:

  1. 把整行 Split 改成基于分隔符索引的解析
  2. 给 result map 预估容量

改完以后:

  • benchstat 显示 time/op 下降
  • alloc/op 和 allocs/op 也一起下降

这时才值得继续往下做更深的优化。
如果 benchstat 没有稳定收益,就不该继续往这条路上堆复杂度。

二十一、怎么给性能分析流程补最小验证

性能分析不是只有 benchmark。
至少还可以补两类验证:

  1. 正确性测试
    避免为了性能把逻辑改坏。

  2. 小规模基线比较
    避免“优化”只是偶然波动。

例如普通测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func TestAggregate(t *testing.T) {
lines := []string{
"order-api,200,18",
"order-api,500,24",
"order-api,503,66",
}

got := Aggregate(lines, 20)
stat := got["order-api"]

if stat.Total != 3 {
t.Fatalf("want total=3, got %d", stat.Total)
}
if stat.Error != 2 {
t.Fatalf("want error=2, got %d", stat.Error)
}
if stat.Slow != 2 {
t.Fatalf("want slow=2, got %d", stat.Slow)
}
}

二十二、一个高频排错场景:为什么 benchmark 优化了,线上还是没变

这种情况通常先查三件事:

  1. benchmark 是否覆盖了真实热点
  2. 线上热点是否其实在 I/O、锁等待或网络
  3. 当前优化收益是否被更大的瓶颈吃掉了

例如:

  • benchmark 只测了字符串聚合
  • 线上真正最慢的是磁盘读取或远程存储

那当前优化当然不会明显改善端到端耗时。

二十三、什么时候不该继续优化

这也是性能分析里很重要的边界。

如果当前情况是:

  • benchmark 收益很小
  • pprof 热点不明显
  • 代码复杂度会显著上升
  • 当前瓶颈不在 CPU 和 alloc

那更合适的做法通常是停下来,先转去看:

  • I/O
  • 网络
  • 调度窗口
  • 缓存策略

性能优化不是默认要一直做下去,而是要看当前收益值不值得。

二十四、一个实际练习

可以直接把这一篇改成一个完整练习。

练习目标:做一个最小日志聚合器性能分析闭环。

要求:

  1. 先写一个能通过正确性测试的版本
  2. 补一个 BenchmarkAggregate
  3. b.ReportAllocs() 观察分配
  4. 生成 cpu.outmem.out
  5. 做一次小优化
  6. benchstat 比较优化前后结果

如果这个练习能独立做完,说明性能分析已经不只是“会跑工具”,而是开始具备闭环意识了。

二十五、这篇文章的边界在哪里

这一篇重点讲的是最小性能分析链:

  • benchmark
  • pprof
  • benchstat

先不展开这些更大的话题:

  • 线上持续 profiling
  • trace
  • block / mutex profile
  • 更复杂的负载模型

这些更适合放到后面的服务性能分析或并发问题文章里继续展开。

二十六、这篇文章学完以后,下一步应该补什么

这一篇解决的是:

  • 怎么量性能
  • 怎么找热点
  • 怎么比前后差异

接下来最适合继续补的是:

  1. 数组、链表、栈、队列这些基础结构在 Go 里怎么实现和使用
  2. 双指针、滑动窗口、哈希、递归、回溯这些模式在工程里怎么落地
  3. 排序、查找、堆、图这些结构和算法怎么从会写题走向会落地

因为到这一步,已经能开始做“性能证据化”了。
后面更值得补的是:底层结构和算法选择为什么会直接影响性能表现。

二十七、结语

Go 性能分析最容易走偏的地方,不是工具不会用,而是没有把三步连起来:

  • benchmark 量出来
  • pprof 找出来
  • benchstat 比出来

只要这条链先立住,后面的优化就不再只是感觉。
它会变成一套能复现、能比较、能回滚、能说服别人的工程动作。