admin管理员组

文章数量:1438709

后台填坑记——Golang内存泄漏问题排查(一)

(太长不看版)


DeepSeek生成的文章摘要

文章摘要

本文记录了Golang服务内存泄漏问题的排查过程,通过多维度工具分析和代码溯源,最终定位到Redis/SQL连接池未正确关闭导致的协程泄漏问题。文章系统性地展示了从现象分析到根因定位的全流程方法,对后台服务稳定性维护具有实践指导意义。

文章重点总结

问题现象:

服务内存缓慢增长,初步堆内存分析(heap pprof)未发现明显异常

Goroutine分析显示大量runtime.gopark等待状态协程,暗示协程泄漏可能

排查过程:

双时间切片对比:通过-base参数对比不同时段pprof文件,放大增量变化

Goroutine专项分析

1.发现(*ConnPool).reaper相关泄漏线索

2.结合CPU分析无果后转向源码审查

关键代码溯源:

SQL库OpenDB方法创建connectionOpener协程未关闭

Redis连接池NewConnPool启动reaper协程未终止

技术原理:

Golang内存泄漏多源于协程泄漏(未进入GC回收的活跃对象)

连接池实现机制:

1.生产者协程监听连接创建请求(channel)

2.需显式调用Close()释放资源

解决方案:

1.确保正确调用client.Close()关闭Redis连接

2.规范SQL连接池的生命周期管理

工具使用亮点

1.多维度pprof组合分析(heap/goroutine/cpu)

2.Peek视图解析调用关系占比

3.差异化对比(-base)技术定位增量问题

4.源码级逆向工程验证假设

评价

本文真实还原了线上问题排查的全场景,展现了"工具分析+逻辑推理+源码验证"的完整技术闭环。文章包含大量可复现的操作命令和代码分析片段,为Golang服务稳定性维护提供了标准的排查范本。其"从现象到本质"的推导过程对培养工程思维尤具参考价值。

|

|

|

|

|

|

(长文版)


原文如下

近期的项目中,发现了一个奇怪的现象,某一个服务的内存似乎在缓慢的泄漏,内存监控指标如下:

golang的服务,线上看内存问题标准组件——pprof搞起

(一)抓一个内存切片

首先看了下heap,在线上服务执行:

代码语言:bash复制
curl -o heap.pprof "http://localhost:6668/debug/pprof/heap?debug=0"

获取到了heap.pprof文件,然后在本地网页打开:

代码语言:bash复制
go tool pprof -seconds=10 -http=:9998 Desktop/heap.pprof

因为是疑似内存泄漏,所以SAMPLE选择:inuse_space

结果如下:

猛的一看貌似没啥重要的信息,最多的内存都是正常的业务逻辑需要,这里一般来说很难直接看出来原因,内存泄漏的不多,所以很可能在insue_space里面占了很小的一块

(二)抓两个内存切片对比

既然占比很小,那就按照“时间换空间”的思路放大问题,隔一个合适的时间间隔分别抓一个内存切片,然后用diff的方式查看:

过了几天(这里泄漏比较慢),再抓一个pprof

代码语言:bash复制
curl -o heap2.pprof "http://localhost:6668/debug/pprof/heap?debug=0"

然后在本地命令行执行:

代码语言:bash复制
go tool pprof -base -http=:9997 Desktop/heap.pprof Desktop/heap2.pprof

如果所示:

可以看到绿色的为相对之前的内存切片减少的,而红色是相比之前的内存切片增加的,可以看到,shtrings.genSplit和NewCommonRedisUnit这里有少量的内存,加在一起才1Mb左右,而整体的内存增长超过了3Mb,看起来这里并没有找到原因!

(三)抓一个Goroutine看看

既然内存增长在堆中不明显,那可能内存泄漏出现在栈中!抓个Goroutine看看

Golang的内存泄漏绝大部分出现在Goroutine泄露上,也就是协程泄漏,这里的泄漏并不会在Heap中有体现

抓下协程情况

代码语言:bash复制
curl -o goroutine.pprof "http://localhost:6668/debug/pprof/goroutine?debug=0"

然后在本地命令行执行:

代码语言:bash复制
go tool pprof -http=:9996 Desktop/goroutine.pprof

结果如下:

好消息是,类别倒是少且清晰;

坏消息是,都是系统函数!

不过,这里有一个很重要的提示点:runtime.gopark 这个函数,其实如果解过几个泄漏的问题的话,就会主导,几乎在所有的 goroutine 泄露中都会看到有,并且都会是大头,既然是大头,那这个函数是啥作用:看下源码:

代码语言:go复制
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
	mp := acquirem()
	gp := mp.curg
	status := readgstatus(gp)
	mp.waitlock = lock
	mp.waitunlockf = unlockf
	gp.waitreason = reason
	mp.waittraceev = traceEv
	mp.waittraceskip = traceskip
	releasem(mp)
	
	mcall(park_m)
}

该函数主要作用有三大点:

  • 调用 acquirem 函数:- 获取当前 goroutine 所绑定的 m,设置各类所需数据。 - 调用 releasem 函数将当前 goroutine 和其 m 的绑定关系解除
  • 调用 park_m 函数:- 将当前 goroutine 的状态从 _Grunning 切换为 _Gwaiting,也就是等待状态。 - 删除 m 和当前 goroutine m->curg(简称gp)之间的关联。
  • 调用 mcall 函数,仅会在需要进行 goroutiine 切换时会被调用:- 切换当前线程的堆栈,从 g 的堆栈切换到 g0 的堆栈并调用 fn(g) 函数。 - 将 g 的当前 PC/SP 保存在 g->sched 中,以便后续调用 goready 函数时可以恢复运行现场。

