《Go 语言编程实战》12.1 基准与压测

把 TaskHub 的性能问题从「猜」变成「量」:用 go test -bench 和 -benchmem 测出字符串拼接与预分配 slice 的真实差距,用 ab 在三种并发下压测 HTTP 服务找到吞吐拐点,再用 RunParallel 把压测写进 Go 测试,并用 Little's Law 校验并发、吞吐与延迟三者自洽,最后列出压测最容易骗人的几个坑。

本节把 TaskHub 推进到「性能可量化」:前面十一章都在讲正确性,但线上真正让用户骂街的往往是慢。我们先用 Go 基准测出微优化的真实收益,再用压测找到服务的吞吐拐点,并用数学把「并发、吞吐、延迟」三个数字对起来。
适用版本:Go 1.27(实测 go1.27.0),压测机为 Apple M1 Pro。

12.1 基准与压测

性能优化最容易犯的错误是凭直觉改代码。人脑对性能的直觉极差:你以为的瓶颈十有八九不是真瓶颈,你以为无所谓的开销可能是大头。这一节的全部内容就是一件事——把性能变成数字。

工具分两类,用途不同:

工具测什么粒度
go test -bench单个函数/操作的耗时与分配纳秒级、函数级
压测(ab / wrk / Go 并行基准)整个服务的吞吐与延迟分布秒级、请求级

先基准后压测:基准告诉你「哪个函数慢」,压测告诉你「整体能扛多少」。用基准做微优化,用压测定容量。

12.1.1 写一个基准测试

基准函数和测试函数长得一样,只是 *testing.T 换成 *testing.B,跑在 for i := 0; i < b.N; i++ 里:

func BenchmarkBuildCSVConcat(b *testing.B) {
	for i := 0; i < b.N; i++ {
		if buildCSVConcat(tasks) == "" {
			b.Fatal("empty")
		}
	}
}

b.N 由测试框架自动调整——它先试一个小的,逐步放大,直到测量时间足够稳定。你只管写循环体。

两个关键参数:

  • -benchmem:额外报告每次操作的内存分配(B/op 与 allocs/op)。几乎总是要加——Go 的性能问题一半以上是分配问题。
  • -benchtime=200ms:每个基准跑多久。默认 1 秒;调小能加快迭代,调大(如 -benchtime=10s)能得到更稳的数字。

12.1.2 本机实测:两个微优化到底值多少

第一个对比:拼接 1000 条任务的 CSV,用 s += 还是 strings.Builder。

func buildCSVConcat(tasks []Task) string {
	s := ""
	for _, t := range tasks {
		s += strconv.FormatInt(t.ID, 10) + "," + t.TenantID + "," + t.Title + "\n"
	}
	return s
}

func buildCSVBuilder(tasks []Task) string {
	var b strings.Builder
	b.Grow(len(tasks) * 32) // 预分配,避免中途扩容
	for _, t := range tasks {
		b.WriteString(strconv.FormatInt(t.ID, 10))
		b.WriteByte(',')
		b.WriteString(t.TenantID)
		b.WriteByte(',')
		b.WriteString(t.Title)
		b.WriteByte('\n')
	}
	return b.String()
}

第二个对比:收集 ID 时预分配 slice 容量还是任其增长。

func collectNoCap(tasks []Task) []int64 {
	var ids []int64
	for _, t := range tasks {
		ids = append(ids, t.ID)
	}
	return ids
}

func collectWithCap(tasks []Task) []int64 {
	ids := make([]int64, 0, len(tasks))
	for _, t := range tasks {
		ids = append(ids, t.ID)
	}
	return ids
}

本机实测(go test -bench=. -benchmem -benchtime=200ms):

goos: darwin
goarch: arm64
pkg: demo/internal/bench
cpu: Apple M1 Pro
BenchmarkBuildCSVConcat-10     	     266	    954366 ns/op	 9330609 B/op	    1901 allocs/op
BenchmarkBuildCSVBuilder-10    	    8540	     25926 ns/op	   35653 B/op	     902 allocs/op
BenchmarkCollectNoCap-10       	   73186	      3125 ns/op	   25208 B/op	      12 allocs/op
BenchmarkCollectWithCap-10     	  178182	      1372 ns/op	    8192 B/op	       1 allocs/op

对着数字读结论:

对比耗时内存倍数
Concat → Builder954µs → 26µs9.3MB → 36KB快 36.8 倍,省 261 倍内存
NoCap → WithCap3125ns → 1372ns25KB → 8KB快 2.3 倍,分配 12 → 1 次

strings.Builder 快这么多是因为 s += 每次都分配一个新字符串并整体拷贝,1000 次拼接就是 O(n²) 的拷贝量。预分配 slice 则把 12 次扩容变成 1 次。这两个优化几乎是 Go 里的肌肉记忆,但"快多少"要靠基准才知道——不测,你只会觉得"应该快一点"。

12.1.3 怎么读 benchmark 数字

以 BenchmarkCollectNoCap-10 73186 3125 ns/op 25208 B/op 12 allocs/op 为例:

字段含义怎么看
-10用了 10 个逻辑 CPU与 GOMAXPROCS 一致
73186循环执行了 73186 次框架自动定的,不是你写的
3125 ns/op每次操作 3.1 微秒越小越好,但要相对看
25208 B/op每次操作分配 25KB与吞吐、GC 压力直接相关
12 allocs/op每次操作 12 次分配降到 1 是常见目标

一条经验:先看 allocs/op,再看 ns/op。分配次数下降通常直接带来耗时下降,而且它能跨机器、跨 Go 版本比较,比绝对耗时更稳定。

12.1.4 用 ab 给整个服务压测

