记录内存泄漏的问题排查

背景:
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方法。

posted @   李若盛开  阅读(80)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· DeepSeek 开源周回顾「GitHub 热点速览」
点击右上角即可分享
微信分享提示