Go 有自动垃圾回收,但这不表示内存可以不管。容器里部署服务时,如果内存持续上涨,最终可能被 OOM kill。初学者常见误解是:只要没有内存泄漏,Go 会自动处理。现实是,GC 有策略,程序有峰值,容器有上限,你需要知道基本参数。
本文用入门角度讲 GOMEMLIMIT、GOGC 和几个观察点。
GOGC 是什么
GOGC 控制 GC 目标百分比。默认 100,粗略理解是:当新分配堆大小达到上次存活堆大小的 100% 左右时触发下一轮 GC。调低 GOGC 会更频繁 GC,内存可能更低,CPU 成本更高;调高则相反。
运行:
GOGC=50 ./app
不要随便改。大多数服务默认值就够用。只有在有指标、压测和明确目标时,才调整。
GOMEMLIMIT
GOMEMLIMIT 给 Go runtime 一个软内存限制:
GOMEMLIMIT=512MiB ./app
它不是硬限制,也不等于容器内存上限。它告诉 Go 尽量把 runtime 管理的内存控制在这个目标附近。容器里通常可以把它设置得低于容器限制,给非 Go 堆内存、线程栈、mmap、系统开销留空间。
比如容器限制 1GiB,可以先设置:
GOMEMLIMIT=800MiB
具体值要看程序行为。图片处理、大文件缓冲、cgo、外部库都会影响真实内存。
观察内存
Go 可以读取 runtime 指标:
var m runtime.MemStats
runtime.ReadMemStats(&m)
log.Printf("heap_alloc=%d heap_sys=%d num_gc=%d", m.HeapAlloc, m.HeapSys, m.NumGC)
HeapAlloc 是当前已分配且仍在使用的堆内存,HeapSys 是 runtime 从系统拿到的堆空间。进程 RSS 可能更大,因为还有栈、代码段、mmap、cgo 等。
不要只看一个数字。容器 OOM 看的是进程实际占用,不只是 Go 堆。
常见内存峰值来源
- 一次性读取大文件
- 大 JSON 全量解码
- 不受限的 map 缓存
- goroutine 泄漏
- 响应体没有关闭
- bytes.Buffer 被长期持有
优化方向通常不是先调 GOGC,而是减少峰值:流式处理、分页、限制上传大小、给缓存容量上限、及时关闭资源。
用 pprof 看 heap
如果怀疑内存问题,pprof 比猜测更可靠:
go tool pprof http://127.0.0.1:6060/debug/pprof/heap
看哪些函数分配了大量对象。注意 heap profile 说明的是采样结果,要结合请求量和业务场景解释。看到某个函数分配多,不一定是泄漏,可能它就是在处理大数据。
容器里的实践
容器部署时建议:
- 设置明确内存 limit。
- 根据 limit 设置
GOMEMLIMIT。 - 观察 RSS、GC 次数、延迟和 OOM 事件。
- 压测大请求和批处理任务。
- 避免单请求无限占用内存。
环境变量要写进部署配置,而不是靠手工:
env:
- name: GOMEMLIMIT
value: "800MiB"
用 runtime/debug 设置
除了环境变量,Go 也可以在程序里设置内存限制和 GC 百分比。这样做适合命令行工具、测试程序,或需要根据配置文件调整的服务。
package main
import (
"runtime/debug"
)
func main() {
oldPercent := debug.SetGCPercent(100)
_ = oldPercent
oldLimit := debug.SetMemoryLimit(800 << 20) // 800 MiB
_ = oldLimit
// start server...
}
生产服务里我更偏向用环境变量,因为部署层能直接看见配置,也方便灰度和回滚。代码设置的优点是集中,缺点是容易让运行环境的人不知道程序内部还改了参数。
看 pprof 时区分分配和保留
看到某个函数分配很多内存,不代表它泄漏。比如接口把 20MB CSV 转成结构体,分配峰值确实会高,但请求结束后对象能被回收。真正需要警惕的是内存持续上涨,而且 GC 后也降不下来。
func readAll(r io.Reader) ([]byte, error) {
return io.ReadAll(r)
}
这个函数本身没有泄漏,但如果请求体没有上限,用户上传 2GB 文件,程序就会尝试读进内存。更好的写法是加限制,或者直接流式处理。
func limitedBody(r io.Reader) ([]byte, error) {
const max = 10 << 20 // 10 MiB
return io.ReadAll(io.LimitReader(r, max+1))
}
读完后还要检查长度是否超过上限。内存优化里最有效的动作,往往不是调整 GC,而是拒绝不合理输入。
缓存是最常见的软泄漏
Go 程序里很多“内存泄漏”其实是缓存没有上限。map 一直塞数据,GC 当然不会回收,因为程序还持有引用。
type Cache struct {
mu sync.Mutex
m map[string][]byte
}
func (c *Cache) Set(k string, v []byte) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = v
}
这个缓存很容易无限增长。入门阶段可以先加一个简单容量限制,超过后清空或拒绝写入;生产环境可以使用 LRU、TTL 或成熟缓存库。
func (c *Cache) SetLimited(k string, v []byte, max int) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.m) >= max {
for old := range c.m {
delete(c.m, old)
break
}
}
c.m[k] = append([]byte(nil), v...)
}
这里复制 v 是为了避免调用方后续修改底层数组,导致缓存内容悄悄变化。内存和正确性经常绑在一起看,不能只盯着占用数字。
大请求要设上限
HTTP 服务一定要限制请求体大小。没有上限的上传接口,是最容易把容器内存打满的入口之一。
func upload(w http.ResponseWriter, r *http.Request) {
const maxUpload = 20 << 20 // 20 MiB
r.Body = http.MaxBytesReader(w, r.Body, maxUpload)
defer r.Body.Close()
b, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "upload too large", http.StatusRequestEntityTooLarge)
return
}
fmt.Fprintf(w, "received %d bytes", len(b))
}
如果文件更大,不要 ReadAll,应该边读边写到对象存储或临时文件。内存限制参数只能帮你更早感知压力,不能替你决定业务边界。
指标要一起看
观察内存时不要只看 RSS。至少要同时看请求量、P95 延迟、GC 次数、GC 暂停时间、堆对象数量。如果某次发布后 RSS 变高,但延迟没变、GC 稳定、没有 OOM,可能只是程序缓存了更多热数据。若 RSS 高、GC 频繁、延迟也上升,就要优先查大对象分配和缓存增长。
调参的过程应该有记录:原始值、修改值、压测流量、结果。不要今天把 GOGC 改成 50,明天又改成 200,却没有任何对比数据。对初学者来说,养成“先观测,再判断,再修改”的习惯,比背参数含义更重要。
小结
Go 的 GC 能自动回收不再使用的对象,但它不能替你设计内存边界。GOGC 影响 GC 频率,GOMEMLIMIT 提供软内存目标,容器 limit 则是部署层硬边界。
入门阶段不要急着调参数。先限制输入大小、避免一次性加载、控制缓存容量、用 pprof 找热点。参数调优应该建立在观测和压测之上。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。