跳到主要内容

内存泄漏排查

问题

Go 有 GC 为什么还会内存泄漏?常见的泄漏场景有哪些?如何排查?

答案

Go 中的"内存泄漏"

Go 有 GC,不会出现 C/C++ 那样的忘记 free 的问题。但 Go 的内存泄漏通常是对象仍被引用但不再需要,GC 无法回收。

最常见的是 goroutine 泄漏——goroutine 无法退出,其引用的内存永远不会释放。

常见泄漏场景

1. Goroutine 泄漏——Channel 阻塞

// ❌ goroutine 永远阻塞在 ch 发送上
func leak() {
ch := make(chan int)
go func() {
val := doWork()
ch <- val // 如果没人接收,goroutine 永远阻塞
}()

// 如果函数提前返回(如超时),goroutine 泄漏
select {
case result := <-ch:
use(result)
case <-time.After(1 * time.Second):
return // ch 无人接收,goroutine 泄漏!
}
}

// ✅ 修复:用带缓冲的 Channel 或 Context
func noLeak(ctx context.Context) {
ch := make(chan int, 1) // 带缓冲,发送不阻塞
go func() {
val := doWork()
ch <- val // 即使没人接收也不会阻塞
}()

select {
case result := <-ch:
use(result)
case <-ctx.Done():
return // goroutine 发送后自动退出
}
}

2. Goroutine 泄漏——忘记关闭 Channel

// ❌ 消费者 range 永远等待
func leak() {
ch := make(chan int)
go producer(ch) // 如果 producer 忘了 close(ch)

for v := range ch { // 永远不会结束
process(v)
}
}

3. time.After 内存泄漏

// ❌ 每次循环都创建 Timer,旧的 Timer 直到超时才会被 GC
for {
select {
case data := <-ch:
process(data)
case <-time.After(5 * time.Second): // 每次创建新 Timer
return
}
}

// ✅ 修复:复用 Timer
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()

for {
timer.Reset(5 * time.Second)
select {
case data := <-ch:
process(data)
case <-timer.C:
return
}
}

4. 全局 Map 不清理

// ❌ map 只增不减
var cache = make(map[string]*BigObject)

func handler(key string) {
cache[key] = loadObject(key) // 永远不删除旧条目
}

// ✅ 修复:使用 LRU 缓存或定期清理

5. Slice 底层数组未释放

// ❌ s 虽然只有 2 个元素,但底层数组仍持有 1000000 个元素的引用
func getLast2(data []int) []int {
return data[len(data)-2:] // 底层共享大数组
}

// ✅ 修复:复制到新的 slice
func getLast2(data []int) []int {
result := make([]int, 2)
copy(result, data[len(data)-2:])
return result
}

6. HTTP 响应 Body 未关闭

// ❌ Body 未关闭,连接不会被复用,TCP 连接泄漏
resp, err := http.Get(url)
if err != nil { return err }
// 忘了 resp.Body.Close()

// ✅ 修复
resp, err := http.Get(url)
if err != nil { return err }
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body) // 读完 Body,连接才能复用

排查工具

1. pprof 查看堆内存

# 查看当前堆使用
go tool pprof http://localhost:6060/debug/pprof/heap

# 比较两个时间点的差异
go tool pprof -diff_base=base.prof current.prof

2. pprof 查看 goroutine 数量

# 查看 goroutine 数量和栈
curl http://localhost:6060/debug/pprof/goroutine?debug=1 | head -5

# 详细分析
go tool pprof http://localhost:6060/debug/pprof/goroutine
(pprof) top

3. runtime.NumGoroutine()

// 定时打印 goroutine 数量
go func() {
for {
fmt.Println("goroutines:", runtime.NumGoroutine())
time.Sleep(10 * time.Second)
}
}()

4. goleak(测试中检测 goroutine 泄漏)

import "go.uber.org/goleak"

func TestMain(m *testing.M) {
goleak.VerifyTestMain(m) // 测试完成后检查是否有泄漏的 goroutine
}

常见面试问题

Q1: goroutine 泄漏的常见原因?

答案

  1. Channel 阻塞:发送/接收没有对应的接收/发送方
  2. 忘记关闭 Channel:消费者 range 永远不结束
  3. 缺少退出机制:没有 Context 取消 或 done Channel
  4. 锁等待:永远获取不到锁(死锁的一种形式)

Q2: 如何预防内存泄漏?

答案

  1. goroutine 必须有退出条件——使用 Context 或 done Channel
  2. Channel 必须有 close——生产者负责关闭
  3. defer 释放资源——resp.Body.Close()、file.Close()、rows.Close()
  4. 全局缓存加上限——LRU、TTL 淘汰
  5. CI 中运行 go test -race 和 goleak

相关链接