记录内存泄漏的问题排查
背景:
Golang服务通过docker部署在了线上环境中,但是该服务经常出现内存爆满导致容器自动被kill而退出的现象。因为是线上环境,排查问题会耗时很长,因此先在线上服务的容器上加了个–restart=always的参数,这样可以让容器退出后自动重启,继续进行任务。
但在排查内存爆满问题时,线上环境有严格的网络限制,较难用golang的pprof工具进行内存分析,因此将其转移到本地环境中进行事故模拟。
排查过程:
1. 本地模拟docker环境进行排查
在本地运行时,首先模拟了docker环境,通过docker stats 命令实时观测其内存变化,分别测试了发送http请求成功和发送http请求失败的两种情况,发现在http请求失败时内存不会增加,但http请求成功时,内存会逐渐增加,初步推断可能是http请求时出现的问题。
2. 代码中加入runtime.GC(),在监测内存超过300MB时,自动进行GC清理
根据第一步中的结果,初步推断可能是在http请求时生成了大量对象,但GC清理速度较慢,导致内存溢出的问题出现,因此,在代码中加入了一个goroutine,用于实时监测内存状态,发现堆内存占用超过300MB时,则进行runtime.GC()清理。
加入runtime.GC()后,的确起到了一些效果,但效果不佳,通过docker stats命令观测其内存变化从 200MB 到 600 MB 之间不断变化,初步认为问题已经解决。
但容器在运行了一段时间后,又出现了自动退出的情况,且当时的内存占用仅为600MB左右,并未超过设定阈值 1 GB,问题仍需排查。
3. 从docker容器中拿出来,在本地运行
由于docker容器自动退出时,很多runtime的堆栈日志例如fatal错误不会输出到控制台,并且在docker容器内将这种错误进行导出的配置也相当麻烦,因此把服务从容器中拿出来,直接在本地运行,观测其发生的现象。
在本地运行时,不观测不知道,一观测吓一跳。
在高并发下,起初程序运行并没有问题,但是通过top命令观测其内存变化,竟然逐渐增大至高达1.5GB,并且runtime.GC()并不其任何作用,说明程序中有大量内存无法被GC清理,说明肯定有很多对象的引用一直存在。并且在本地运行时,还观测到,日志报错中出现了错误:
An operation on a socket could not be performed because the system lacked sufficient buffer space or because a queue was full.
该错误表明,大量的http请求连接仍然处于keep-alive状态,并没有被消灭。那么问题出在什么地方?
代码:
func httpSend(dto *Dto, payload *bytes.Reader) error {
response, err := http.Post(url, "application/json", payload)
if err != nil {
log.Printf("ERROR: %s\n", err)
return err
}
log.Printf("HTTP post success, response.StatusCode is:%d\n", response.StatusCode)
if response.StatusCode == http.StatusOK {
log.Println("HTTP post success, response code is HTTP.StatusOK")
return nil
} else {
return errors.New("send failed")
}
}
原因:
发现并没有Close()这个方法,原来是response.Body没有关闭,response.Body是属于 io.ReadCloser 对象,该对象会占用文件描述符socket,当该对象不被主动关闭时,随着时间的推移,肯定会造成系统中socket数量不够用,进而导致内存泄漏,容器崩溃,甚至服务器卡死的情况!
因此,if resp != nil {}的检查语句是没必要的。应该坚持最初的解决方案,只有在没有错误的情况下才在延迟函数中关闭主体。
注意:在服务端,当实现一个HTTP handler时,不必关闭请求,因为它会被服务器自动关闭。
关闭资源以避免泄露不仅仅和HTTP的响应体有关。通常来说,所有实现了io.Closer接口的结构体都需要在某个时刻被关闭。该接口包含唯一的一个Close方法:
type Closer interface {
Close() error
}
总结:
本次内存泄漏的原因是在http请求时,没有主动关闭返回体中的response.Body导致了socket文件被大量占用,进而导致内存泄漏的问题出现。根据此次问题排查,以后在每一次http请求时,不管response.Body的内容是否被使用,请求的返回体都需要主动关闭,否则就会造成严重的内存泄漏,甚至服务器卡死!
在编程时,这些资源必须在代码的中的某个地方被关闭释放,以避免造成资源不足而泄露。但开发人员在编写代码时往往会忽略关闭已打开的资源,从而因资源不足导致程序出现异常。
释放有限的资源以避免泄露,一般有以下可能的场景:
1)defer resp.Body.Close()
2)sql.Rows
sql.Rows是用于sql查询结果的结构体。因为该结构体实现了io.Closer接口,所以它必须被关闭。也可以像下面这样使用延迟函数来处理关闭逻辑:
db, err := sql.Open("postgres", dataSourceName) ①
if err != nil {
return err
}
rows, err := db.Query("SELECT * FROM MYTABLE") ②
if err != nil {
return err
}
defer rows.Close() ③
// Use rows
db, err := sql.Open("postgres", dataSourceName)
① if err != nil { return err } rows, err := db.Query("SELECT * FROM MYTABLE")
② if err != nil { return err } defer rows.Close()
③ Use rows
如果Query的调用没有返回错误,那就需要及时的关闭rows。
3)os.File
os.File代表一个打开的文件标识符。和sql.Rows一样,最终也应该的被关闭:
f, err := os.Open("events.log") ①
if err != nil {
return err
}
defer f.Close() ②
// Use file descriptor
① 打开文件
② 关闭文件标识符
当所在的函数块返回时我们又一次使用defer来调度Close方法。
注意:正在关闭的文件不会保证文件内容已经被写到磁盘上。事实上,写入的内容可能留在了文件系统的缓冲区上,还没有被刷新到磁盘上。如果持久化是一个关键因素,应该使用Sync()方法来把缓冲区上的内容刷到磁盘上。
4)压缩实现
压缩的写入和读取实现也需要被关闭的。事实上,其创建的内部缓冲区也是需要被手动释放的。例如:gzip.Writer
var b bytes.Buffer ①
w := gzip.NewWriter(&b) ②
defer w.Close() ③
① 创建一个缓冲区
② 创建一个新的gzip writer
③ 关闭 gzip.Writer
gzip.Reader具有同样的逻辑:
var b bytes.Buffer ①
r, err := gzip.NewReader(&b) ②
if err != nil {
return nil, err
}
defer r.Close() ③
① 创建一个缓冲区
② 创建一个新的gzip writer
③ 关闭gzip.Writer
综上,有限的资源必须在正确的时间和特定的场景下被关闭。有时是否需要资源不是很明确,只能通过阅读相关的API文档或实际实践来决定。然而必须要谨慎,如果一个结构体实现了io.Closer接口,就必须要在最后调用Close方法。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· DeepSeek 开源周回顾「GitHub 热点速览」