Go 1.19 引入 GOMEMLIMIT 之后,很多人把它当成 -Xmx 的等价物:设一个数,进程就不该超过它。上线后看到 RSS 还是越线,于是得出结论“这个参数没用”。其实它从来没承诺过硬上限,它改变的是 GC 什么时候开始更卖力地工作。
它实际控制什么
运行时的堆目标大致由两个量决定:上一次 GC 之后存活对象的字节数,以及 GOGC 给出的增长比例。GOMEMLIMIT 加进来以后,堆目标会再被压到“限制减去一段安全余量”之下。换句话说,它让 GC 在接近限制时提前启动、提高频率,而不是在分配失败时拒绝分配。
这意味着两件事:
- 超过限制不会触发 OOM,也不会阻塞分配;运行时只是继续 GC,哪怕回收不出多少空间。
- 真正决定进程会不会被内核杀掉的,是 RSS,而 RSS 不等于 Go 堆。
// 观察运行时眼里的内存分类,比看 MemStats 更直接。func snapshot() { samples := []runtime.MetricSample{ {Name: "/memory/classes/heap/objects:bytes"}, {Name: "/memory/classes/heap/unused:bytes"}, {Name: "/memory/classes/heap/released:bytes"}, {Name: "/memory/classes/total:bytes"}, {Name: "/gc/heap/goal:bytes"}, } runtime.ReadMetrics(samples) for _, s := range samples { fmt.Printf("%-45s %d\n", s.Name, s.Value.Uint64()) }}heap/objects 是活对象,heap/unused 是已经向操作系统要来、但还没被对象使用的 span,heap/released 是 GC 还回去的部分。GOMEMLIMIT 主要作用于前两者之和,加上少量固定开销。
RSS 越线的三类原因
堆内保留
Go 的分配器按 size class 管理 span,回收后的内存不一定立刻归还内核。频繁分配大小混合的对象时,碎片会让 heap/unused 长期偏高。GOMEMLIMIT 会让 GC 更早触发,但不会让分配器主动整理碎片。
可以用 GODEBUG=madvdontneed=1 改变归还策略的历史行为,但现代版本默认已经是 MADV_DONTNEED 之外更温和的策略;更实际的做法是把对象尺寸收敛到少数几个档位,减少 size class 之间的空洞。
堆外占用
goroutine 栈、GC 元数据、OS 线程栈、cgo 分配、mmap 的文件映射、sync.Pool 里的大对象,都不完全计入 Go 堆。一个常见的场景是:堆只有 400MB,RSS 却是 1.2GB,因为几十万个 goroutine 的栈加起来就有几百 MB,而每个栈都按需增长、回收后仍然保留一段时间。
runtime/metrics 里的 /sched/goroutines:goroutines 和 /memory/classes/stacks:bytes 能把这两块单独拿出来看。如果栈占比高,GOMEMLIMIT 帮不上忙,得先减少并发度。
内核记账
页缓存、文件映射、socket 缓冲区也算进 RSS。一个做日志轮转的服务,如果持续 mmap 大文件又不 munmap,RSS 会一直涨,而 Go 运行时对此一无所知。
一个可用的配置思路
把容器限制记作 C,先设:
GOMEMLIMIT=$((C * 8 / 10)) # 80%,给堆外和内核留余量GOGC 保持默认,除非你确认 CPU 有富余,想用更低的 GC 频率换吞吐。不要同时把 GOGC 调得很高、GOMEMLIMIT 设得很紧:前者让堆目标增长得快,后者又把它压回来,结果是 GC 在目标附近来回震荡。
监控上,关注 /gc/heap/goal:bytes 与 heap/objects 的比值。如果 goal 长期贴着限制、objects 却降不下来,说明存活集本身已经接近上限,继续调参数没有意义,该查泄漏或者拆分进程了。
结论
GOMEMLIMIT 是一个给 GC 的软目标,不是给操作系统的承诺。把它当成“让 GC 更早介入的旋钮”,同时用 runtime/metrics 分清堆内、堆外和内核三部分,才能解释 RSS 为什么和它不相等。