记一次 pprof 分析解决 golang 项目内存泄漏的过程

前言

我在 blued 工作期间,负责过一个跟谷歌推送相关的子项目,原先是 node 写的,然后用 golang 重构,这个项目可以算是我第一个有一定并发量的程序,前前后后也碰到过不少问题。

比如刚开始碰到的问题是过度的并发问题,原先的设计是从 rabbitmq 消费一条消息,解析后直接丢给一个协程处理,每个协程最后主要是给 firebase 云消息服务 post 一个请求,正常情况下单机几千个 goroutine 并发,各项指标都正常;
但是发生网络波动(因为 rabbitmq 集群在内地,谷歌推送服务器在墙外,这种现象每天都有可能出现)时,可能 rabbitmq 那边会堆积一部分消息,如果此时一下子推过来,本来处理几千的量变成处理几万的量,服务的各项指标就明显变差了,后面大批任务阻塞时间不断增长。
后面引入一个消息缓冲队列用来缓冲意外暴增的消息,再实现一个工作队列不断去消息池里取消息消费,这个工作队列可以控制 goroutine 的并发,同时减少 goroutine 的频繁创建和销毁,通过如此处理,即使发生上诉情况,服务也可以继续稳定运行,每个请求的响应时间也都维持在较低水平线上。

之后碰到的问题就是内存泄漏,这个项目在线上运行时,内存占用总会缓慢地往上涨,一开始找不到原因,临时的处理办法是在 pm2 的参数里设置 “max_memory_restart”: “1024M”,当程序内存占用触发此值便重启一下进程。这个问题一直搁着放了很久,后面这个项目要准备交接给别人了,我想着还是别把坑留给别人了,自己解决一下吧!
然后空闲时间尝试使用一下 golang pprof 性能分析工具分析一下程序到底哪出问题了,好在最后是解决问题了。

grafana-before

图上是内存泄漏时内存增长的情况

添加 pprof 模块

在这之前我还没使用过这个工具,看网上介绍又是这个函数又是那个函数的,处理好像挺麻烦的,实际上现在最新版本的 go tool 分析工具已经很人性化了,使用其实比我想象的方便。

首先在入口文件里添加代码引入 pprof 模块:

package main

import (
    "log"
    "net/http"
    "os"
    "runtime"

    _ "github.com/mkevac/debugcharts" // 可选,添加后可以查看几个实时图表数据
    _ "net/http/pprof" // 必须,引入 pprof 模块

    "git.blued.cn/wangriyu/go-fcm/service"
)

func main() {
    if os.Getenv("debugPProf") == "true" {
        go func() {
            // terminal: $ go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap
            // web:
            // 1、http://localhost:8081/ui
            // 2、http://localhost:6060/debug/charts
            // 3、http://localhost:6060/debug/pprof
            log.Println(http.ListenAndServe("0.0.0.0:6060", nil))
        }()
    }

    service.Start()
    select {}
}

然后在 pm2 的启动文件里添加一个环节变量 debugPProf,方便使用时开启:

{
    "name": "FCM",
    "script": "main",
    "error_file": "/data/logs/pm2/FCM/error.log",
    "out_file": "/data/logs/pm2/FCM/out.log",
    "log_date_format": "YYYY-MM-DD HH:mm:ss Z",
    "instances": 1,
    "exec_mode": "fork",
    "merge_logs": true,
    "max_memory_restart": "1024M",
    "env": {
        "fcm_env": "dev",
        "debugPProf": false
    }
}

使用 pprof

使用时将 debugPProf 设为 true,启动程序,通过上面设置的端口即可访问 pprof 的数据

打开 http://$HOSTIP:6060/debug/pprof/ : 这个页面包含总览信息,我不做介绍了,网上都有,我主要讲讲内存分析这块的

点击 heap 进入 http://$HOSTIP:6060/debug/pprof/heap?debug=1 界面,这里有内存分配信息,然后拉到最下面(mac 可以使用快捷键 CMD+↓ 跳到底部)

web-heap

heapSys 是占用的物理内存大小,也就是我们直观上看到进程占用的大小;
heapIdle 是当中空闲的内存大小;heapInuse 才是此时程序真正使用的内存;nextGC 是下次触发 GC 的内存阀值

如果你添加了 “github.com/mkevac/debugcharts”,可以打开 http://$HOSTIP:6060/debug/charts

debugChart

分析工具

在 terminal 中使用 go tool pprof http://$HOSTIP:6060/debug/pprof/heap 可以进入 pprof 分析工具,比如输入 top 可以显示靠前的几项

go tool pprof 可以带上参数 -inuse_space (分析应用程序的常驻内存占用情况) 或者 -alloc_space (分析应用程序的内存临时分配情况)

现在 go tool 可以直接可视化结果,只需要带上 -http=:8081 参数即可

$ go tool pprof -http=:8081 http://$HOSTIP:6060/debug/pprof/heap

如果 Could not execute dot,可能需要安装 graphviz 这个工具

之后打开 http://$HOSTIP:8081/ui 即可,里面包含 dot 格式的图、火焰图、top 列表、source 列表等

dot

flamegraph

peek

top

排查内存泄漏的过程

第一遍跑了一段时间程序后查看结果发现是 amqp 库的一个函数 recvContent 占用内存最高

source

我仔细看了下源码,发现这个函数使用用于解析消息体的,但是为什么这里泄漏内存不清楚,然后去 github issue 上搜了一下,发现有不少类似的情况,比如 https://github.com/streadway/amqp/issues/272

然后出现问题的共同点是我们用了 autoAck 机制,rabbitmq server 会自动做 ack 然后有多少消息都推给 cient,我那个项目因为要求处理尽可能快、消息不能延迟太多,有部分消息丢失是允许的,所以一开始选择的是 autoAck,发现这个问题后我改成了 manualAck;

改完这个后第二天一看发现还是有内存泄漏,但这回出问题不是 recvContent 函数,而是 notifyClose 函数,因为在代码中实现了一层重连机制,用到这个函数用于监听连接是否断开

dot-2

最后我把 notifyClose 去掉用其他方法判断连接断开,这样才解决了泄漏问题

grafana-after

虽然解决了问题,但是根本原因还是在源代码里,当时因为时间原因就没深究下去了…

文章目录
  1. 前言
  2. 添加 pprof 模块
  3. 使用 pprof
    1. 分析工具
  4. 排查内存泄漏的过程