目录

Go 基准测试 Benchmark 详解:精准分析性能瓶颈

在 Go 开发中,功能正常只是基础,性能达标才是关键。

尤其是高并发场景下,一个看似简单的函数若存在性能隐患,可能会拖垮整个服务。

而 Benchmark(基准测试)就是定位性能瓶颈的核心工具,它能精准统计函数的执行效率。

做性能优化,如果没搞懂 Benchmark 的细节,测出来的结果忽高忽低,会走不少弯路。

今天就结合工作实战经验,把 Benchmark 讲透,让你少踩坑。

一、为什么要进行基准测试?

Benchmark 是 Go 标准库 testing 包提供的性能测试能力,核心作用是通过反复执行目标函数,统计其平均执行时间、每秒执行次数、内存分配等关键指标。

和普通单元测试(Test 函数)不同,它关注的不是“功能对不对”,而是“性能好不好”。

实际开发中,Benchmark 的使用场景非常多:

  • 优化函数后,验证性能是否真的提升(比如把冒泡排序改成快速排序后,量化提升幅度);

  • 对比不同实现方案的优劣(比如用切片和数组存数据,哪个读写更快);

  • 提前发现性能隐患(比如某个函数每次执行都分配大量内存,高并发下会触发频繁 GC)。

使用 Benchmark 的核心规范(新手必记,错了测试不执行):

  • 测试文件必须以 “_test.go” 结尾(和普通单元测试一致);

  • 测试函数必须以 “Benchmark” 开头,比如 BenchmarkSort;

  • 函数参数固定为 “b *testing.B”,不能多也不能少;

  • 执行时需用 “go test -bench=” 命令,默认不执行基准测试。

二、核心用法

(一)写一个简单测试

先以“对比两种字符串拼接方式的性能”为例,写一个基础的 Benchmark 测试。

目标函数是两种拼接方式:用 “+” 拼接和用 strings.Builder 拼接。

首先创建业务文件 “string_join.go”,实现两种拼接逻辑:

// string_join.go
package stringutil

import "strings"

// JoinWithPlus 用 "+" 拼接字符串
func JoinWithPlus(a, b, c string) string {
    return a + b + c
}

// JoinWithBuilder 用 strings.Builder 拼接字符串
func JoinWithBuilder(a, b, c string) string {
    var builder strings.Builder
    builder.WriteString(a)
    builder.WriteString(b)
    builder.WriteString(c)
    return builder.String()
}

然后创建测试文件 “string_join_test.go”,编写 Benchmark 测试函数:

// string_join_test.go
package stringutil

import "testing"

// BenchmarkJoinWithPlus 测试 JoinWithPlus 函数的性能
func BenchmarkJoinWithPlus(b *testing.B) {
    // 1. 测试前的准备工作:定义输入参数(不会计入测试时间)
    a, b, c := "hello", "world", "go"

    // 2. 重置计时器:关键!忽略准备工作的时间
    b.ResetTimer()

    // 3. 循环执行目标函数:b.N 是 testing 包自动计算的迭代次数
    for i := 0; i < b.N; i++ {
        JoinWithPlus(a, b, c)
    }
}

// BenchmarkJoinWithBuilder 测试 JoinWithBuilder 函数的性能
func BenchmarkJoinWithBuilder(b *testing.B) {
    a, b, c := "hello", "world", "go"
    b.ResetTimer() // 必须加,否则准备时间会影响结果

    for i := 0; i < b.N; i++ {
        JoinWithBuilder(a, b, c)
    }
}

这里有个关键细节:b.ResetTimer() 必须加在循环前。

因为测试前的准备工作(比如定义参数)不应该计入函数的执行时间,否则会导致结果不准。

(二)执行 Benchmark 测试

在测试文件所在目录打开终端,执行以下命令:

// 执行所有基准测试,显示内存分配信息
go test -bench=. -benchmem -v

命令参数说明:

  • -bench=.: 表示执行所有 Benchmark 测试函数,“.” 是通配符;如果想只执行某个,比如 BenchmarkJoinWithPlus,就写 “-bench=JoinWithPlus”;

  • -benchmem: 关键参数,显示内存分配信息(每次执行分配多少内存、分配多少次);

  • -v: 显示详细日志,包括测试函数名称和执行状态。

