背景
最近经常发现web服务内存占用持续缓慢增高,从监控看板上看是基本持续递增,但偶尔也有下降趋势(不会下降很明显),对比30天以前没那么高内存占用,最近30天内存占用变化且无明显规律。WEB服务框架是echo框架,引入了pprof包。
监控
线上可以通过监控观察到以下特征:
-
实例内存限制1G,看pod内存占用率50%~70%,go process内存占用也不超过700m,其中heap内存占近一半,约350M;
-
11.00左右触发扩容后内存占用未见下降,但次日凌晨1.30左右忽然下降;
-
open fd(socket)数量持续增长;
-
服务承载qps是个位数,服务接口逻辑很简单;
-
cpu占用率5%左右;
-
网络IO吞吐量并不高。
分析
由于测试环境不好模拟线上真实请求场景,于是让运维同学执行以下命令拿到pprof文件,第一次和第二次隔5分钟拿是为了对比。heap用于分析内存占用,goroutine是为了分析协程数量。
//立即执行
curl -o heap_20241216_cur.txt http://127.0.0.1:8081/debug/pprof/heap
curl -o goroutine_20241216_cur.txt http://127.0.0.1:8081/debug/pprof/goroutine
//5分钟后执行
curl -o heap_20241216_5min.txt http://127.0.0.1:8081/debug/pprof/heap
curl -o goroutine_20241216_5min.txt http://127.0.0.1:8081/debug/pprof/goroutine
heap
go tool pprof goroutine_20241216_cur.txt
(pprof) top
Showing nodes accounting for 247.95MB, 88.76% of 279.35MB total
Dropped 75 nodes (cum <= 1.40MB)
Showing top 10 nodes out of 99
flat flat% sum% cum cum%
59.23MB 21.20% 21.20% 59.23MB 21.20% bufio.NewWriterSize (inline)
52.70MB 18.86% 40.07% 52.70MB 18.86% bufio.NewReaderSize (inline)
36.42MB 13.04% 53.10% 36.42MB 13.04% encoding/json.(*decodeState).literalStore
34.57MB 12.38% 65.48% 34.57MB 12.38% reflect.mapassign_faststr0
14.51MB 5.20% 70.68% 14.51MB 5.20% net/http.(*Transport).queueForIdleConn
13.51MB 4.84% 75.51% 21.01MB 7.52% net/http.(*Transport).tryPutIdleConn
9.50MB 3.40% 83.21% 125.43MB 44.90% net/http.(*Transport).dialConn
9MB 3.22% 86.43% 9MB 3.22% runtime.malg
6.50MB 2.33% 88.76% 7.50MB 2.69% net/http.(*connLRU).add
- 不难看出内存占用并不多,不到300M,看来问题不算严重,但是bufio、net/http占用内存加起来已经超过200M,超过70%,根据二八定律,基本确定内存如果泄漏,大概率是httpclient有关。
还可以生成图,更直观,命令:
go tool pprof -png -output=go_cur.png heap_20241216_cur.txt
goroutine
命令:go tool pprof goroutine_20241216_cur.txt
(pprof) top
Showing nodes accounting for 27836, 100% of 27838 total
Dropped 93 nodes (cum <= 139)
Showing top 10 nodes out of 14
flat flat% sum% cum cum%
27836 100% 100% 27836 100% runtime.goparkπ
0 0% 100% 13846 49.74% bufio.(*Reader).Peek
0 0% 100% 13846 49.74% bufio.(*Reader).fill
0 0% 100% 13850 49.75% internal/poll.(*FD).Read
0 0% 100% 13853 49.76% internal/poll.(*pollDesc).wait
0 0% 100% 13853 49.76% internal/poll.(*pollDesc).waitRead (inline)
0 0% 100% 13853 49.76% internal/poll.runtime_pollWait
0 0% 100% 13850 49.75% net.(*conn).Read
0 0% 100% 13850 49.75% net.(*netFD).Read
0 0% 100% 13844 49.73% net/http.(*persistConn).Read
- 即便是菜鸟也能看出来大多数协程都是park状态,说明协程并不活跃,但数量惊人约28000个,远超出我们服务个位数qps的预期。
- 而且大多数协程(99%以上)都是和http有关,bufio用于http的rw缓冲,net用于http连接,poll用于缓冲区rw。
还可以生成图,更直观,命令:
go tool pprof -png -output=go_cur.png goroutine_20241216_cur.txt
代码
既然通过heap和goroutine分析到是跟http有关,且服务本身qps并不高,框架本身也不太可能有问题,那就唯一的可能是httpclient使用不当导致内存泄漏。于是一般都能想到有两个思路可以分析:
- 连接未关闭,比如response未关闭,属于代码问题(大部分人都能想到可能是这个问题)
- 连接未能复用,可能是连接池配置问题,也可能是代码问题,导致httpclient创建开销大,占用了较多内存(小部分人能想到这吧)
于是结合pprof中heap和goroutine命令结果的关键字,以及代码中httpclient的实现发现不属于情况1,每个response都有正常关闭。那就可能是问题2,通过代码发现httpclient的使用方式有两种,全局定义中公用一个httpclient,也存在函数中每次新创建httpclient,经验丰富的我立马就想到了最可能是后者出了问题。因为go的httpclient在创建时基本都是默认创建的httpclient连接池,连接池参数中一般都会定义连接存活时间,因此必然是有独立的生命周期的,不随函数执行完成而销毁。
因此通过阅读代码发现,每次创建httpclient实际是创建了一个连接池,但如果只使用httpclient发一次请求,底层会启动两个协程用于read和write。也就是每来一个业务请求,会导致新建一个httpclient连接池和两个协程用于read和write的协程,而且60秒后这三个资源才会真正销毁,因此服务承载的qps不高但协程数那么多。
问题复现
可以通过本地demo复现该问题,主要分三步:
-
启动以下demo服务
-
访问"http://0.0.0.0:6060/debug/pprof/goroutine?debug=1"观察goroutine数量
-
再访问"http://0.0.0.0:6060/"触发调用"httpwithmetric.DefaultHttpClientWithMetric().Get",再观察2,可发现每次用户请求是秒级返回,但是goroutine数量会增加用户请求数*2,且60秒后才减少,证明上述分析结论正确。
package main
import (
"demo/httpwithmetric"
"fmt"
"net/http"
_ "net/http/pprof"
)func homeHandler(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
code, content, err := httpwithmetric.DefaultHttpClientWithMetric().Get("test", "https://www.baidu.com/")
fmt.Fprintf(w, fmt.Sprintf("Welcome to the home page!%v,%v,%v", code, content, err))
}func main() {
http.HandleFunc("/", homeHandler)
// 开启pprof,监听请求
ip := "0.0.0.0:6060"
if err := http.ListenAndServe(ip, nil); err != nil {
fmt.Printf("start pprof failed on %s\n", ip)
}
}
结论
- 是由于HttpClient请求使用不当掉导致的内存泄漏,解决方案很简单:使用全局的HttpClient。
- 内存占用持续增高是因为qps虽然是个位数,但是每来一个业务请求,会导致新建一个httpclient连接池和两个协程用于read和write的协程,而且60秒后这三个资源才会真正销毁,因此服务承载的qps不高但协程数那么多。
- 为什么偶尔有下降,主要是因为超过60秒没有请求,就不会新建httpclient,老的也会回收。
- 重启能降低内存占用,扩容则分两种情况,扩容后qps非常低低于1/60则也可以立即见效,扩容后打到老实例的qps还是超过1/60的话还是会继续增长。
经验和教训
本人在大厂写了三年go,还真没遇到过内存泄漏的问题,也是大姑娘上轿头一回分析go的内存泄漏,由于问题不紧急,前期读了好几篇文章打基础,因此准备的过于充分:Memory leaks in Go - DEV Community
由于刚开始一点思路没有,甚至都不知道pprof是何种神器,毕竟以前只是背过JAVA八股文也分析过JAVA内存泄漏、CPU飙升问题,但还真没背过GO的八股文,现在想想有时候八股文这种基础知识还真的挺有用。
第一次分析还闹过笑话就是,由于研发不能操作线上实例,把这种命令给了运维帮dump线上内存和协程快照:
go tool pprof http://localhost:6060/debug/pprof/heap # heap profile
但是由于服务是二进制启动(我也不太理解),没办法执行go命令,只能通过curl命令dump并上传到文件服务(命令见上文),研发才能下载到本地分析。