本节把 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=30 | CPU 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 | 抓什么 | 典型问题 |
|---|---|---|
profile | CPU 时间分布 | 请求变慢、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 健康检查与指标端点 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。