执行后输出结果类似这样:

goos: darwin
goarch: arm64
pkg: stringutil
BenchmarkJoinWithPlus-8        500000000                2.430 ns/op           16 B/op          1 allocs/op
BenchmarkJoinWithBuilder-8     1000000000               0.8980 ns/op          0 B/op          0 allocs/op
PASS
ok      stringutil  3.678s

(三)读懂测试结果

结果中的每个字段都有特殊含义,以 “BenchmarkJoinWithPlus-8” 为例,逐字解读:

  • BenchmarkJoinWithPlus-8:“BenchmarkJoinWithPlus” 是测试函数名;“8” 表示测试时使用了 8 个 CPU 核心(和电脑核心数一致,可通过 -cpu 参数指定核心数,比如 -cpu=4 表示用 4 核);

  • 500000000:迭代次数(b.N 的值),testing 包会自动调整这个值,确保测试总时间在 1 秒左右(避免测试时间太短导致结果波动);

  • 2.430 ns/op:每次执行函数的平均耗时,“ns/op” 是纳秒/次,这里表示每次拼接耗时约 2.43 纳秒。

  • 16 B/op:每次执行函数分配的内存大小,这里是 16 字节。

  • 1 allocs/op:每次执行函数的内存分配次数,这里是 1 次。

对比两个函数的结果,很明显:JoinWithBuilder 性能更好,不仅耗时只有 “+” 拼接的 1/3 左右,还完全不分配内存。

这也印证了我们的常识:在字符串拼接场景,strings.Builder 比 “+” 更高效,尤其是拼接次数多的时候。

三、进阶技巧

(一)子基准测试:测试不同参数下的性能

实际开发中,函数性能可能受参数影响很大。比如排序函数,对 10 个元素和 1000 个元素排序的性能肯定不一样。

这时候就需要用子基准测试,通过 b.Run() 方法实现,给同一个函数传不同参数测试。

示例:测试切片排序函数在不同切片长度下的性能,在 “sort_test.go” 中编写代码:

// sort_test.go
package sortutil

import (
    "math/rand"
    "sort"
    "testing"
    "time"
)

// 生成指定长度的随机切片
func generateRandomSlice(length int) []int {
    rand.Seed(time.Now().UnixNano())
    slice := make([]int, length)
    for i := 0; i < length; i++ {
        slice[i] = rand.Intn(10000)
    }
    return slice
}

// BenchmarkSortSlice 测试不同长度切片的排序性能(子基准测试)
func BenchmarkSortSlice(b *testing.B) {
    // 定义要测试的切片长度列表
    lengths := []int{10, 100, 1000, 10000}

    for _, len := range lengths {
        // 子基准测试:第一个参数是子测试名称,第二个是测试函数
        b.Run(fmt.Sprintf("length=%d", len), func(b *testing.B) {
            // 准备工作:生成指定长度的随机切片
            slice := generateRandomSlice(len)
            b.ResetTimer()

            // 循环排序:每次排序前要复制切片,避免原切片已排序影响结果
            for i := 0; i < b.N; i++ {
                temp := make([]int, len)
                copy(temp, slice)
                sort.Ints(temp)
            }
        })
    }
}

执行命令 “go test -bench=SortSlice -benchmem” 后,输出结果会按子测试名称分类:

BenchmarkSortSlice/length=10-8        100000000               11.80 ns/op          48 B/op          1 allocs/op
BenchmarkSortSlice/length=100-8       10000000                119.0 ns/op         384 B/op          1 allocs/op
BenchmarkSortSlice/length=1000-8      1000000                 1210 ns/op         3072 B/op          1 allocs/op
BenchmarkSortSlice/length=10000-8     100000                  12300 ns/op        32768 B/op         1 allocs/op

从结果能清晰看到:切片长度越长,排序耗时越长,内存分配也越多。

这种方式能帮我们快速定位“函数在什么场景下性能不足”。

(二)控制测试时间:避免测试耗时过长

默认情况下,每个 Benchmark 测试会执行到总时间约 1 秒,确保结果稳定。但如果函数执行很慢(比如每次执行要 100 毫秒),1 秒只能执行 10 次,迭代次数太少,结果可能不准。