熟读了其源码后,我们可得知该函数的关键作用就是将当前的 goroutine 放入等待状态,这意味着 goroutine 被暂时被搁置了,也就是被运行时调度器暂停了。

参考链接:/

那谁把这里的协程释放了呢?

选Peek视图看下:

这里peek视图中每一栏都表示对最下面这一行的调用占比,比如第二个图第一栏的4965中,有4963被chan.go:447调用,有2个被chan.go:442调用,所以第一个才是重点,当然在这里偶尔可能出现的调用方也不是完全没用,算是一个提示,当然,这里与根因的关系并不强一致

看到一个线索,有一半的协程是被gopkg.in/redis.v5/internal/pool.(*ConnPool).reaper引用

综合来看: 是redis和sql的连接没有及时关闭,导致多出来的协程睡眠了,导致的泄漏问题!

(四)抓两个Goroutine对比看看

当然了,既然是内存泄漏要做实,还需要隔一个合适的时间间隔分别抓一个协程切片,然后对比看看

抓下协程情况

代码语言:bash复制
curl -o goroutine2.pprof "http://localhost:6668/debug/pprof/goroutine?debug=0"

然后在本地命令行执行:

代码语言:bash复制
go tool pprof  -http=:9995 -base Desktop/goroutine.pprof  Desktop/goroutine2.pprof

结果如下:

做实了!

(五)抓GPU对比看看

问题基本定型,但现在还存在一个问题

为什么协程都是系统函数和第三方库呢?为什么没有堆栈呢?

其实这也是Goroutine的不足,因为很多时候这里只有调用者,而没有完整的调用链

那哪里有调用链呢?

pprof的GPU!

搞起!

代码语言:bash复制
curl -o CPU.pprof "http://localhost:6060/debug/pprof/profile?seconds=900"

然后在本地命令行执行:

代码语言:bash复制
go tool pprof -http=:9994 Desktop/CPU.pprof

结果如下:

依然没有堆栈!

(六)查代码!

既然堆栈这里没有线索,那就只有一种可能了,方法内创建协程了!

这里!

在Golang的SQL系统库中

代码语言:go复制
func OpenDB(c driver.Connector) *DB {
    ctx, cancel := context.WithCancel(context.Background())
    db := &DB{
        connector: c,
        openerCh:  make(chan struct{}, connectionRequestQueueSize),
        lastPut:   make(map[*driverConn]string),
        stop:      cancel,
    }

    go db.connectionOpener(ctx)

    return db
}

重点在上文的10行,可以看到OpenDB这里使用新起协程的方式开启了connectionOpener,所以goroutine和CPU都抓不到,因为不在一个堆栈中

connectionOpener源码如下:

代码语言:go复制
func (db *DB) connectionOpener(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return
        case <-db.openerCh:
            db.openNewConnection(ctx)
        }
    }
}

// Open one new connection
func (db *DB) openNewConnection(ctx context.Context) {
    // maybeOpenNewConnections has already executed db.numOpen++ before it sent
    // on db.openerCh. This function must execute db.numOpen-- if the
    // connection fails or is closed before returning.
    ci, err := db.connector.Connect(ctx)
    db.mu.Lock()
    defer db.mu.Unlock()
    if db.closed {
        if err == nil {
            ci.Close()
        }
        db.numOpen--
        return
    }
    if err != nil {
        db.numOpen--
        db.putConnDBLocked(nil, err)
        db.maybeOpenNewConnections()
        return
    }
    dc := &driverConn{
        db:         db,
        createdAt:  nowFunc(),
        returnedAt: nowFunc(),
        ci:         ci,
    }
    if db.putConnDBLocked(dc, err) {
        db.addDepLocked(dc, dc)
    } else {
        db.numOpen--
        ci.Close()
    }
}

可以看到sql库的连接池的实现机制其实还是蛮复杂的,生产者connectionOpener goroutine 阻塞监听 openerCh 创建连接放入连接池。当请求来时,先查询连接池有没有空闲连接,如果没有空闲连接则创建

这里为了避免提交建连,所以用了懒连接的方式,从而选择了异步协程的方式,所以如果OpenDB之后,没有及时调用Close()方法,则会导致这个协程会一直增多,进而泄漏

redis同理,也是需要在合适的时机调用close()方法即可

代码语言:go复制
func NewConnPool(dial dialer, poolSize int, poolTimeout, idleTimeout, idleCheckFrequency time.Duration) *ConnPool {
    p := &ConnPool{
        dial: dial,

        poolTimeout: poolTimeout,
        idleTimeout: idleTimeout,

        queue:     make(chan struct{}, poolSize),
        conns:     make([]*Conn, 0, poolSize),
        freeConns: make([]*Conn, 0, poolSize),
    }
    if idleTimeout > 0 && idleCheckFrequency > 0 {
        go p.reaper(idleCheckFrequency)
    }
    return p
}

第13行这里也是一个新协程,这里这个reaper是一个定时任务,定期清理过期的conn

作为使用方调用的方法是:

代码语言:go复制
client = redis.NewClient(op)

所以只需要对合适时机调用:

代码语言:go复制
client.close()

参考:

本文标签: 后台填坑记Golang内存泄漏问题排查(一)