【2025-04-17】Golang内存泄漏/内存占用过高排查整理

537 阅读9分钟

内存泄漏是保留了对对象的非预期引用所造成的。
Goroutine泄露是指程序中启动的Goroutine无法正常退出,长期驻留在内存中,导致资源(如内存、CPU)逐渐耗尽的现象。类似于内存泄漏,但表现为未终止的 Goroutine 的累积。

内存泄漏问题分析

1.slice/string切片泄露

原因:

  • slice底层结构是data,len和cap
  • data是uintptr类型的
  • 做切片之后,底层指向的还是原来的内存空间,旧的没有被释放 解决方案:
  • 申请新的数组做深拷贝

2.不同版本的go内存释放策略不同(怀疑内存泄漏,实际不是内存泄漏,占用居高不下)

mp.weixin.qq.com/s/eCR7cIqvr…
mp.weixin.qq.com/s/7bpzvGLPd…

  • 不同 GO 版本的释放策略
    • 在GO1.12之前,默认选择MADV_DONTNEED策略进行内存回收,通过立即释放物理内存来降低RSS
    • 在GO1.12~GO1.15,默认选择MADV_FREE策略进行内存回收(linux内核版本>=4.5),标记内存为可回收状态但延迟实际释放
    • 在GO1.16及之后,又改回MADV_DONTNEED策略进行内存回收,重新强调RSS指标的实时性。
  • 不同策略的释放机制
    • MADV_DONTNEED:内核立即从进程页表中移除物理页映射(内核将会在合适的时机去释放内存), RSS(常驻内存)将会立即减少。如果再次申请内存,内核会重新分配一块新的空间。
    • MADV_FREE:仅标记内存为可回收,内核在内存压力时才真正回收,分配给其他进程使用。进程的RSS不会立即减少。只能在linux内核版本4.5以上才能使用。
  • 不同策略的实际差别
    • 性能:MADV_FREE最开始只打标记,速度更快,延迟内存的分配和回收,减少物理页操作提升效率,可以提高内存管理的效率,但可能导致内存碎片。
    • 可观测性:MADV_DONTNEED使RSS更真实反映内存使用,而MADV_FREE可能导致监控工具误报内存泄漏
  • 为什么Go放弃使用性能更高的MADV_FREE
    • 指标失真:MADV_FREE使进程RSS长期保持高位,导致K8S误判内存压力,引发不必要的扩缩容操作
    • 资源挤压:混合部署的环境里,Go进程占用大量标记为可回收的内存,导致同主机其他应用内存不足
    • 依赖Linux4.5+版本,Go需要兼容更广泛的部署环境
    • 内存管理失控:容器资源受限,内存很小,可能会导致Go进程还没有释放内存,就触发了OOM
    • 误报内存泄漏:线上观测到内存占用过高,实际可能是延迟释放的特性导致的
    • 性能收益有限:虽然MADV_FREE减少了系统调用次数,但在Go的垃圾回收机制下,内存复用的实际收益未达预期,反而增加了调试成本
    • Go 团队最终选择优先保证内存使用的可观测性和稳定性,而非单纯追求理论性能。MADV_DONTNEED使RSS指标更真实反映内存状态,降低运维复杂度
  • 使用建议
    • 在1.12-1.15强制使用MADV_DONTNEED:设置环境变量GODEBUG=madvdontneed=1
    • 监控优化:1.16之后的版本,可以结合/proc/self/smaps文件中的Pss指标(按比例计算共享内存)进行更精确的内存监控
    • 容器化部署:在k8s环境中,可以调整进程OOM优先级,避免MADV_DONTNEED导致的内存竞争。(即使进程的RSS较低,较低的oom_score_adj仍能使其在内存竞争中存活,避免因短暂内存压力被误杀。)
  • 证明确实是这个问题
    • 通过一个气球程序来占用内存,让操作系统进行内存回收
    • 能被气球程序申请到的内存就是已经不被使用的内存(控制内存容量)(不能是k8s集群)
  • 为什么MADV_FREE会导致内存碎片
    • 物理页分散:标记页的释放时机不确定,内核在内存压力下可能仅释放部分页,导致相邻的空闲页无法合并为大块连续内存,影响后续大内存分配的成功率
    • 清理优先级:内核优先回收未标记的活跃页,导致标记清理的页长期驻留内存,当进程再次申请内存的时候,内核可能被迫分配新的物理页,加剧碎片。

3.goroutine过多/阻塞

mp.weixin.qq.com/s/HdSIC93HM…

3.1 goroutine被阻塞无法释放
  • 互斥锁/waitgroup/select/channel
    • goroutine继续执行的条件无法被触发,会阻塞,协程没结束,协程里该释放的没释放
    • 如果是channel,尽量设置1的缓冲,防止没有消费者了,生产者被阻塞
  • I/O未设置超时时间,导致goroutine一直在等待
    • 在请求第三方网络连接接口时,因网络问题一直没有接到返回结果,如果没有设置超时时间,则代码会一直阻塞。
      • 上游服务设置了keep alive
      • idletimeout、readtimeout没设置
      • 如果上游持有本服务的连接不进行释放,那么服务端会一直维持着这个连接的存在,不进行回收,导致协程泄露
      • 不只是http有这样的问题,thrift或者其他rpc也会这样,如果服务端不对连接设置timeout,就会被上游拖死。