这时候可以用 -benchtime 参数延长测试时间,比如:

// 让每个测试执行 5 秒(增加迭代次数,结果更稳定)
go test -bench=. -benchmem -benchtime=5s

反之,如果想快速验证,也可以缩短测试时间,比如 “-benchtime=100ms”,但不建议太短,否则结果波动大。

(三)结合 pprof:定位具体性能瓶颈

Benchmark 能告诉我们“函数性能不好”,但没法直接说“哪里不好”。

这时候需要结合 Go 自带的 pprof 工具,分析 CPU 占用、内存分配等细节。执行命令时加上 pprof 相关参数即可:

// 生成 CPU 性能分析文件(cpu.pprof)
go test -bench=. -benchmem -cpuprofile=cpu.pprof

// 生成内存性能分析文件(mem.pprof)
go test -bench=. -benchmem -memprofile=mem.pprof

生成文件后,用以下命令查看分析结果(需要先安装 graphviz 工具可视化):

// 分析 CPU 性能
go tool pprof cpu.pprof

// 分析内存性能
go tool pprof mem.pprof

进入交互模式后,输入 “top” 可查看占用 CPU/内存最高的函数,输入 “web” 可生成可视化图表,清晰看到哪个函数是性能瓶颈。

pprof 相关可以查阅文章:Golang 性能分析神器 pprof 最全图文教程

四、注意事项

(一)性能影响

不同环境下,测试结果性能数据不稳定,因此,为了确保测试结果的准确性,需要尽可能的保证测试时机器环境的一致性稳定性:

  • 多次测试时,机器状态保持一致,并且机器需要处于闲置空闲状态。

  • 机器需要在测试时关闭节能模式/省点模式

  • 虚拟机和云主机 CPU 和内存一般会进行超分配,因此应该避免在虚拟机和云主机进行性能测量

⚠️:如果你确实没办法保证以上条件,那么只是在比对测试的时候,两次运行时机器环境要尽可能保持一致,确保基准测试的准确性

(二)测试参数

// 执行基准测试,-cpu 指定 cpu 核数,-count 执行轮数,-benchmem 输出cpu、内存测试结论, 点 . 表示在执行测试的目录为当前目录
go test -bench='BenchmarkStringBuilderAdd' -cpu=2,4 -count=3 -benchmem .
执行参数 简介 备注
-bench=‘Fib$’ 可传入正则,匹配用例 如只运行以 Fib 结尾的 benchmark 用例
-cpu=2,4 可改变 CPU 核数 GOMAXPROCS,CPU核数,默认机器的核数
-benchtime=5s
-benchtime=50x
可指定执行时间或具体次数 benchmark 的默认时间是 1s,决定了b.N的次数,测试时间
-benchtime的值除了是时间外,还可以是具体的次数。
例如,执行 30 次可以用-benchtime=30x
-count=3 可设置 benchmark 轮数 参数可以用来设置 benchmark 的轮数,默认是1轮
-benchmem 可查看内存分配量和分配次数 参数可以度量内存分配的次数,添加此参数后会在结果之后显示 n allocs/op
也就是内存分配了n次,m B/op,
总共分配了 m 字节8003641 B/op == 8 003 641 B=7.63M
-cpuprofile=./cpu.prof 生成CPU性能分析 执行 CPU profiling,并把结果保存在 cpu.prof
-memprofile=./mem.prof 生成内存性能分析 执行 Mem profiling,并把结果保存在 cpu.prof 文件中

(三)执行结果说明

mac@fiveyoboy learn-go % go test -bench='BenchmarkStringBuilderAdd'  -cpu=2,4 -count=3 -benchmem .

goos: darwin // 操作系统
goarch: arm64  // cpu 处理器架构(我这里是 mac )
pkg: fiveyoboy/learn-go/ // 测试执行所在的包
cpu: Apple M1 Pro // cpu

