跳到主要内容

内存泄漏排查

问题

Go 服务运行一段时间后内存持续增长不释放,如何排查和定位内存泄漏?

答案

内存泄漏典型表现

排查步骤

1. 确认内存趋势

先通过监控(Prometheus + Grafana)或系统工具确认内存确实在持续增长:

# 查看进程 RSS 内存
ps aux | grep myapp
# 或者看 /proc/<pid>/status
cat /proc/<pid>/status | grep VmRSS
提示

Go 向操作系统归还内存有延迟(默认 5 分钟),短时间内存增长不一定是泄漏。

2. 启用 pprof

import _ "net/http/pprof"

func main() {
// 在独立端口暴露 pprof,不要和业务端口混在一起
go func() {
log.Println(http.ListenAndServe(":6060", nil))
}()
// ...业务代码
}

3. 抓取堆内存 Profile

# 抓取当前堆快照
go tool pprof http://localhost:6060/debug/pprof/heap

# 进入交互模式后
(pprof) top 20 # 按内存排序前 20
(pprof) list funcName # 查看某函数的内存分配
(pprof) web # 生成SVG火焰图(需安装 graphviz)

4. 对比两个时间点的 Profile

# 先抓一份基准
curl -o base.pb.gz http://localhost:6060/debug/pprof/heap

# 等待 10 分钟
curl -o current.pb.gz http://localhost:6060/debug/pprof/heap

# 对比差异——找到增长的部分
go tool pprof -base base.pb.gz current.pb.gz
(pprof) top
这是最有效的排查手段

对比两个时刻的 heap profile,增量最大的函数大概率就是泄漏点。

5. 检查 Goroutine 泄漏

go tool pprof http://localhost:6060/debug/pprof/goroutine

(pprof) top # 哪些函数创建了最多 goroutine
(pprof) traces # 查看完整调用栈

常见泄漏场景与修复

场景 1:Channel 阻塞导致 Goroutine 泄漏

// ❌ 泄漏:没人消费 ch,goroutine 永远阻塞
func leak() {
ch := make(chan int)
go func() {
result := doWork()
ch <- result // 如果外部没读取,永远阻塞
}()
// 函数返回了,但 goroutine 还活着
}

// ✅ 修复:使用带缓冲的 channel 或 context 取消
func fixed(ctx context.Context) {
ch := make(chan int, 1) // 带缓冲,即使没人读也不阻塞
go func() {
select {
case ch <- doWork():
case <-ctx.Done():
return
}
}()
}

场景 2:全局 Map 无限增长

// ❌ 泄漏:缓存只增不删
var cache = make(map[string]*BigObject)

func Store(key string, obj *BigObject) {
cache[key] = obj
}

// ✅ 修复方案 1:加 TTL + 定期清理
type CacheItem struct {
Value *BigObject
ExpireAt time.Time
}

func CleanExpired() {
ticker := time.NewTicker(time.Minute)
for range ticker.C {
now := time.Now()
mu.Lock()
for k, v := range cache {
if now.After(v.ExpireAt) {
delete(cache, k)
}
}
mu.Unlock()
}
}

// ✅ 修复方案 2:使用 LRU 缓存限制大小

场景 3:time.Ticker 未 Stop

// ❌ 泄漏:Ticker 不 Stop 会一直占用资源
func process() {
ticker := time.NewTicker(time.Second)
for range ticker.C {
if done {
return // ticker 没有 Stop!
}
}
}

// ✅ 修复:defer Stop
func process() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
if done {
return
}
}
}

场景 4:HTTP Response Body 未关闭

// ❌ 泄漏:body 未关闭,底层 TCP 连接无法复用
resp, err := http.Get("https://example.com")
if err != nil {
return err
}
data, _ := io.ReadAll(resp.Body)

// ✅ 修复:一定要 Close Body
resp, err := http.Get("https://example.com")
if err != nil {
return err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)

场景 5:Slice 引用大数组

// ❌ 潜在泄漏:小 slice 引用大底层数组,GC 无法回收大数组
func getHeader(data []byte) []byte {
return data[:16] // 底层仍引用整个 data
}

// ✅ 修复:拷贝出来
func getHeader(data []byte) []byte {
header := make([]byte, 16)
copy(header, data[:16])
return header
}

排查工具汇总

工具用途
pprof heap堆内存分配分析
pprof goroutineGoroutine 数量和调用栈
pprof allocs累计内存分配(含已释放)
runtime.ReadMemStats运行时内存统计
GODEBUG=gctrace=1GC 日志追踪
Prometheus + Grafana长期趋势监控
// 在代码中打印内存统计
func PrintMemStats() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc=%dMB Sys=%dMB NumGC=%d Goroutines=%d\n",
m.Alloc/1024/1024,
m.Sys/1024/1024,
m.NumGC,
runtime.NumGoroutine(),
)
}

常见面试问题

Q1: Go 的内存泄漏和 C/C++ 有什么区别?

答案

  • Go 有 GC,不存在"忘记 free"的泄漏
  • Go 的内存泄漏本质是引用泄漏:对象仍被引用(goroutine、全局变量、闭包捕获等),GC 无法回收
  • 最常见的是 Goroutine 泄漏:goroutine 阻塞不退出,它引用的所有对象都无法回收

Q2: pprof 的 inuse_spacealloc_space 有什么区别?

答案

  • inuse_space(默认):当前正在使用的内存,反映"还活着"的对象
  • alloc_space:程序启动以来的累计分配量,含已释放的
  • 排查泄漏用 inuse_space,优化分配频率用 alloc_space

Q3: 如何在生产环境安全使用 pprof?

答案

  • pprof 端口不对外暴露(内网端口或通过跳板机访问)
  • 使用独立的 HTTP Server 监听不同端口
  • 可以加 Basic Auth 或 IP 白名单

Q4: GOMEMLIMIT 和 GOGC 怎么配合用?

答案

  • GOGC=100(默认):堆增长 100% 时触发 GC
  • GOMEMLIMIT=512MiB(Go 1.19+):设置内存软上限
  • 组合用法:设置 GOMEMLIMIT + GOGC=off,让 GC 只在接近上限时触发,减少 GC 频率

相关链接