3.2 goroutine申请过多
  • goroutine申请过多,增长速度快于释放速度,会导致goroutine越来越多
    • 比如:一次请求就新建一个client,业务请求量大时client建立过多,来不及释放。
    • cgo一般是单独开一个线程进行处理,是runtime不能管理的

4.for循环里使用time.After

mp.weixin.qq.com/s/KSBdPkkvo…

  • 在for里进行select,执行time.After
  • 如果命中了别的case,这个timer不会丢弃,而是等待时间到之后,才会被GC(变成孤儿)
  • 命中了别的case,执行完之后又到了select开头,导致快速触发newTimer
  • 解决方法:不要在for循环里触发newTimer,而是在外边newTimer,在里边直接<-timer.C
  • golang在1.23版本之后修复了这个问题

5.time.Ticker没有关闭

  • 使用time.Ticker需要手动调用stop方法,否则将会造成永久性内存泄漏。

6.不关闭打开的文件/关闭过晚

  • 有可能文件描述符达到最大限制,无法再打开新的文件或者连接,程序会报too many open files的错误
  • 如果关闭的过晚,也可能会出现处理过程中一直占用着文件描述符。

7.http.Response.Body未关闭

mp.weixin.qq.com/s/RoC3FXLz2… mp.weixin.qq.com/s/MwnArLI04…

  • 不执行ioutil.ReadAll(resp.Body),网络连接无法归还到连接池。

  • 不执行resp.Body.Close(),网络连接就无法被标记为关闭,也就无法正常断开。

  • 尽量避免使用默认的transport,使用有idle connect timeout的transport。

  • 复用连接池里同一个连接的要求

    • 使用同一个transport,用完就关闭。
    • 接收方ip、接收方端口、协议,五元组都相同。
  • (正常情况下我们的代码都会执行 ioutil.ReadAll(),但如果此时忘了 resp.Body.Close(),确实会导致泄漏。但如果你调用的域名一直是同一个的话,那么只会泄漏一个 读goroutine 和一个写goroutine这就是为什么代码明明不规范但却看不到明显内存泄漏的原因。)

8.runtime.SetFinalizer阻塞或循环引用

  • 如果两个对象都设置了runtime.SetFinalizer
  • runtime.SetFinalizer是顺序执行的
  • 如果它们之间存在循环引用、或者某一个有阻塞
  • 会出现泄露

9.defer晚

  • 不论是对文件close,或者ticker进行stop,还是body进行close
  • 尽量用直接写close来替代defer
  • 如果占用连接过久没有释放,也会造成内存泄漏
  • (在执行函数功能,还没执行defer,造成占用资源)

10.map不释放桶

mp.weixin.qq.com/s/ZHk4zjZdP… mp.weixin.qq.com/s/DxLEazUs-…

  • 业务涨量,导致map变大,触发扩容之后,桶会变大
  • 业务涨量结束之后,内存有降低,但还是比最开始高
  • map存储的内容删除,但是桶无法缩减
  • 解决方案
    • 定时进行map深拷贝,但是在复制的时候会用两倍的内存,在内存低峰期可以接受
    • 使用指针进行存储,并不解决会有大量存储桶的问题,而每个存储桶条目将为值保留指针的大小,能明显降低
    • 业务低峰期定时重启
  • (桶:bucket是一个由八个元素组成的固定大小数组。如果插入到已经满了的bucket中(bucket溢出),Go会创建另一个包含八个元素的buckets,并将前一个元素链接到该bucket)

11.错误的循环引用

  • golang的垃圾回收用的是标记清除算法,对于循环引用的情况,只要A和B都无法从根出发进行访问,就会被回收。
  • 但是如果,循环对象中的某个对象,仍然被程序的其他部分引用,这个循环引用永远不会被垃圾回收,出现内存泄漏

内存泄漏示例代码/解决代码

1.slice切片泄露

a指向的还是b原来的内存空间(没被用到的地方没有被释放)

var a []int  
  
func f(b []int) []int {  
 a = b[:2]  
 return a  
}  
  
func main() {  
    ...  
}

做深拷贝

var a []int  
var c []int    // 第三者  
  
func f(b []int) []int {  
 a = b[:2]  
    
  // 新的切片 append 导致切片扩容  
 c = append(c, b[:2]...)  
 fmt.Printf("a: %p\nc: %p\nb: %p\n", &a[0], &c[0], &b[0])  
    
 return a  
}

4.for循环里使用time.After

timer不会丢弃,而是等待时间到之后,才会被GC

func main() {  
    ch := make(chan int10)  
    go func() {  
        in := 1  
        for {  
            in++  
            ch <- in  
        }  
    }()  
      
    for {  
        select {  
        case _ = <-ch:  
            // do something...  
            continue  
        case <-time.After(3 * time.Minute):  
            fmt.Printf("现在是:%d", time.Now().Unix())  
        }  
    }  
}

