一个 Go 服务把内存从 2G 降到 800M,而 RSS 始终显示 1.8G。runtime.MemStats 里 HeapAlloc 只有 500M,HeapReleased 也在涨——于是"进程占用的内存"与"堆里的对象"之间差了一倍。
Go 的内存分三层:堆对象(HeapAlloc)、堆保留(HeapSys)、进程地址空间(RSS)。而"RSS 不降"的常见原因,这个"不是泄漏"的判断很重要,因为"追泄漏"的方向会带偏:你以为要找"谁没释放",而实际问题在"运行时什么时候把闲置内存还给 OS"——两者的修法完全不同。
是"归还与复用"的时机问题,不是"泄漏"。
1. 三个内存口径
| 口径 | 含义 |
|---|---|
HeapAlloc |
存活对象占用 |
HeapIdle |
已归还给"运行时"但未归还 OS |
HeapReleased |
已归还 OS |
RSS |
进程实际占用(含栈、代码、cgo) |
第二项是关键:**Go 运行时"保留"内存用于"下次分配复用"**第二项的"动机"是"性能":向 OS 申请/归还内存要系统调用,而"保留"能让"下一次分配"免掉这一步——于是"保留"是"用内存换 CPU"的默认策略。
,于是"对象死了"不等于"内存还给了 OS"。
2. "RSS 不降"的四种原因
| 原因 | 判据 |
|---|---|
| 内存未归还(Go 保留) | HeapIdle 高、HeapReleased 低 |
| 归还节奏慢 | 需要"两次 GC 或定时归还" |
| 栈与 cgo 占用 | StackSys/GCCPUFraction |
| 内存碎片/大对象 | HeapInuse 与 HeapIdle 比例 |
第二项还有个"步调"细节:归还由后台清扫者"按步"做,于是"刚从峰值下来"的那几秒,RSS 仍然很高——它会被"按分钟看"的监控放大。
第二项最常被忽略:Go 的"归还"不是"GC 之后立刻",而是"由后台清扫者按步调进行",且在"内存用量低"时才积极归还。
// 四个关键指标(一眼看出"账在哪")
var m runtime.MemStats
runtime.ReadMemStats(&m)
// HeapAlloc 存活对象
// HeapIdle 已空闲、未还 OS(RSS 的主要来源)
// HeapReleased 已还 OS
// HeapSys 向 OS 申请的总量
// 判据:HeapIdle 大 + HeapReleased 小 → "Go 保留着没还"
3. 三种"让 Go 还回去"的手段
| 手段 | 效果 |
|---|---|
debug.FreeOSMemory() |
立刻归还(有停顿代价) |
GODEBUG=madvdontneed=1 |
改"归还方式"(更积极) |
调 GOGC/GOMEMLIMIT |
间接影响"何时归还" |
第一项是"手动强制":它触发一次 GC + 归还第一项的"适用"很窄:**"空闲时段"或"任务结束后"**调一次可以;"每次请求后"调会让"停顿"变成"常态延迟"。
,代价是"一次停顿"——适合"低频维护任务",不适合"每个请求"。
第三项的"差别"在于"目标":GOGC 调的是"增长倍数",GOMEMLIMIT 调的是"总量红线"——而"红线"才是容器里真正会触发 OOM 的东西。
第三项是"现代做法":GOMEMLIMIT 告诉运行时"总内存上限",于是"接近上限时"它会"更积极归还"——这比"调 GOGC"更贴近"容器配额"。
// 三种配置(按场景选)
GOGC=100 # 默认:堆翻倍就 GC
GOMEMLIMIT=1500MiB # 按容器配额设(推荐,Go 1.19+)
GODEBUG=madvdontneed=1 # 让"归还"用 MADV_DONTNEED(更积极)
4. "泄漏"与"不归还"的区分
| 指标组合 | 结论 |
|---|---|
HeapAlloc 持续涨 |
可能泄漏 |
HeapAlloc 稳定、RSS 涨 |
不归还/碎片/栈 |
Goroutine 数持续涨 |
泄漏(goroutine 持有对象) |
HeapObjects 数持续涨 |
泄漏 |
第二项是"最常被误判为泄漏"的组合:对象数稳定、堆稳定,而 RSS 不降——它通常"不是 bug",而是"运行时的保留策略"。
第三项是最常见的"真泄漏":goroutine 卡住(等 channel/锁)会持有它引用的所有对象第三项的"排查"有现成工具:pprof goroutine 看"哪个栈的 goroutine 最多"——若"同一处卡了几千个",泄漏点就找到了。
——于是"堆涨"其实是"goroutine 涨"。
// 三分钟的"是不是泄漏"判定
1 持续请求 10 分钟,看 HeapAlloc 是否单调上升(且 GC 后不回落)
2 看 goroutine 数与 HeapObjects 数是否同步上升
3 用 pprof heap(inuse_space)看"谁占着"
// 结论:1 不涨 → 不是泄漏;2 同步涨 → goroutine 泄漏优先
"内存账"可以用一个"三行 dump"贴进故障单,避免"看错口径":
// 三行 dump(HeapAlloc / HeapIdle / RSS)+ goroutine 数
HeapAlloc=512MiB HeapIdle=1100MiB HeapReleased=120MiB HeapSys=1700MiB
RSS=1.8GiB goroutine=48
// 读法:HeapIdle 大(1100MiB)说明"Go 保留着没还";
// goroutine 只有 48 → 不是 goroutine 泄漏;HeapAlloc 稳定 → 不是对象泄漏
5. "归还"的三个误区
| 误区 | 实际 |
|---|---|
| "GC 之后 RSS 就该降" | 不一定(保留复用) |
| "调小 GOGC 就会省内存" | 会更频繁 GC,RSS 未必降 |
| "FreeOSMemory 可以每次都调" | 停顿代价高 |
第二项的"原因"是"保留量由"峰值"决定":GC 更频繁只是"更早回收垃圾",而"曾经用到过的峰值"仍被保留——除非"主动归还"。
第二项很反直觉:GOGC 小 → GC 更频繁 → CPU 上升,而 RSS 可能不变(因为"保留量"由"峰值"决定)。
"强制归还"要"按条件触发",而不是"定时无脑调":
// 仅在"堆空闲占比高且长时间无请求"时归还
if idleRatio > 0.4 && time.Since(lastReq) > 30*time.Second {
debug.FreeOSMemory() // 一次 GC + 归还;代价是一次停顿
}
// 反例:每次请求后调用 → 停顿变成常态延迟,吞吐下降
6. 排错:五种"内存问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| RSS 不降但 Heap 稳定 | 保留/碎片 | 设 GOMEMLIMIT |
| 容器 OOM 但 Heap 不高 | 栈/cgo/碎片 | 看 HeapInuse/Idle |
| 内存单调上升 | 泄漏 | pprof + goroutine 数 |
| GC 频繁但内存不降 | GOGC 太小 | 改 GOMEMLIMIT |
| 峰值后 RSS 一直高 | 峰值决定保留 | 接受或强制归还 |
第五项是"容量规划"的现实:RSS 的上界由"历史峰值"决定第五项的"缓解"是"把峰值降下来":"某次大查询"用了 1.5G,之后 RSS 就按 1.5G 保留——所以"限制单次查询的内存"比"事后归还"更根本。
,于是"某次大查询"会让"之后一直占着"——除非"主动归还"或"重启"。
最后一个"观测"建议:把 HeapIdle 与 RSS 画在同一张图上,而不是只看 RSS。
// 两张图能讲的三个故事
RSS 涨、HeapIdle 涨 → 保留(不是泄漏)
RSS 涨、HeapAlloc 涨 → 泄漏(去 pprof)
RSS 稳定、HeapAlloc 涨 → 分配快、回收也快(GC 在跑,正常)
// 判据:单看 RSS 无法区分这三种,必须配一个堆内指标
7. 边界:什么情况不必追 RSS
| 场景 | 为什么 | 做法 |
|---|---|---|
| 容器内存充足(余量 > 50%) | 不影响稳定 | 不追 |
| 短生命周期进程(批处理) | 退出即释放 | 不追 |
| RSS 高但 P99 稳定 | 无实际影响 | 不追 |
已设 GOMEMLIMIT 且未 OOM |
运行时在管 | 观察即可 |
最后一条与"容器"有关的边界:容器的 memory limit 是"硬上限",而 Go 在 1.19 前"不知道"这个上限——于是"堆按机器内存算"、"容器按配额杀",这就是"GOMEMLIMIT 存在的原因"。
判断口径一句话:"RSS 高会不会导致 OOM 或影响 P99"。会(容器配额紧),就要"设 GOMEMLIMIT + 找峰值来源";不会(余量充足),它只是"运行时保留",不必当成 bug。
8. 自测
- 三个内存口径分别是什么?为什么"对象死了"不等于"内存还给 OS"?
- 四种"RSS 不降"的原因里,哪一种是"运行时保留"?它用什么指标判断?
GOMEMLIMIT与GOGC的区别是什么?为什么前者更贴近容器?- 怎么区分"泄漏"与"不归还"?哪两项指标同步上涨指向"goroutine 泄漏"?
- 为什么"调小 GOGC 未必省内存"?
- 判断"是否该追 RSS"的那个问题是什么?