线上 Go 服务 RSS 一路涨,重启后短暂恢复,过几小时继续涨——这就是典型“疑似内存泄漏”场景。

别先拍脑袋改代码,先把证据链跑通:监控确认 → pprof 采样 → FlameGraph 对比 → 定位对象增长路径 → 回归验证。这套流程跑完,基本能把“玄学泄漏”打成“可复现 bug”。

一句话结论

  • 先看趋势,不要只看某一刻内存值。
  • heap + allocs + GC 指标一起判断,避免误判“缓存增长”。
  • 线上排查优先用 go tool pprof,复杂调用链再上 FlameGraph。

一、先确认是不是“真泄漏”

先在监控看 3 组指标:

  1. process_resident_memory_bytes(进程 RSS)
  2. go_memstats_heap_inuse_bytes
  3. go_memstats_heap_objects

如果现象是:

  • QPS 稳定甚至下降
  • RSS 和 Heap Objects 仍持续爬升
  • Full GC 后也不明显回落

那基本就值得进入泄漏排查。

二、给服务暴露 pprof(仅内网)

1) 标准方式接入

import _ "net/http/pprof"

func init() {
    go func() {
        // 只监听内网或 localhost,别裸奔到公网
        _ = http.ListenAndServe("127.0.0.1:6060", nil)
    }()
}

如果你用 Gin/Echo,也可以单独起一个 debug 端口,避免业务路由污染。

2) 采样命令(线上可直接跑)

# 当前堆快照
curl -s http://127.0.0.1:6060/debug/pprof/heap > heap_1.pb.gz
sleep 300
curl -s http://127.0.0.1:6060/debug/pprof/heap > heap_2.pb.gz

# 分配视角(看谁在疯狂分配)
curl -s http://127.0.0.1:6060/debug/pprof/allocs > allocs.pb.gz

关键点:至少抓两份间隔样本,单样本很难判断“增长责任方”。

三、用 pprof 快速锁定增长对象

go tool pprof -top heap_1.pb.gz
go tool pprof -top heap_2.pb.gz
go tool pprof -base heap_1.pb.gz heap_2.pb.gz

重点关注:

  • inuse_space 增长最大的类型
  • 同一调用路径是否持续扩大
  • 是否集中在 map/slice/string/buffer 相关对象

常见泄漏模式:

  1. 全局 map 只增不删(key 持续增长)
  2. goroutine 泄漏持有引用,导致对象无法回收
  3. 缓存没有 TTL/LRU,上限失控
  4. channel 积压导致对象滞留

四、FlameGraph 看调用链(定位“谁在养胖它”)

go tool pprof -http=:8081 heap_2.pb.gz

浏览器打开后看 FlameGraph:

  • 横向越宽,内存占用越大
  • 沿调用栈往下钻,找到业务函数入口
  • 对比 heap_1heap_2,看是哪条链在持续变宽

如果你看到某条 BuildXXXResponse -> append -> json.Marshal 链长期变宽,十有八九是大对象拼装 + 缓存引用没释放。

五、一个真实修复示例(可直接参考)

问题代码(简化)

var userCache = map[string]*UserProfile{}

func GetUserProfile(uid string) *UserProfile {
    if v, ok := userCache[uid]; ok {
        return v
    }
    p := loadProfile(uid)
    userCache[uid] = p
    return p
}

这段在高基数 UID 下会无限增长。

修复方案

  • 加上限(LRU)
  • 加过期(TTL)
  • 加观测(缓存大小、命中率、淘汰次数)

示例(伪代码):

cache := lru.NewWithTTL(20000, 10*time.Minute)

六、回归验证:别“感觉好了”

修复后至少做这 3 件事:

  1. 同负载压测 30~60 分钟
  2. 对比修复前后 heap_objects 斜率
  3. 复抓 heap 并做 -base 对比

理想状态:

  • 对象总量进入平台期
  • GC 后 heap_inuse 明显回落
  • RSS 波动但不再单边上升

七、排查清单(值班时直接抄)

  • pprof 端口仅内网可达
  • 至少两份 heap 样本(间隔 >= 5 分钟)
  • 使用 -base 做增量对比
  • 验证 goroutine 是否异常增长
  • 验证缓存是否有上限 + 过期
  • 修复后做同条件回归压测

总结

Go 的“内存泄漏”大多不是 GC 失灵,而是对象引用路径设计失控

把证据链走完整:监控 → pprof → FlameGraph → 修复 → 回归,你就能把问题从“猜”变成“证据驱动”。这比盲改快得多,也安全得多。