《Go 语言编程入门》16.2 pprof 与 expvar 看运行时

给 TaskAPI 挂上运行时观测能力:用 net/http/pprof 一行导入拿到 CPU、堆、goroutine profile,用 go tool pprof 定位热点函数,再用 expvar 暴露自增计数器与自定义指标,并用 runtime/pprof 在离线程序里抓 CPU profile。

本节把 TaskAPI 推进到「能自证健康」:挂上 net/http/pprof 与 expvar 两个标准库端点,让线上进程随时能吐出 CPU 热点、内存分布、goroutine 栈和业务计数,不用重启、不用改代码。
适用版本:Go 1.27(实测 go1.27.0)。

16.2 pprof 与 expvar 看运行时

上一节解决了「事后能查日志」。但日志是应用自己写的,它看不到语言运行时的内部状态:CPU 花在哪个函数、堆里谁在占内存、有多少 goroutine 卡住。这些问题要靠 pprof(性能剖析)和 expvar(运行时变量导出)来回答。两者都是标准库,零第三方依赖。

16.2.1 一行导入,五个端点

net/http/pprof 这个包的巧妙之处在于:它的价值全在 init() 里。只要空白导入,它就会把一堆 handler 注册到 http.DefaultServeMux:

import (
	"net/http"
	_ "net/http/pprof" // 只为触发 init 注册
)

导入后,只要你的服务用的是默认 mux,就能访问:

端点内容
/debug/pprof/概览页,列出所有可用 profile
/debug/pprof/profile?seconds=30CPU profile,默认采样 30 秒
/debug/pprof/heap堆内存分配快照
/debug/pprof/goroutine当前所有 goroutine 的调用栈
/debug/pprof/cmdline进程启动命令行

TaskAPI 用的是自定义 http.ServeMux(第 13 章),所以默认 mux 上的这些端点不会和业务路由冲突——但我们通常单独起一个端口暴露它们,避免业务端口把观测端点暴露给公网。第 16.3 节会讲边界问题。

16.2.2 亲手跑一次 pprof

写一个最小服务,注册一个耗 CPU 的 /work,然后把 pprof 挂上:

import (
	"fmt"
	"log"
	"net/http"
	_ "net/http/pprof"
)

func main() {
	http.HandleFunc("/work", func(w http.ResponseWriter, r *http.Request) {
		sum := 0
		for i := 0; i < 1_000_000; i++ {
			sum += i
		}
		fmt.Fprintf(w, "sum=%d\n", sum)
	})
	log.Fatal(http.ListenAndServe("127.0.0.1:6060", nil))
}

启动后先看概览页,确认 profile 都已注册:

$ curl -s http://127.0.0.1:6060/debug/pprof/
<html>
<head>
<title>/debug/pprof/</title>
...
Types of profiles available:
<table>
<thead><td>Count</td><td>Profile</td></thead>
<tr><td>1</td><td><a href='allocs?debug=1'>allocs</a></td></tr>
<tr><td>0</td><td><a href='block?debug=1'>block</a></td></tr>

再抓一段 2 秒的 CPU profile 存到文件:

curl -s -o cpu.prof "http://127.0.0.1:6060/debug/pprof/profile?seconds=2"

文件很小(本次 1932 字节),因为采样期大部分时间进程是空闲的。用 go tool pprof 打开它,-top 直接列出耗时最多的函数:

go tool pprof -top -nodecount=6 ./profdemo cpu.prof
File: profdemo
Type: cpu
Duration: 2.03s, Total samples = 50ms ( 2.47%)
Showing nodes accounting for 50ms, 100% of 50ms total
      flat  flat%   sum%        cum   cum%
      20ms 40.00% 40.00%       20ms 40.00%  main.main.func2
      20ms 40.00% 80.00%       20ms 40.00%  syscall.rawsyscalln
      10ms 20.00%   100%       10ms 20.00%  internal/runtime/atomic.(*UnsafePointer).StoreNoWB (inline)
         0     0%   100%       20ms 40.00%  bufio.(*Writer).Flush

flat 是函数自身耗时,cum 是含被调用的累计耗时。main.main.func2 就是那个忙循环,一眼就能定位。除了命令行,还能起一个带火焰图的 Web UI:

go tool pprof -http=:8081 ./profdemo cpu.prof

16.2.3 五种 profile 怎么选

pprof 提供的 profile 不止 CPU。选错类型等于白跑:

profile抓什么典型问题
profileCPU 时间分布请求变慢、CPU 打满
heap堆分配与存活对象内存持续上涨、OOM
goroutine所有 goroutine 栈goroutine 泄漏、死锁
block阻塞在同步原语的时间锁竞争
mutex互斥锁持有情况热点锁

block 和 mutex 默认是关闭采样的,要先打开:

runtime.SetBlockProfileRate(1)
runtime.SetMutexProfileFraction(1)