不要在for循环里触发newTimer,而是在外边newTimer,在里边直接<-timer.C

func main() {  
    timer := time.NewTimer(3 * time.Minute)  
    defer timer.Stop()  
      
    ...  
    for {  
        select {  
        ...  
        case <-timer.C:  
            fmt.Printf("现在是:%d", time.Now().Unix())  
        }  
    }  
}

5.time.Ticker没有关闭

func main(){  
        ticker := time.NewTicker(5 * time.Second)  
        go func(ticker *time.Ticker) {  
                for range ticker.C {  
                        fmt.Println("Ticker1....")  
                }  
  
                fmt.Println("Ticker1 Stop")  
        }(ticker)  
  
        time.Sleep(20* time.Second)  
        //ticker.Stop()  
}

6.不关闭打开的文件/关闭过晚

func main() {    
    files := make([]*os.File, 0)    
    for i := 0; ; i++ {    
        file, err := os.OpenFile("test.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)    
        if err != nil {    
            fmt.Printf("Error at file %d: %v\n", i, err)    
            breakelse {    
            _, _ = file.Write([]byte("Hello, World!"))    
            files = append(files, file)    
        }    
    }    
}

7.http.Response.Body未关闭

func makeRequest() {  
    client := &http.Client{}  
    req, err := http.NewRequest(http.MethodGet, "http://localhost:8081", nil)  
    res, err := client.Do(req)  
	if err != nil {  
		fmt.Println(err)  
    }  
	_, err = ioutil.ReadAll(res.Body)  
	// defer res.Body.Close()  
	if err != nil {  
		fmt.Println(err)  
    }  
}

10.map不释放桶

func main() {  
    n := 1_000_000  
    m := make(map[int][128]byte)  
    printAlloc()  
  
    for i := 0i < n; i++ { // Adds 1 million elements  
        m[i] = [128]byte{}  
    }  
    printAlloc()  
  
    for i := 0i < n; i++ { // Deletes 1 million elements  
        delete(m, i)  
    }  
  
    runtime.GC() // Triggers a manual GC  
    printAlloc()  
    runtime.KeepAlive(m) // Keeps a reference to m so that the map isn’t collected  
}  
  
func printAlloc() {  
    var m runtime.MemStats  
    runtime.ReadMemStats(&m)  
    fmt.Printf("%d KB\n", m.Alloc/1024)  
}

内存泄漏问题排查

1.pprof

mp.weixin.qq.com/s/8UG7qJabq… mp.weixin.qq.com/s/vncOjgrSo…

1.1 使用方法

  • 创建一个debug端口
package main

import (
    "net/http"
    _ "net/http/pprof"
)

func main() {
    // 启动一个 HTTP 服务器来提供 pprof 数据
    go func() {
        http.ListenAndServe("localhost:8080", nil)
    }()

    // do something
}
  • 在特定位置获取heap快照
import "runtime/pprof"  
  
pprof.WriteHeapProfile(writer)

使用 go tool pprof 命令访问本地服务的 /debug/pprof/profile 接口,CPU 采样数据会自动保存到 $HOME/pprof/ 目录下,同时也会直接进入分析命令行。

  • cpu性能分析
go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30
  • heap/内存性能分析
go tool pprof http://127.0.0.1:6060/debug/pprof/heap?seconds=30
  • goroutine分析
go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine?seconds=30
  • block分析
/debug/pprof/block

1.2 分析方法

cloud.tencent.com/developer/a…

  • Flat:函数自身运行耗时
  • Flat%:函数自身耗时比例
  • Sum%:指的就是每一行的flat%与上面所有行的flat%总和
  • Cum:当前函数加上它所有调用栈的运行总耗时
  • Cum%:当前函数加上它所有调用栈的运行总耗时比例
  • 举例说明
    • 函数demo由三部分组成:调用函数foo、自己直接处理一些事情、调用函数bar
    • 其中调用函数foo耗时1秒,自己直接处理事情耗时3秒,调用函数bar耗时2秒,
    • 那么函数demoflat耗时就是3秒,cum耗时就是6秒。

2.goref

github.com/cloudwego/g…
mp.weixin.qq.com/s/ZJZwPphCW…
goref的优点:能准确展示内存引用分布和引用关系,是通过dlv来实现的。

  • 安装 如果报unknown directive:toolchain问题,需要升级Go版本到1.21以上
go install github.com/cloudwego/goref/cmd/grf@latest
  • 设置grf命令 会优先使用gobin的路径
echo $GOBIN
echo $GOPATH
  • 示例
$ echo $GOBIN
/home/log/.GOPATH/bin
$ ll /home/log/.GOPATH/bin/grf
/home/log/.GOPATH/bin/grf
$

需要给PATH把gobin路径加上

export PATH=$PATH:/home/log/.GOPATH/bin
  • 获取内存信息
grf attach {pid}

把输出文件放到开发环境

  • 安装可视化工具
yum install graphviz
  • 查看结果 需要外网访问则必要用0.0.0.0
go tool pprof -http=0.0.0.0:5079 ./grf.out

todo goref缺点,pprof缺点(性能等)