刚开始写 Go 的时候,很多人会把“性能优化”理解成一种直觉游戏:我觉得这个写法少了一层函数调用,应该更快;我觉得这个循环里用了 append,应该会慢;我觉得字符串拼接换成 strings.Builder,一定更专业。直觉有时候能帮你发现问题,但它不能替代测量。尤其在 Go 里,编译器、逃逸分析、运行时分配器都在悄悄做事,很多看上去“聪明”的写法,实际效果可能很小,甚至更差。
基准测试的价值不在于让你到处追求纳秒级数字,而是让你在改代码之前有一个参照,在改代码之后知道收益是否值得。它特别适合回答三类问题:第一,某个函数在常见输入规模下大概多快;第二,两个实现谁更稳;第三,一个实现是否带来了额外内存分配。本文用几个贴近日常的例子讲 Go 的 benchmark,重点放在初学者真正能用起来的方式上。
第一个 benchmark
Go 的基准测试和单元测试放在同一种文件里,文件名通常是 xxx_test.go。函数名以 Benchmark 开头,参数是 *testing.B。比如我们有一个非常普通的函数,把用户 ID 拼成日志前缀:
package logfmt
import "fmt"
func Prefix(userID int, action string) string {
return fmt.Sprintf("user=%d action=%s", userID, action)
}
对应的基准测试可以这样写:
package logfmt
import "testing"
func BenchmarkPrefix(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = Prefix(42, "login")
}
}
这里最容易疑惑的是 b.N。它不是你自己指定的固定次数,而是 Go 测试框架动态调整出来的循环次数。框架会让函数跑足够多次,以便得到相对稳定的平均耗时。运行方式如下:
go test -bench=.
你可能会看到类似输出:
BenchmarkPrefix-8 13890271 86.10 ns/op
这表示在当前机器、当前 Go 版本、当前负载下,每次调用平均约 86 纳秒。这个数字不能拿去和同事电脑上的数字硬比,因为 CPU、系统负载、省电策略都会影响结果。它最适合在同一台机器上比较同一段代码的前后变化。
一定要看 -benchmem
只看耗时很容易漏掉问题。很多服务里的性能瓶颈不是某个函数算得慢,而是高频路径上制造了太多短生命周期对象,给 GC 增加压力。Go 的 benchmark 可以用 -benchmem 显示每次操作的内存分配:
go test -bench=. -benchmem
输出可能变成:
BenchmarkPrefix-8 13890271 86.10 ns/op 24 B/op 2 allocs/op
B/op 表示每次操作分配多少字节,allocs/op 表示每次操作分配多少次。对于业务接口来说,偶尔多几个分配不一定重要;但对日志格式化、协议解析、批量导入、消息消费这类高频路径来说,分配次数非常值得关注。很多“CPU 不高但延迟抖”的线上问题,最后会发现是对象分配和 GC 压力叠加出来的。
不过也别走到另一个极端:看到 2 allocs/op 就立刻重写代码。优化之前先问三个问题:这个函数是不是热点?调用频率有多高?优化后的代码可读性会不会明显下降?基准测试给你数据,但是否值得改,还需要结合场景判断。
用字符串拼接做一个对比
假设我们要把一批标签拼成一行文本:
package label
func JoinSlow(labels []string) string {
var out string
for _, label := range labels {
if out != "" {
out += ","
}
out += label
}
return out
}
这个实现很直观,初学者也经常这样写。问题是字符串在 Go 里是不可变的,每次 out += label 都可能创建一个新字符串。输入越长,复制越多。我们可以写一个 benchmark:
package label
import "testing"
var smallLabels = []string{"api", "login", "success", "mobile", "paid"}
func BenchmarkJoinSlow(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = JoinSlow(smallLabels)
}
}
如果再写一个 strings.Builder 版本:
package label
import "strings"
func JoinBuilder(labels []string) string {
var b strings.Builder
for i, label := range labels {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(label)
}
return b.String()
}
继续加 benchmark:
func BenchmarkJoinBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = JoinBuilder(smallLabels)
}
}
运行后你可能会看到 Builder 版本分配更少、耗时也更稳定。可是这个结论只对当前输入规模成立。如果标签只有两个,差距可能没意义;如果标签有两千个,慢版本可能会被明显拉开。一个严谨的 benchmark 通常会覆盖多个输入规模,而不是只测一个“刚好证明自己观点”的例子。
用子 benchmark 覆盖输入规模
testing.B 支持子 benchmark,写法和子测试类似。我们可以把不同规模放在同一个函数里:
package label
import (
"fmt"
"testing"
)
func makeLabels(n int) []string {
labels := make([]string, n)
for i := range labels {
labels[i] = fmt.Sprintf("tag%d", i)
}
return labels
}
func BenchmarkJoinImplementations(b *testing.B) {
sizes := []int{2, 10, 100, 1000}
for _, size := range sizes {
labels := makeLabels(size)
b.Run(fmt.Sprintf("slow/%d", size), func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = JoinSlow(labels)
}
})
b.Run(fmt.Sprintf("builder/%d", size), func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = JoinBuilder(labels)
}
})
}
}
这段代码有一个细节:labels := makeLabels(size) 放在子 benchmark 的循环外。我们要测的是拼接函数,不是构造测试数据。如果把准备数据放进 for i := 0; i < b.N; i++,结果会混入额外开销,最后你以为自己测到了拼接,其实测到了数据准备。
如果确实有复杂准备步骤必须放在基准测试里,可以用 b.ResetTimer() 把准备阶段排除:
func BenchmarkProcess(b *testing.B) {
input := loadLargeFixture()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = Process(input)
}
}
也可以用 b.StopTimer() 和 b.StartTimer() 包住每轮里不得不做、但不想计入核心逻辑的准备工作。不过对初学者来说,先把测试数据放在循环外,通常已经足够。
防止编译器把结果优化掉
有些 benchmark 看起来跑得飞快,是因为编译器发现结果没人用,直接把计算消掉了。比如:
func BenchmarkSum(b *testing.B) {
values := []int{1, 2, 3, 4, 5}
for i := 0; i < b.N; i++ {
Sum(values)
}
}
如果 Sum 足够简单,编译器可能做一些激进优化。一个常见做法是把结果赋给包级变量:
var sink int
func BenchmarkSum(b *testing.B) {
values := []int{1, 2, 3, 4, 5}
for i := 0; i < b.N; i++ {
sink = Sum(values)
}
}
sink 不是魔法,只是让结果“逃出”当前函数,降低被完全消除的可能。实际业务里的函数通常会有更多外部效果,不一定需要这个技巧;但在测纯函数、微小算法、格式化函数时,它很有用。
切片预分配:先测再改
另一个常见优化是给切片预分配容量。假设我们要过滤出所有可见用户:
package users
type User struct {
ID int
Name string
Deleted bool
}
func VisibleUsers(input []User) []User {
var out []User
for _, user := range input {
if !user.Deleted {
out = append(out, user)
}
}
return out
}
预分配版本:
func VisibleUsersPrealloc(input []User) []User {
out := make([]User, 0, len(input))
for _, user := range input {
if !user.Deleted {
out = append(out, user)
}
}
return out
}
这个版本看上去一定更好,因为减少了扩容。但它也可能多占内存:如果一千个用户里只有一个可见,容量仍然按一千个申请。是否合适取决于数据分布。如果大多数用户都会留下,预分配很合理;如果过滤比例极高,可以先统计数量,或者接受少量扩容。benchmark 可以帮助你做决定:
func BenchmarkVisibleUsers(b *testing.B) {
input := make([]User, 1000)
for i := range input {
input[i] = User{ID: i, Name: "user"}
if i%10 == 0 {
input[i].Deleted = true
}
}
b.Run("plain", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = VisibleUsers(input)
}
})
b.Run("prealloc", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = VisibleUsersPrealloc(input)
}
})
}
这里还可以再加一个“删除 90%”的输入。真实项目里,最好的 benchmark 往往来自真实数据的形状,而不是脑子里随便想的数组长度。比如接口一次最多返回 50 条,那就测 10、50、100;批量导入一次通常 5000 行,那就测 1000、5000、20000。
benchmark 不是单元测试的替代品
有些团队会犯一个小错误:为了写 benchmark,顺手把正确性检查也塞进去。比如每轮都检查输出是否等于预期:
func BenchmarkJoinBuilderWithCheck(b *testing.B) {
for i := 0; i < b.N; i++ {
got := JoinBuilder(smallLabels)
if got != "api,login,success,mobile,paid" {
b.Fatal(got)
}
}
}
这样做不是绝对错误,但它会把检查逻辑的成本混进结果。更清楚的方式是把正确性留给普通测试:
func TestJoinBuilder(t *testing.T) {
got := JoinBuilder([]string{"api", "login"})
want := "api,login"
if got != want {
t.Fatalf("JoinBuilder() = %q, want %q", got, want)
}
}
benchmark 里只测性能。测试保证你没改错,benchmark 告诉你改完是否更快。这两个角色分开,代码会更容易维护。
读 benchmark 输出时保持克制
基准测试结果会波动。后台进程、温度、CPU 调度、笔记本是否插电,都会影响数字。所以比较两个实现时,不要被一次输出里的 3% 差异牵着走。你可以多跑几次:
go test -bench=Join -benchmem -count=5
如果每次结果都稳定显示 A 比 B 快很多,结论比较可信。如果结果忽上忽下,说明差异可能不大,或者 benchmark 本身不稳定。对于严肃性能分析,可以使用 benchstat 这类工具比较多轮输出;入门阶段至少要养成一个习惯:不要只凭一次运行就宣布优化成功。
也要避免把微基准结果直接推导到整体服务。一个函数快了 30%,如果它只占请求总耗时的 1%,用户几乎感受不到。反过来,一个函数只快了 5%,但它在每个请求里调用几千次,收益可能很明显。benchmark 最好和 profiling、日志指标、真实压测结合起来看。
一个可执行的优化流程
在日常项目里,我更推荐这样使用 benchmark:
- 先用日志、监控或 pprof 找到热点,不要凭感觉到处优化。
- 为热点函数补一个能代表真实输入的 benchmark。
- 运行
go test -bench=. -benchmem -count=5,保存改动前结果。 - 做一个小而明确的优化,比如预分配、减少格式化、复用缓冲区。
- 再运行同样命令,比较耗时和分配。
- 如果收益明显且代码仍然好读,就保留;否则撤回或换方向。
这个流程看上去慢,其实能省很多时间。最浪费时间的优化,是花半天写出一段更复杂的代码,最后没有可证明的收益,还让后来的人不敢改。
小结
Go 的 benchmark 门槛很低:写 BenchmarkXxx,循环 b.N,用 go test -bench=. -benchmem 运行,就能得到耗时和分配数据。真正难的是解释这些数据:准备步骤是否混进来了,输入规模是否真实,结果是否被优化掉,多次运行是否稳定,收益是否值得牺牲可读性。
对初学者来说,先把 benchmark 当成一把尺子,而不是一场比赛。它不是为了证明你写得多快,而是帮助你在具体场景里做更可靠的选择。只要你愿意先测量再下结论,很多性能讨论就会从“我觉得”变成“数据说明”。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。