本文字数:5295 字
精读时间:10 分钟
也可在 5 分钟内完成速读
问题起因
这几天有一个 Go API service 经过定时监控发现占用的内存不断上涨,内存从初始的 70M 一直上升到超过 1G 直到吃光内存退出,基本上就可以断定是存在内存泄露的问题了,但是因为自带垃圾回收的语言出现内存泄露的情况较少,如果存在那一定是大问题,因此有了下文详细的排查过程,为日后处理此类问题积累经验。
分析排查
goroutine泄露
之前就有听说过一句名言:Go 中的内存泄露十有八九都是 goroutine 协程泄露,是否真的如此呢?
对 runtime.NumGoroutine()
的定时查询可以帮助我们进行判断,或者线上开启了 pprof 的话访问 /debug/pprof
就可以看到 goroutine 的数量,我使用了 expvar 来定时暴露协程的总数信息,在本地对线上数据定时进行抓取。
// server.go
// 将统计数据通过 expvar.Handler 暴露到 HTTP 服务中
mux := http.NewServeMux()
mux.Handle("/debug/vars", expvar.Handler())
// main.go
func sampleInterval() chan struct{} {
done := make(chan struct{})
go func() {
ticker := time.NewTicker(1 * time.Second)
numG := expvar.NewInt("runtime.goroutines")
defer ticker.Stop()
for {
select {
case <-ticker.C:
numG.Set(int64(runtime.NumGoroutine()))
case <-done:
return
}
}
}()
return done
}
// 本地运行
// curl http://host:port/debug/vars
{
"runtime.goroutines": 48
}
done chan 会被返回,以便我们关闭系统时可以进行优雅退出。通过一段时间的抓取和图表分析,发现并不是 goroutine 的泄露,如果是,那么可以访问 /debug/pprof/goroutine?debug=1
在线上查看每个 G 的状态,然后再来做进一步的分析,但显然通过排除法,这不是这次事故的问题所在。
长期持有引用
那么新的问题也就很自然地浮现出来:如果 G 的数量有上升然后总是能够自动回落,说明非常驻型的 G 都能够正常运行结束,G的阻塞等待导致迟迟无法退出造成的泄露是可以排除了,那么我们是否能够认为内存的泄露就完全与 G 无关了?答案是不能,因为我们不能确定 G 是否会导致我们的一些引用对象被一直持有,在标记 Mark 阶段的时候这些依然被持有的对象肯定是不能被垃圾回收的,但它因为某些原因一直被持有而且还伴随着新的内存分配,这是导致内存不断上涨的第二大元凶。
所以,随着问题落到了对象或者是对象群的内存分配上了,这个问题可以是某个 大的 slice 一直不断添加元素却一直在对象的生命周期中被引用,也可以是某对象中持有其他对象的指针,类似链表状的引用关系,如果这个引用链不断增长,那么同样也会造成内存的泄露。
使用 pprof 采样分析堆内存
通过获取 pprof heap profile,我们能够在线上直接生成当前堆内存分配的快照文件并通过 .pb.gz 压缩包的形式持久化到本地供我们作进一步的离线分析。
go tool pprof http://[domain]/debug/pprof/heap
使用命令行就能够将堆内存的快照以及各种统计数据都持久化到本地进行分析,进入到 pprof 交互式命令行。这边我直接在线上隔一段时间分别抓取了两次快照到本地来分析:
File: binary
Type: inuse_space
Time: Feb 27, 2020 at 12:06pm (CST)
(pprof) top
Showing nodes accounting for 44174.92kB, 93.50% of 47247.81kB total
Showing top 10 nodes out of 114
flat flat% sum% cum cum%
35842.64kB 75.86% 75.86% 35842.64kB 75.86% myfuncA
1825.78kB 3.86% 79.73% 1825.78kB 3.86% github.com/mozillazg/go-pinyin.init
1805.17kB 3.82% 83.55% 2354.01kB 4.98% compress/fl