基准测的是函数,压测测的是服务。先起一个真实 HTTP 服务(每个请求构造 20 条任务并序列化),再用 ab(ApacheBench)打:

# 串行:一次一个请求,测最纯粹的单请求延迟
ab -n 2000 -c 1  -k http://127.0.0.1:18093/v1/tasks
# 中等并发
ab -n 5000 -c 50 -k http://127.0.0.1:18093/v1/tasks
# 高并发:找拐点
ab -n 8000 -c 100 -k http://127.0.0.1:18093/v1/tasks

-k 开 keep-alive(真实客户端都复用连接),-c 是并发数,-n 是总请求数。

本机实测三种并发的关键数字:

并发吞吐 (req/s)平均延迟P95P99最长
c=134800.287ms0ms1ms—
c=5068077.345ms10ms23ms36ms
c=100666215.008ms19ms78ms—

这张表是本节的精华,它讲了一个所有后端都要懂的道理:

  • c=1 到 c=50,吞吐翻倍(3480 → 6807):并发确实在用满 CPU 的多个核,服务还没到瓶颈。
  • c=50 到 c=100,吞吐不升反降(6807 → 6662):到拐点了。再加并发只会让请求排队,延迟从 7.3ms 涨到 15ms(翻倍),P99 从 23ms 暴涨到 78ms,而吞吐一点没涨。
  • 结论:这个服务在单机上的合理并发就是 50 左右。继续加并发是负收益——用户更慢,机器更累。

如果只看 c=100 的吞吐(6662)就下结论"能扛 6662 QPS",会漏掉它是以 P99 三倍恶化为代价换来的。压测必须同时看吞吐和延迟分布,只看一个都会骗人。

12.1.5 用 Go 写并行压测,嵌进 CI

ab 是外部工具,CI 里不一定有。Go 自带的 RunParallel 能把压测写进测试文件:

func BenchmarkHandlerParallel(b *testing.B) {
	srv := httptest.NewServer(newHandler())
	defer srv.Close()

	b.ResetTimer()
	b.RunParallel(func(pb *testing.PB) {
		client := &http.Client{}
		for pb.Next() {
			resp, err := client.Get(srv.URL + "/v1/tasks")
			if err != nil {
				b.Fatal(err)
			}
			_, _ = io.Copy(io.Discard, resp.Body)
			resp.Body.Close()
		}
	})
}

两个细节:b.ResetTimer() 必须放在 httptest.NewServer 之后,否则建服务器的时间会被算进每操作耗时;必须读完并关闭 resp.Body,否则连接无法复用,测出来的是"每次新建连接"的假数据。

本机实测:

BenchmarkHandlerParallel-10    	    7621	    167487 ns/op	    7786 B/op	      92 allocs/op

167487 ns/op 换算成吞吐是 1e9 / 167487 ≈ 5970 req/s,和 ab 的 6807 同量级——两个独立工具互相对上了。

12.1.6 Little’s Law:让三个数字自洽

并发、吞吐、延迟不是三个独立的数字,它们被 Little’s Law 绑在一起:

并发数 = 吞吐 × 平均延迟

拿 c=50 的数据验证:吞吐 6807 req/s,平均延迟 7.345ms = 0.007345s,乘起来是 50.0——正好等于压测设的并发数 -c 50。这不是巧合,是 Little’s Law 的必然。

这条公式有两个实用推论:

  • 想提吞吐又不加延迟,只能降延迟。吞吐 = 并发 / 延迟,并发有上限(CPU、连接数),唯一能动的就是延迟。
  • 发现"并发=50 但算出来是 30",说明有请求没被统计(比如失败的、超时的),压测工具或服务端漏记了。

用 Little’s Law 反查压测结果自洽性,是发现压测脚本 bug 的好办法。

12.1.7 压测最容易骗人的几个坑

坑表现规避
客户端先到瓶颈吞吐上不去,以为是服务慢换更强机器压,或看压测机 CPU
走 localhost延迟低得离谱(网络开销为 0)记住这只测了应用,没测网络
没有预热前几次慢(连接/缓存冷、CPU 升频)先打几百次再正式计时
忘了 body服务端写不出去,阻塞一定要读并关闭响应体
单机同压同测压测进程和被测进程抢 CPU分机器,或至少 -c 别开太大
只跑一次数字抖动被当成结论-count=5 取中位数,或 benchstat

最后一条值得展开:单次压测结果不可信。CPU 调频、后台进程、GC 时机都会影响结果。Go 生态的 golang.org/x/perf/cmd/benchstat 就是干这个的——跑多次,它给出中位数和变化区间,并告诉你"两个版本的差异是否统计显著"。改代码前后各跑 5 次,用 benchstat 对比,才能避免"优化了个寂寞"。

小结

  • 先基准后压测:基准找慢函数,压测定容量。
  • -benchmem 几乎必加;先看 allocs/op 再看 ns/op。
  • 实测:strings.Builder 比 += 快 36.8 倍;预分配 slice 快 2.3 倍、分配从 12 降到 1。
  • 压测同时看吞吐和延迟:本机服务在 c=50 达到 6807 req/s 的拐点,c=100 吞吐不再涨、P99 恶化三倍。
  • RunParallel 把压测写进 Go 测试;b.ResetTimer 放在起服务器之后,务必读完并关闭 body。
  • Little’s Law 并发 = 吞吐 × 延迟 用来校验三个数字自洽。
  • 单次结果不可信,用 -count 和 benchstat 取统计。

压测告诉你"慢了多少",但回答不了"为什么慢"。下一节用 pprof 和 trace 把 CPU 花在哪、锁竞争多严重看到,而不是猜。

阅读导航:上一节:11.3 集成/端到端与数据隔离 · 下一节:12.2 pprof/trace 定位瓶颈 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练