Go 基准测试和分配入门:用 -benchmem 看清楚优化是否值得

面向 Go 初学者的基准测试实践:从 testing.B、-benchmem、字符串拼接、切片预分配到如何判断一次优化是否真的有价值。

刚开始写 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:

  1. 先用日志、监控或 pprof 找到热点,不要凭感觉到处优化。
  2. 为热点函数补一个能代表真实输入的 benchmark。
  3. 运行 go test -bench=. -benchmem -count=5,保存改动前结果。
  4. 做一个小而明确的优化,比如预分配、减少格式化、复用缓冲区。
  5. 再运行同样命令,比较耗时和分配。
  6. 如果收益明显且代码仍然好读,就保留;否则撤回或换方向。

这个流程看上去慢,其实能省很多时间。最浪费时间的优化,是花半天写出一段更复杂的代码,最后没有可证明的收益,还让后来的人不敢改。

小结

Go 的 benchmark 门槛很低:写 BenchmarkXxx,循环 b.N,用 go test -bench=. -benchmem 运行,就能得到耗时和分配数据。真正难的是解释这些数据:准备步骤是否混进来了,输入规模是否真实,结果是否被优化掉,多次运行是否稳定,收益是否值得牺牲可读性。

对初学者来说,先把 benchmark 当成一把尺子,而不是一场比赛。它不是为了证明你写得多快,而是帮助你在具体场景里做更可靠的选择。只要你愿意先测量再下结论,很多性能讨论就会从“我觉得”变成“数据说明”。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页