TaskAPI 是并发 store(第 11 章的 RWMutex),上线前用 mutex profile 确认锁没有成为瓶颈,是个好习惯。

16.2.4 goroutine 泄漏:pprof 的主场

goroutine 泄漏是 Go 服务最常见的线上问题之一,而 goroutine profile 是最直接能抓出它的工具。第 10 章我们写过 worker pool,如果 close(ch) 漏了,worker 会永远卡在 <-ch。抓一份快照,数一下同栈的 goroutine 有多少个,就能定位:

curl -s "http://127.0.0.1:6060/debug/pprof/goroutine?debug=1"

debug=1 返回人类可读的文本栈;不带参数则返回可被 go tool pprof 解析的二进制。用 -top 看,泄漏点会呈现为同一个函数成百上千次。

16.2.5 expvar:业务计数器的标准出口

pprof 看的是运行时,expvar 看的是应用自己的数字。它把 expvar.Int、expvar.Float、expvar.String 等变量以 JSON 形式导出到 /debug/vars:

import "expvar"

var (
	reqTotal = expvar.NewInt("http_requests_total")
	started  = expvar.NewString("started_at")
)

expvar.Int.Add 是原子操作,可以在 handler 里放心自增。注册后访问 /debug/vars:

$ curl -s http://127.0.0.1:6060/debug/vars
{
"cmdline": ["/tmp/gowork/profdemo/profdemo"],
"http_requests_total": 1,
"memstats": {"Alloc":288496,"TotalAlloc":288496,"Sys":8145160,...}
}

注意 cmdline 和 memstats 是 expvar 自动附带的——前者是启动命令行,后者是完整的 runtime.MemStats。你注册的变量和它们平级。

16.2.6 自定义导出函数

有时要导出的是「算出来的值」而非累加值,用 expvar.Func:

expvar.Publish("goroutines", expvar.Func(func() any { return 12 }))

每次请求 /debug/vars 时函数会被调用,返回值序列化成 JSON。可以拿它导出缓存命中率、队列深度、当前活跃连接数等派生指标。但要注意:函数会在每次抓取时执行,别在里面做慢操作。

16.2.7 离线程序:runtime/pprof

服务端用 net/http/pprof 最方便,但命令行工具、批处理程序没有 HTTP 服务,这时用 runtime/pprof 手动写文件:

f, _ := os.Create("cpu.prof")
defer f.Close()
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
// ... 被测逻辑 ...

跑一个忙循环程序,实测得到:

$ go run .
sum = 59999997
$ go tool pprof -top -nodecount=5 . cpu.prof
Type: cpu
Duration: 201.66ms, Total samples = 10ms ( 4.96%)
      flat  flat%   sum%        cum   cum%
      10ms   100%   100%   10ms   100%  main.busy (inline)
         0     0%   100%       10ms   100%  main.main

main.busy 被内联了,pprof 仍能标出它。内存则用 pprof.WriteHeapProfile(f) 或 pprof.Lookup("heap").WriteTo(f, 0)。

16.2.8 把观测端点接进 TaskAPI

TaskAPI 的业务路由在自定义 mux 上,pprof 在默认 mux 上。用两个端口分开:

func main() {
	// 业务端口
	go func() { _ = http.ListenAndServe(":8080", businessMux) }()
	// 观测端口,只绑本机或内网
	_ = http.ListenAndServe("127.0.0.1:6060", nil) // nil 用默认 mux
}

安全提醒:pprof 端点会泄露内存内容与调用栈,绝不能暴露到公网。生产环境的正确做法是绑 127.0.0.1 由运维通过 SSH 隧道访问,或放在独立的、有鉴权的内网端口上。

16.2.9 代价与边界

  • CPU profile 会真的采样:profile?seconds=30 期间进程会有额外开销,别在高负载时抓 30 秒。
  • heap 是快照不是趋势:要判断内存是否泄漏,得隔一段时间抓两次对比。
  • expvar 是全局的:多个包注册同名变量会 panic,命名要带前缀(如 taskapi_)。
  • 不要在生产常开 block/mutex 全采样:SetBlockProfileRate(1) 会拖慢高并发路径。

小结

  • _ "net/http/pprof" 一行导入,默认 mux 上就有五个端点;业务用自定义 mux 时要单独开观测端口。
  • CPU 用 profile、内存用 heap、泄漏用 goroutine、锁竞争用 block/mutex,选对类型才有用。
  • go tool pprof -top 定位热点,-http 看火焰图;离线程序用 runtime/pprof 写文件。
  • expvar 把业务计数器以 JSON 导出,expvar.Func 可导出派生指标。
  • pprof 绝不能裸奔在公网。

pprof 是给人排查用的原始工具。但真正上线后,运维系统需要的是标准化的健康与指标端点——/healthz 给探针、/metrics 给监控。下一节我们把这两类端点按规范做进 TaskAPI。

阅读导航:上一节:16.1 log/slog 结构化日志 · 下一节:16.3 健康检查与指标端点 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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