// 测试函数-使用CPU核数      执行次数   每次耗时ns(左移9位为秒)       分配内存字节B(左移6位为m) 每次分配内存次数
BenchmarkStringBuilderAdd-2      2170213               546.5 ns/op          4320 B/op          6 allocs/op
BenchmarkStringBuilderAdd-2      2217442               548.2 ns/op          4320 B/op          6 allocs/op
BenchmarkStringBuilderAdd-2      2229522               543.1 ns/op          4320 B/op          6 allocs/op

// 上面使用2核进行测试,测试3次,下面是用4核进行测试,测试 3 次
BenchmarkStringBuilderAdd-4      2068552               563.3 ns/op          4320 B/op          6 allocs/op
BenchmarkStringBuilderAdd-4      2069716               561.8 ns/op          4320 B/op          6 allocs/op
BenchmarkStringBuilderAdd-4      2103303               587.5 ns/op          4320 B/op          6 allocs/op

PASS
ok      fiveyoboy/learn-go       9.302s 
结果参数 简介
b.N benchmark的次数b.N,默认是-benchtime=1s内执行的次数,
b.N 从 -benchtime=1s开始,如果该用例能够在 1s 内完成,
b.N 的值大概以 1, 2, 3, 5, 10, 20, 30, 50, 100 这样的序列递增,越到后面,增加得越快
goos 操作系统
goarch cpu处理器
pkg 所在的包
BenchmarkGenerate-2
BenchmarkGenerate-4
BenchmarkGenerate-8
【测试函数名】-【cpu核数】BenchmarkGenerate-2,
表示使用2核CPU执行BenchmarkGenerate测试函数
ns/op 每次耗时多少毫秒
B/op 每次分配内存多少字节Bytes
allocs/op 每次分配多少次内存

常见问题

Q1. 测试结果波动很大?

这是新手最常遇到的问题,原因主要有 3 个:

  • ① 没加 b.ResetTimer():准备工作的时间混入了测试时间,解决办法就是在循环前加 b.ResetTimer();

  • ② 测试时电脑有其他占用资源的程序:比如开着视频、游戏,CPU 或内存被占用,导致测试结果不准,解决办法是关闭其他程序,确保测试环境安静;

  • ③ 迭代次数太少:测试时间太短,解决办法是用 -benchtime 延长测试时间,增加迭代次数。

Q2. 子基准测试不执行?

核心原因是执行命令时的 -bench 参数没匹配到子测试。

比如子测试名称是 “BenchmarkSortSlice/length=10”,如果只写 “-bench=SortSlice”,会执行所有子测试;如果写 “-bench=SortSlice/length=10”,会只执行该子测试。

如果参数写的是 “-bench=length=10”,则匹配不到,子测试不会执行。

Q3. 内存分配次数为 0,但实际有分配?

可能是 Go 的逃逸分析优化导致的。

比如函数内的变量被分配到栈上,而不是堆上,栈内存的分配和释放不会被 benchmem 统计。

这种情况是正常的,说明 Go 编译器做了优化,不用刻意处理。

Q4. 测试函数和目标函数不在同一个包?

如果目标函数是包内私有函数(首字母小写),测试函数必须和它在同一个包才能访问;如果是公开函数(首字母大写),测试函数可以在不同包,但需要导入目标包。

建议测试函数和目标函数放在同一个包,避免导入路径问题。

总结

Benchmark 作为 Go 性能测试的核心工具,用法其实不难,关键在于掌握细节:写测试函数时加 b.ResetTimer(),执行时用 -benchmem 看内存分配,用子基准测试测不同参数场景,结合 pprof 定位瓶颈。

新手入门的关键是“多练+多观察结果”:先从简单函数的测试写起,对比不同实现的性能差异,再逐步尝试子基准测试和 pprof 分析。

记住,性能测试不是“炫技”,而是“解决实际问题”——比如优化后接口响应时间从 100ms 降到 10ms,这才是 Benchmark 的真正价值。

最后提醒:性能优化要“适度”,不要为了优化而优化。先用 Benchmark 找到真正的瓶颈,再针对性优化,避免把时间浪费在对整体性能影响不大的函数上。

如果有其他 Benchmark 踩坑经历,欢迎在评论区交流~

版权声明

未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!

本文原文链接: https://fiveyoboy.com/articles/go-testing-benchmark/

备用原文链接: https://blog.fiveyoboy.com/articles/go-testing-benchmark/