线上go内存泄漏分析实战

背景

最近经常发现web服务内存占用持续缓慢增高,从监控看板上看是基本持续递增,但偶尔也有下降趋势(不会下降很明显),对比30天以前没那么高内存占用,最近30天内存占用变化且无明显规律。WEB服务框架是echo框架,引入了pprof包。

监控

线上可以通过监控观察到以下特征:

  1. 实例内存限制1G,看pod内存占用率50%~70%,go process内存占用也不超过700m,其中heap内存占近一半,约350M;

  2. 11.00左右触发扩容后内存占用未见下降,但次日凌晨1.30左右忽然下降;

  3. open fd(socket)数量持续增长;

  4. 服务承载qps是个位数,服务接口逻辑很简单;

  5. cpu占用率5%左右;

  6. 网络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
  1. 不难看出内存占用并不多,不到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
  1. 即便是菜鸟也能看出来大多数协程都是park状态,说明协程并不活跃,但数量惊人约28000个,远超出我们服务个位数qps的预期。
  2. 而且大多数协程(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使用不当导致内存泄漏。于是一般都能想到有两个思路可以分析:

  1. 连接未关闭,比如response未关闭,属于代码问题(大部分人都能想到可能是这个问题)
  2. 连接未能复用,可能是连接池配置问题,也可能是代码问题,导致httpclient创建开销大,占用了较多内存(小部分人能想到这吧)

于是结合pprof中heap和goroutine命令结果的关键字,以及代码中httpclient的实现发现不属于情况1,每个response都有正常关闭。那就可能是问题2,通过代码发现httpclient的使用方式有两种,全局定义中公用一个httpclient,也存在函数中每次新创建httpclient,经验丰富的我立马就想到了最可能是后者出了问题。因为go的httpclient在创建时基本都是默认创建的httpclient连接池,连接池参数中一般都会定义连接存活时间,因此必然是有独立的生命周期的,不随函数执行完成而销毁。

因此通过阅读代码发现,每次创建httpclient实际是创建了一个连接池,但如果只使用httpclient发一次请求,底层会启动两个协程用于read和write。也就是每来一个业务请求,会导致新建一个httpclient连接池和两个协程用于read和write的协程,而且60秒后这三个资源才会真正销毁,因此服务承载的qps不高但协程数那么多。

问题复现

可以通过本地demo复现该问题,主要分三步:

  1. 启动以下demo服务

  2. 访问"http://0.0.0.0:6060/debug/pprof/goroutine?debug=1"观察goroutine数量

  3. 再访问"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)
    }
    }

结论

  1. 是由于HttpClient请求使用不当掉导致的内存泄漏,解决方案很简单:使用全局的HttpClient。
  2. 内存占用持续增高是因为qps虽然是个位数,但是每来一个业务请求,会导致新建一个httpclient连接池和两个协程用于read和write的协程,而且60秒后这三个资源才会真正销毁,因此服务承载的qps不高但协程数那么多。
  3. 为什么偶尔有下降,主要是因为超过60秒没有请求,就不会新建httpclient,老的也会回收。
  4. 重启能降低内存占用,扩容则分两种情况,扩容后qps非常低低于1/60则也可以立即见效,扩容后打到老实例的qps还是超过1/60的话还是会继续增长。

经验和教训

本人在大厂写了三年go,还真没遇到过内存泄漏的问题,也是大姑娘上轿头一回分析go的内存泄漏,由于问题不紧急,前期读了好几篇文章打基础,因此准备的过于充分:Memory leaks in Go - DEV Community

Go语言内存泄漏与Goroutine排查-CSDN博客

Golang pprof使用-CSDN博客

由于刚开始一点思路没有,甚至都不知道pprof是何种神器,毕竟以前只是背过JAVA八股文也分析过JAVA内存泄漏、CPU飙升问题,但还真没背过GO的八股文,现在想想有时候八股文这种基础知识还真的挺有用。

第一次分析还闹过笑话就是,由于研发不能操作线上实例,把这种命令给了运维帮dump线上内存和协程快照:

go tool pprof http://localhost:6060/debug/pprof/heap      # heap profile

但是由于服务是二进制启动(我也不太理解),没办法执行go命令,只能通过curl命令dump并上传到文件服务(命令见上文),研发才能下载到本地分析。

相关推荐
吾当每日三饮五升2 小时前
C++单例模式跨DLL调用问题梳理
开发语言·c++·单例模式
猫武士水星3 小时前
C++ scanf
开发语言·c++
BinaryBardC3 小时前
Bash语言的数据类型
开发语言·后端·golang
Lang_xi_3 小时前
Bash Shell的操作环境
linux·开发语言·bash
Pandaconda3 小时前
【Golang 面试题】每日 3 题(二十一)
开发语言·笔记·后端·面试·职场和发展·golang·go
捕鲸叉4 小时前
QT自定义工具条渐变背景颜色一例
开发语言·前端·c++·qt
想要入门的程序猿4 小时前
Qt菜单栏、工具栏、状态栏(右键)
开发语言·数据库·qt
_院长大人_4 小时前
使用 Spring Boot 实现钉钉消息发送消息
spring boot·后端·钉钉
Elena_Lucky_baby4 小时前
在Vue3项目中使用svg-sprite-loader
开发语言·前端·javascript
土豆凌凌七4 小时前
GO随想:GO的并发等待
开发语言·后端·golang