后台填坑记——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 泄露中都会看到有,并且都会是大头,既然是大头,那这个函数是啥作用:看下源码:
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()
参考:
发布评论