在Go语言项目里,很多性能问题并不会在几秒的基准测试中暴露,而是随着程序持续运行几小时甚至几天后,才出现内存缓慢增长、GC压力变大或协程泄漏等情况。针对这类场景,必须设计长时间运行的性能测试,才能真实反映服务在生产环境中的表现。

为什么普通基准测试不够用
Go标准库中的testing包通过go test -bench提供基准测试能力,但其默认行为是使用-benchtime=1s,也就是说每个基准函数只运行大约一秒钟。对于CPU密集型的小函数来说,这足以得出稳定的耗时数据;但对于带有连接池、缓存、定时任务或后台协程的系统级逻辑,一秒钟根本无法观察到资源随时间累积的变化。
例如一个HTTP服务在每次请求后错误地把临时对象放进了全局map且未清理,短基准测试里只处理几千次请求,内存占用看不出异常;若连续跑一整晚,这个map会拖垮整个进程。因此,长时间运行的性能测试核心目标不是测“快不快”,而是测“稳不稳”。
使用-benchtime拉长基准时长
最简单的方式是修改基准测试的运行时间。Go的go test命令支持将-benchtime设置为带时间单位的值,比如30m代表三十分钟,2h代表两小时。这样基准函数会被持续调用,直到时间耗尽。
下面是一段模拟后台处理的基准代码,我们通过-benchtime=1h让它跑满一小时:
package main
import (
"testing"
"time"
)
func processJob() {
// 模拟实际业务:分配临时缓冲并短暂休眠
buf := make([]byte, 1024)
_ = buf
time.Sleep(time.Millisecond)
}
func BenchmarkProcessJob(b *testing.B) {
for i := 0; i < b.N; i++ {
processJob()
}
}
执行命令go test -bench=BenchmarkProcessJob -benchtime=1h即可。不过要注意,b.N在长时间运行中会被动态调整,Go会先试探性跑一小段来估算速率,再决定后续循环次数,因此测试代码内部不能依赖固定次数做初始化。
这种方法的优点是零代码侵入,缺点是无法在测试中穿插自定义观测逻辑,比如每十分钟打印一次堆内存。若需要更细粒度控制,就要在测试里自己写循环。
在测试代码中自建长时间循环
当我们需要边跑边采集指标时,可以放弃b.N,直接在Benchmark函数里用time.After或time.Ticker控制时长,并定期通过runtime.ReadMemStats读取内存状态。
package main
import (
"runtime"
"testing"
"time"
)
func BenchmarkLongRunWithMetrics(b *testing.B) {
stop := time.After(2 * time.Hour)
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
var m runtime.MemStats
for {
select {
case <-stop:
return
case <-ticker.C:
runtime.ReadMemStats(&m)
b.Logf("HeapAlloc=%vMB Goroutines=%d", m.HeapAlloc/1024/1024, runtime.NumGoroutine())
default:
// 被测逻辑
processJob()
}
}
}
func processJob() {
buf := make([]byte, 2048)
_ = buf
time.Sleep(time.Millisecond)
}
上面的代码每十分钟输出一次堆分配量和协程数。如果HeapAlloc随时间单调上升且不回落,往往意味着发生了内存泄漏;如果NumGoroutine持续增长,则可能是协程没有正确退出。
这种写法把测试主导权拿回自己手里,既可以控制总时长,也能嵌入任意观测点。但要注意b.Logf在基准测试中输出较多内容时,建议配合-v参数运行,否则日志可能不显示。
结合pprof做定时画像
长时间测试最怕“知道慢了但不知道慢在哪”。Go的net/http/pprof包可以在服务运行时暴露采样接口,我们在测试进程中同时启动一个HTTP服务,然后用外部脚本定时抓取堆或CPU画像。
package main
import (
"net/http"
_ "net/http/pprof"
"testing"
"time"
)
func TestLongRunWithPprof(t *testing.T) {
go func() {
http.ListenAndServe("127.0.0.1:6060", nil)
}()
end := time.Now().Add(3 * time.Hour)
for time.Now().Before(end) {
processJob()
time.Sleep(time.Millisecond)
}
}
测试启动后,我们可以在另外的终端里每隔半小时执行go tool pprof http://127.0.0.1:6060/debug/pprof/heap来下载当时的堆状态。将多个时间点的画像对比,就能清楚看到哪些对象在随时间堆积。
这种方案的强大之处在于,pprof不仅能看内存,也能通过/debug/pprof/profile抓CPU使用情况。对于排查长时间运行后调度延迟变高的问题,非常实用。
用独立协程避免测试逻辑干扰
在长时间测试中,如果主逻辑和指标采集耦合太紧,采集动作本身可能改变被测系统的行为。更好的做法是把被测服务作为一个普通Go程序启动,而性能测试脚本只负责长期施加压力和拉取数据。
例如编译出二进制文件./app后,用Shell循环配合wrk或自写客户端持续请求,同时用curl定时访问/debug/pprof接口。这样被测程序与生产形态几乎一致,测试结果更可信。
#!/bin/bash
# 持续压测六小时,每二十分钟抓一次堆画像
for i in $(seq 1 18); do
wrk -t4 -c100 -d20m http://127.0.0.1:8080/api
curl -s http://127.0.0.1:6060/debug/pprof/heap > heap_$i.pb.gz
sleep 1
done
这种分离式结构虽然搭建稍麻烦,但能最大程度排除“测试代码拖慢业务代码”的干扰。对于核心服务上线前的长稳验证,推荐采用。
常见误区与建议
一个常见误区是认为只要把-benchtime设很大就万事大吉。实际上若基准函数内部创建了未关闭的channel或文件句柄,长时间运行会让系统资源耗尽,但这属于测试代码写得不对,而非业务缺陷。因此写长测时,自身的资源清理也要严谨。
另一个建议是:长时间性能测试最好在隔离机器上跑,避免和开发机其他任务争抢CPU导致数据波动。同时把每次测试的Go版本、操作系统、负载参数都记录下来,方便横向对比历史结果。
| 方式 | 适用场景 | 观测能力 |
|---|---|---|
| -benchtime拉长 | 快速验证不泄漏 | 弱,仅最终数据 |
| 自建循环+Log | 需过程指标 | 中,自定义日志 |
| pprof定时抓 | 定位瓶颈点 | 强,可下钻 |
| 分离式压测 | 类生产验证 | 最强,真实形态 |
通过上述几种手段的组合,开发者可以在Go语言中建立起一套贴合自身业务的长时间性能测试体系,把那些“跑久了才出事”的隐患挡在上线之前。