Go pprof 实战:一个内存泄漏的定位全过程#
线上服务有个诡异现象:内存每 6 小时涨 1G,重启后恢复,如此循环。第一反应是"哪里有缓存没清理",实际定位完发现是 goroutine 泄漏——channel 没人收,goroutine 越积越多,每个都挂着内存。
这篇文章完整复盘 pprof 定位过程。
一、先打开 pprof 入口#
1
2
3
4
5
6
7
|
// 生产安全做法:独立端口 + 内网暴露
import _ "net/http/pprof"
go func() {
// 注意:独立端口,不挂业务端口
http.ListenAndServe("127.0.0.1:6060", nil)
}()
|
生产不建议挂业务端口,pprof 有安全风险(能看内存快照、能触发 GC),独立端口 + 防火墙限制访问。
二、第一轮:确认是不是 goroutine 泄漏#
1
2
3
4
|
# 抓 goroutine 栈
curl http://127.0.0.1:6060/debug/pprof/goroutine?debug=1 > goroutine.txt
# 看 goroutine 总数
head -1 goroutine.txt # goroutine profile: total 23014 ← 数量惊人!
|
线索:正常服务 goroutine 应该在几十到几百,2.3 万个必然泄漏。按栈聚合看是哪段代码:
1
2
|
# 统计每个栈点的 goroutine 数量
grep -A 5 "goroutine" goroutine.txt | sort | uniq -c | sort -rn | head -20
|
发现:大量 goroutine 卡在 waiting on channel——都是同一段代码创建后永远在等 channel 消息。
三、第二轮:heap profile 找内存大头#
1
2
3
4
5
|
# 抓堆快照
curl "http://127.0.0.1:6060/debug/pprof/heap?debug=1" > heap.txt
# 或用 go tool 交互式分析
go tool pprof -http=:8081 http://127.0.0.1:6060/debug/pprof/heap
|
火焰图里看 inuse_space(当前占用)和 alloc_space(累计分配):
- inuse_space 大:驻留内存多(真泄漏或大对象滞留)
- alloc_space 大但 inuse 小:高频临时分配(GC 压力)
发现:inuse_space 峰值集中在某结构体的 []byte 字段,每个 64KB——和 goroutine 泄漏是同一处代码。
四、定位根因#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// 问题代码(简化版)
func processDeliveries(deliveries []Delivery) {
results := make(chan Result, 100) // 缓冲 100
for _, d := range deliveries {
go func(d Delivery) {
r := processDelivery(d)
results <- r // 发送到 channel
}(d)
}
// ❌ 只读了 100 条就 return 了!
for i := 0; i < 100; i++ {
result := <-results
handleResult(result)
}
}
|
根因:deliveries 有 1 万个,但主协程只消费 100 条就返回——剩下 9900 个 goroutine 在 results <- r 上永久阻塞(channel 满了,没人收)。每个 goroutine 挂着 r(含 64KB 的 HTML 内容),内存泄漏。
两处错误:
- goroutine 数 = 任务数(1 万),没有并发上限
- 消费逻辑不完整,channel 没人收
五、修复#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
func processDeliveries(ctx context.Context, deliveries []Delivery) error {
// 1. 并发上限:Worker Pool,而不是每任务一 goroutine
workers := 20
jobs := make(chan Delivery)
results := make(chan Result, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for d := range jobs {
select {
case results <- processDelivery(d):
case <-ctx.Done():
return
}
}
}()
}
go func() {
defer close(jobs)
for _, d := range deliveries {
select {
case jobs <- d:
case <-ctx.Done():
return
}
}
}()
// 2. 消费完所有结果再退出
go func() {
wg.Wait()
close(results)
}()
for r := range results {
handleResult(r)
}
return nil
}
|
修复要点:
- goroutine 有上限(Worker Pool)
- 消费完整(
range results 直到 close)
- ctx 兜底:取消时 goroutine 能退出
六、验证#
1
2
3
4
5
6
|
# 修复后观察
kubectl top pod delivery-service # 内存平稳,不再阶梯式上涨
# 压测回归
go test -race ./... # race 检查
k6 run load.js # 压测 1 小时看内存曲线
|
验收标准:压测 2 小时,内存曲线平稳(涨跌随流量),不再单调递增。
七、预防清单#
pprof 定位泄漏的套路:
- goroutine profile:看总数 + 栈聚合,找"永远在等"的 goroutine
- heap profile:inuse_space 找驻留大头,和 goroutine 栈交叉验证
- 根因:通常是"goroutine 没有退出路径"或"channel 无人消费"
- 修复:并发上限 + 完整消费 + ctx 兜底
- 验证:压测看内存曲线,
-race 检查
Go 服务的内存问题,一半是 goroutine 泄漏。记住一句话:每个 go func 都要回答"它什么时候退出、谁保证它退出"。回答不了的,就是下一个 6 小时涨 1G 的定时炸弹。
微信公众号「福清而不淡」
后端架构 · AI 工程 · 云原生的一线实践,扫码关注,不错过更新。
本文已同步发布到公众号,欢迎留言交流。