Go 后端面试复盘:语言、并发与服务实践 · 21. 测试、基准测试与性能排查至22. Go 性能优化
前置:Go 系统学习,并完成一个HTTP 工程练习。本页用于面试前查漏补缺,不按章节替代语言课程。
21. 测试、基准测试与性能排查
21.1 Go 单元测试基本结构是什么?
func TestAdd(t *testing.T) {
got := Add(1, 2)
want := 3
if got != want {
t.Fatalf("Add()=%d, want=%d", got, want)
}
}测试文件以 _test.go 结尾,测试函数以 Test 开头。
21.2 什么是表驱动测试?
func TestParse(t *testing.T) {
tests := []struct {
name string
input string
want int
wantErr bool
}{
{name: "valid", input: "12", want: 12},
{name: "invalid", input: "x", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Parse(tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("err=%v, wantErr=%v", err, tt.wantErr)
}
if !tt.wantErr && got != tt.want {
t.Fatalf("got=%d, want=%d", got, tt.want)
}
})
}优点是用例结构统一、易扩展、可独立运行子测试。
21.3 测试并行有什么注意事项?
使用 t.Parallel() 时:
- 避免共享可变全局状态。
- 不要争用固定端口或同一数据库记录。
- 循环变量要确认版本语义并保持写法清晰。
- 临时文件使用
t.TempDir()。 - 环境变量变更与并行测试可能冲突。
21.4 Benchmark 怎么写?
func BenchmarkEncode(b *testing.B) {
input := prepare()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = Encode(input)
}
}执行:
go test -bench=. -benchmem ./...关注:
- ns/op
- B/op
- allocs/op
必须避免编译器把结果完全优化掉,也要把准备工作排除在计时之外。
21.5 如何比较两次 benchmark?
不要只凭单次结果。建议:
- 在稳定机器上多次运行。
- 保存 before/after 输出。
- 使用统计对比工具分析。
- 同时观察延迟、分配和吞吐。
- 确认输入和环境一致。
微基准变快不代表整条业务链路一定变快。
21.6 Fuzz Testing 是什么?
模糊测试自动生成大量输入,寻找 panic、崩溃、不变量破坏等问题。
func FuzzParse(f *testing.F) {
f.Add("123")
f.Fuzz(func(t *testing.T, s string) {
_, _ = Parse(s)
})
}适合解析器、协议、编码解码、安全边界。
21.7 pprof 有哪些 profile?
- CPU:CPU 时间花在哪里。
- heap:当前存活对象或累计分配。
- goroutine:goroutine 堆栈。
- mutex:锁竞争等待。
- block:阻塞事件。
- allocs:累计分配。
- threadcreate:线程创建。
分析时区分:
- flat:函数自身消耗。
- cum:函数及其调用链累计消耗。
21.8 CPU profile 怎么分析?
流程:
- 在代表性负载下采样。
- 查看 top 找主要 CPU 消耗。
- 查看调用图和 source。
- 判断是业务计算、序列化、锁、自旋、GC 还是系统调用。
- 针对热点修改。
- 使用相同条件复测。
不要只优化排名靠前但总占比很低的函数。
21.9 heap profile 中 inuse 和 alloc 的区别?
inuse_space:当前仍存活的内存,适合看内存占用。inuse_objects:当前存活对象数量。alloc_space:运行期间累计分配字节,适合看分配压力。alloc_objects:累计分配对象数。
某函数累计分配很高但存活很低,可能造成 GC CPU 压力;存活持续增长则可能是缓存、积压或泄漏。
21.10 trace 能看什么?
Go execution trace 可观察:
- goroutine 调度和阻塞。
- 网络等待。
- syscall。
- GC 事件。
- P 的利用率。
- 用户标注的 task/region。
适合分析“CPU 不高但延迟很高”“goroutine 大量等待”“调度不均”等问题。trace 数据较大,采集窗口应受控。
21.11 如何建立可观测性?
三大支柱:
- Metrics:聚合趋势和告警。
- Logs:离散事件和上下文。
- Traces:跨服务请求链路。
推荐服务指标:
- 请求量、错误率、延迟分位数。
- 并发数、队列长度。
- 连接池使用率和等待。
- goroutine、堆、GC CPU。
- 下游调用延迟和错误。
避免高基数 label,例如直接用 user ID、订单 ID 做 metrics 标签。
22. Go 性能优化
22.1 性能优化的正确顺序是什么?
- 明确目标:吞吐、P99、内存、CPU 还是成本。
- 建立可复现基线。
- profile 找主要瓶颈。
- 优化最大热点。
- benchmark 和集成压测验证。
- 观察正确性和可维护性。
- 上线灰度和监控。
没有数据的优化通常只是猜测。
22.2 常见 CPU 优化方向有哪些?
- 降低算法复杂度。
- 避免重复计算。
- 批处理减少函数/网络/锁开销。
- 使用更合适的数据结构。
- 减少序列化和格式转换。
- 改善数据局部性。
- 降低锁竞争。
- 缓存真正高成本且结果稳定的计算。
算法和架构层优化通常比语法微优化收益大。
22.3 常见内存优化方向有哪些?
- 减少不必要分配。
- 预分配 slice/map/buffer。
- 流式处理大数据。
- 控制缓存、队列和批次上限。
- 避免小切片持有大数组。
- 使用紧凑结构和更合理字段顺序。
- 避免长期保留超大 buffer。
- 按需使用
sync.Pool。
22.4 如何优化字符串拼接?
var b strings.Builder
b.Grow(estimated)
for _, part := range parts {
b.WriteString(part)
}
result := b.String()少量固定字符串用 + 通常足够清晰,编译器也可能优化。只有循环或热点中大量拼接才需要 Builder。
22.5 并发越高性能越好吗?
不是。过高并发会导致:
- 调度开销。
- 锁竞争。
- cache miss。
- 下游连接池等待。
- 队列堆积。
- 内存增长。
- 超时雪崩。
应使用有界并发,把并发度与 CPU、下游容量和任务特性匹配。
22.6 什么是假共享?
不同 goroutine 修改逻辑上不同的变量,但变量位于同一 CPU cache line,多个核心反复使缓存失效,性能下降。
高频原子计数器、分片状态中可能出现。解决方案包括分片、本地聚合、调整数据布局。padding 属于低层优化,必须用 benchmark 证明。
22.7 锁优化有哪些方法?
- 缩小临界区。
- 把慢 I/O 移到锁外。
- 降低共享状态。
- 分片锁。
- 使用不可变快照。
- 批量更新。
- 单所有者 goroutine。
- 读写锁仅在合适负载使用。
不要盲目用无锁结构;复杂无锁代码更难证明正确,并可能因原子重试产生高 CPU。
22.8 对象池为什么可能适得其反?
- 池管理本身有成本。
- 对象未重置导致数据污染。
- 大对象进入池导致常驻内存过高。
- 降低代码可读性。
- GC 和编译器进步后收益可能很小。
只有 profile 显示该类对象分配是明确热点,且对象大小适中、复用频繁时再引入。
22.9 如何优化 API P99?
优先查:
- 下游长尾和重试放大。
- 连接池等待。
- 队列排队。
- 锁竞争。
- GC assist。
- 大对象分配。
- 慢 SQL。
- DNS/TLS/建连。
- 日志或序列化阻塞。
- 超时层级不合理。
平均延迟正常并不代表 P99 正常。
继续阅读
- 排错
- 排错
- 排错