本节把 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 → Builder | 954µs → 26µs | 9.3MB → 36KB | 快 36.8 倍,省 261 倍内存 |
| NoCap → WithCap | 3125ns → 1372ns | 25KB → 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) | 平均延迟 | P95 | P99 | 最长 |
|---|---|---|---|---|---|
| c=1 | 3480 | 0.287ms | 0ms | 1ms | — |
| c=50 | 6807 | 7.345ms | 10ms | 23ms | 36ms |
| c=100 | 6662 | 15.008ms | 19ms | 78ms | — |
这张表是本节的精华,它讲了一个所有后端都要懂的道理:
- 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 定位瓶颈 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。