Go服务跑着跑着CPU就爬到80%以上,或者内存一天涨几百MB下不来,top只能看到进程号,看不到是哪段代码。Go自带的pprof就是干这个的,不用装任何东西,代价是在程序里开一个端口。
把pprof端点挂上去
最省事的接法是空导入,一行搞定,它会在默认mux上注册/debug/pprof/这一组路由:
import (
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
http.ListenAndServe("127.0.0.1:6060", nil)
}()
}
单独起一个端口,别和业务端口混在一起。绑127.0.0.1,别绑0.0.0.0,这套端点会把内部堆栈、函数名、内存分布原样吐出来,暴露到公网等于把源码结构送出去。之前见过有服务把6060直接映射到了容器外部,扫描器当天就来敲门。
业务用了自己的mux时,得手工注册这五个handler:
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
还有一种情况是路由前面挂了鉴权或者统一前缀,pprof请求被中间件挡掉,表现为能连上但返回404或者跳转到登录页。这种时候先curl一下/debug/pprof/看有没有反应。
抓CPU:采样期间必须有真实流量
curl -o cpu.prof "http://127.0.0.1:6060/debug/pprof/profile?seconds=30"
go tool pprof ./yourbinary cpu.prof
seconds默认30秒,这30秒里服务要是没请求,采出来的是一片空白,什么热点都看不到。压测、回放日志、或者挑业务高峰期抓都行,实测在流量低谷抓过一次,top里全是runtime的调度函数,白等半小时。
进到交互界面之后,四条命令够用:
(pprof) top
(pprof) top -cum
(pprof) list handleOrder
(pprof) web
top按flat排,flat是函数自身耗掉的时间;top -cum按cumulative排,是这个函数加上它调用的所有东西。这两个列表的差别才是重点:flat高说明活儿就是它干的,cum高而flat低说明它只是个入口,成本在下面,优化它本身一点用没有。list把函数源码逐行标注采样数,看到具体行号就可以动手了。
web会生成SVG调用图,需要装graphviz,缺了会报找不到dot。不想装就用go tool pprof -http=:8080 ./yourbinary cpu.prof,浏览器里直接看,Go 1.26起这个界面默认就是火焰图,老的调用图在View菜单里还能切回去。
火焰图看宽度不看高度。最宽的那根横条才是开销大头,塔尖上那些细长的看着吓人,实际占比很小。
heap的两个视角别搞混
go tool pprof -inuse_space ./yourbinary heap.prof
go tool pprof -alloc_space ./yourbinary heap.prof
inuse_space是此刻还活着的内存,查「内存一直在涨、疑似泄漏」用它。alloc_space是启动至今累计分配过的总量,包含已经被回收掉的,查「GC太频繁、CPU被GC吃掉」用它。一个函数可以在alloc_space里排第一、在inuse_space里完全不见,那不是泄漏,是churn,表现出来是GC占用和延迟抖动,治法是复用对象池,而不是去找谁持着引用不放。
交互里也能切,输入sample_index = alloc_space回车就行。
查泄漏最有效的办法是隔一段时间抓两次做差:
curl -s http://127.0.0.1:6060/debug/pprof/heap > h1.prof
# 等 10 分钟
curl -s http://127.0.0.1:6060/debug/pprof/heap > h2.prof
go tool pprof -base h1.prof h2.prof
-base把前一份减掉,剩下的就是这段时间新涨的部分。对着单份profile猜哪个条目不对劲,远不如这个快。
goroutine泄漏:内存泄漏穿着马甲
curl -s "http://127.0.0.1:6060/debug/pprof/goroutine?debug=1" | head -40
curl -s "http://127.0.0.1:6060/debug/pprof/goroutine?debug=2" > gor.txt
debug=1按堆栈分组并统计数量,debug=2给每个goroutine的完整堆栈,格式和panic打出来的一样。数量就是信号,某个堆栈的计数一直在往上走,那就是泄漏点。按状态归个类更快:
curl -s "http://127.0.0.1:6060/debug/pprof/goroutine?debug=1" | grep -oE "^[0-9]+ @" | sort -rn | head
卡在chan receive的,多半是等一个永远不会有人写的channel;卡在semacquire的是锁被别人拿着;IO wait一般是正常的网络等待,但如果几千个都堆在这儿,要回头看下游是不是慢了或者超时没设。最常见的两种成因,一个是channel建了不关,一个是HTTP请求的context从头到尾没传下去,连接断了goroutine还挂着。
每个goroutine都带着自己的栈,栈上引用的东西全部可达。所以goroutine泄漏在heap profile里会伪装成「某个缓冲区在泄漏」,对着缓冲区查半天,真正的毛病是几千个goroutine堵在一个没关的channel上。这种事遇到过不止一次。
block与mutex:CPU不高但延迟很大时看这两个
runtime.SetBlockProfileRate(1)
runtime.SetMutexProfileFraction(1)
go tool pprof http://127.0.0.1:6060/debug/pprof/block
go tool pprof http://127.0.0.1:6060/debug/pprof/mutex
block记录goroutine在同步原语上阻塞的时间,channel收发、WaitGroup、锁获取都算;mutex专门把锁竞争归到持有者头上。CPU profile在这种场景下是死路,因为CPU根本没在干活。典型的形状是某个看着很便宜的东西(配置map、本地缓存、指标注册表)外面包了一把sync.RWMutex,每个请求都要读一次,量上来就堵成一团。
这两个开关按1/N采样,设成1是每次事件都记,开销不小。生产上先设成100试一下,确认有用再往下调,别常态全开。
没有HTTP端口时的两条退路
命令行程序或者没开端口的服务,用runtime/pprof直接写文件:
f, _ := os.Create("cpu.prof")
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
// 堆
f2, _ := os.Create("heap.prof")
runtime.GC()
pprof.WriteHeapProfile(f2)
写堆之前先手动跑一次runtime.GC(),把已经死掉还没回收的对象清掉,采出来的inuse_space才准。另一条退路是接个系统信号,收到SIGUSR1时dump一份到磁盘,服务不用重启,线上长期挂着也不占什么资源。
几个常见走不通的地方
go tool pprof报找不到符号、函数名全是地址,一般是profile文件和binary对不上,抓完之后代码改过或者binary换过就会这样,把两个文件一起传过来比单独传profile靠谱。容器里抓不到,八成是6060没映射出来,或者curl根本没装,用kubectl port-forward转发一下更省事。
还有个容易忽略的:抓CPU时服务正在限流或者刚好在GC,采出来的曲线会飘。同一个现象抓两三次再下结论。GODEBUG=gctrace=1能把每次GC的耗时和堆变化打到stderr,跟pprof对着看,很快能判断到底是分配太多还是回收不及时。
版本相关的几件事
Go 1.24、1.25、1.26、1.27,这条线上pprof一直在加东西。Go 1.25引入了flight recorder,可以在runtime里常驻一个环形缓冲,出问题之后再回头取最近几秒的轨迹,不用一直开着trace那种开销。Go 1.26在2026年2月10日发布,pprof的web界面改成默认火焰图,同时Green Tea垃圾回收器转默认开启,官方给的口径是真实程序的GC开销降10%到40%,在Ice Lake或者Zen 4以后的机器上还能再低一成。Go 1.27在2026年8月19日发布,带来了goroutineleak这个新的profile类型,专门报告那些因为堵在不可达的channel或锁上、永远醒不过来的goroutine,查泄漏时可以直接用它,比人肉数debug=1的计数省事。
另外,Go 1.26的实验开关goroutineleakprofile在1.27里已经转正,用到这个profile时注意工具链版本。文中版本信息截至2026年10月。