并发标记-清除 (非分代·非压缩): 两次极短 STW + 并发标记 — GOGC / GOMEMLIMIT 的 pacer 逻辑
Go GC 是并发三色标记-清除: 把对象涂成白(垃圾候选)/灰(待扫)/黑(存活), 从根出发染色; 染色期间用户代码照常跑, 靠混合写屏障保证并发不漏标。停顿亚毫秒、与堆大小基本无关; 代价是标记期吃掉 ~25% CPU, 且默认策略下内存峰值约 2 倍存活堆 — 调 GOGC / GOMEMLIMIT 就是在这笔账上做取舍。
func sum(a []int) int { // 值语义: 中间量在栈上 t := 0 for _, v := range a { t += v } // 不进堆 → 不给 GC 添活 return t } // 关键: 短命对象大多栈上消失, 分代 scavenging 收益薄
type node struct{ next *node } // size-class 分配, 天然低碎片 // 挪对象 = 修全部指针 = STW 拉长 → 不压缩 // 关键: 堆不主动还 OS, RSS 常驻偏高是常态 debug.FreeOSMemory() // 唯一主动归还口 (慎用)
// 并发标记中用户改指针 — 黑→白 新边: black.ptr = white // 若无屏障可能漏标 → 活对象被收, 致命 // 写屏障: 把 white 标灰, 宁可多标成浮动垃圾 // 关键: 多标=下轮再收(无害), 漏标=数据损坏(致命)
触发点 = live × (1 + GOGC/100)。GOGC=100: 4G 存活 → 堆涨到 8G 触发下一轮。调小省内存但 GC 更频繁; GOGC=off 只在批处理+显式 runtime.GC() 场景使用。// live heap = 4G 时: GOGC=100 // → 4G × 2.0 = 8G 才触发 (默认) GOGC=50 // → 4G × 1.5 = 6G 触发, 更勤更省内存 debug.SetGCPercent(50) // 关键: 运行时同样可调
# K8s memory limit = 1Gi 时: GOMEMLIMIT=921MiB ./app // ~90%, 含堆外 debug.SetMemoryLimit(921 << 20) // 代码内同理 // 关键: 逼近时加速 GC 而非 OOM; 超限只加力度
buf := make([]byte, 16<<20) // 大分配 → assist 债务暴涨 // 该 goroutine 被强征去帮 GC 标记 → 请求变慢 // 关键: 根因是分配速率, 降分配才是根本解
GODEBUG=gctrace=1 每轮打印: 触发堆/目标堆/存活/暂停; runtime/metrics 或 pprof 看 GC CPU 占比与分配速率。# GODEBUG=gctrace=1 ./app # gc 12 @8.301s 4%: [8.2 8.4 8.8] ms → ... # ^第12轮 ^GC占CPU ^三阶段耗时 // 关键: live 周期性增长=泄漏; % 持续高=该降分配率
服务 live heap ~400MB, 默认 GOGC=100 会放任堆冲到 800MB+, 加上堆外余量逼近 limit:
// 方案: 直接限定内存软上限, 让 Pacer 自己算频率 GOMEMLIMIT=900MiB GOGC=100 ./app # 内存紧张时它自动加大 GC 频率换安全; 空闲时不浪费 CPU
8G 堆的服务, GOGC=100 意味着每"多 4G 垃圾"才 GC, 单轮标记久、CPU 抖动明显:
GOGC=50 // 触发点 = live × 1.5: GC 更勤但每轮更轻, 毛刺变小 # 内存富余 (机器 16G 只用 6G) 时这是免费的质量提升
GODEBUG=gctrace=1 ./app # gc 12 @8.301s 4%: [8.2 8.4 8.8] ms → # ^第12轮 ^触发时刻 ^GC占CPU ^三个阶段耗时 # 后面跟 live/新分配/... — 周期性增长 = 泄漏; %持续高 = 该降分配率
跑完就退出的批任务, 中途 GC 纯属浪费 — 关掉, 分段手动收:
func runBatch(chunks []Chunk) { old := debug.SetGCPercent(-1) // GOGC=off: 不自动 GC defer debug.SetGCPercent(old) // 退出前恢复(进程复用时安全) for i, c := range chunks { process(c) if i%100 == 0 { runtime.GC() } // 每 100 段收一次, 内存有界 } }
10GB 级缓存服务单独调哪个参数都不稳, 组合拳:
GOGC=80 GOMEMLIMIT=12GiB ./cache-service // GOGC=80: 单轮 GC 更轻(触发更早), 毛刺小 // GOMEMLIMIT: 硬兜底 — 逼近 12G 时 Pacer 强制加速, 防 OOMKill // 观测确认: gctrace 里 heap goal 与 total alloc 速率是否匹配预期
凌晨批处理吃掉 8G 后白天空闲, RSS 居高不下 — 低峰主动归还(慎用):
if lowTraffic() { // 流量谷底窗口才执行 runtime.GC() // 收一轮: 活对象压实 debug.FreeOSMemory() // 把空闲 span 还给 OS (STW 短暂停) } // 注意: FreeOSMemory 全局暂停, 高峰调用 = 自造毛刺; 只配定时低峰任务
GC 扫描成本正比于指针数 — 同样 100 万元素, 指针切片扫描量差一个数量级:
type item struct{ id int64; score float64 } // 无指针字段! items := make([]item, 1_000_000) // 好: 连续内存, span 无指针, GC 跳过 // 对比 make([]*item, 1_000_000): 百万个指针全要三色标记 // 大索引/缓存结构优先值切片 + 下标代替引用
gctrace 之外, 程序内可按需采样官方指标:
import "runtime/metrics" sample := []metrics.Sample{ {Name: "/gc/heap/allocs:bytes"}, // 累计分配 {Name: "/gc/heap/live:bytes"}, // 存活堆 {Name: "/gc/cycles/total:gc-cycles"}, // GC 轮数 } metrics.Read(sample) // 差值除以时间 = 分配速率; live 持续增长 = 泄漏
调参必须同负载对比, 固定流程四步:
// 1. 固定压测负载与数据集 (重放流量) wrk -t8 -c200 -d120s --latency http://svc/api # 2. A 组: 默认 GOGC=100; B 组: GOGC=50 GOGC=50 ./svc & wrk ... > b.txt # 3. 对比: P99 / GC次数(gctrace) / 峰值RSS 三张图 # 4. 内存富余→选 GOGC 小的(毛刺低); 内存吃紧→保守值+GOMEMLIMIT
池在 GC 时被清空, 大 buffer 池要意识到这个节律:
var bigBufPool = sync.Pool{ New: func() any { b := make([]byte, 64<<10); return &b }, } // Get/Hit 率打进指标: GC 后命中率骤降属正常(victim 池缓冲一轮) // 若业务流量周期 ≈ GC 周期, 池形同虚设 → 改为结构体持有 + 复用切片字段
# 错: "Go GC 零停顿, 不用关心" GODEBUG=gctrace=1 ./svc # gc 12 @8.301s 4%: [8.2+8.4+8.8] ms ← 有停顿+吃 CPU # 对: 低延迟目标下压分配率 (pprof alloc_objects)
# 错: 看到 RSS ≈ 2×live 就报"泄漏" GODEBUG=gctrace=1 ./app # ... 4->8->4 MB live 稳定波动 ← 设计使然 GOGC=50 ./app # 对: live 稳定时调参省内存
var p = sync.Pool{New: func() any { return fetch() }} // 错: 把 Pool 当缓存 — GC 时被清空, 数据"凭空丢失" // 对: Pool 只做短期复用降分配率 // 真缓存用带 TTL 结构 (ristretto / bigcache)
delete(m, k) // 错: bucket 稀疏, span 不还给 OS m = make(map[K]V, n) // 对: 定期重建换新实例 // 或分片轮转; 监控看 live heap 而非 RSS
debug.SetGCPercent(-1) // 错: 常驻服务 = 攒垃圾到 OOM for i, c := range chunks { // 对: 批任务+显式收口 process(c) if i%100 == 0 { runtime.GC() } // → 内存有界 }
# 错: 一上来就调 GOGC 硬扛 go tool pprof http://:6060/debug/pprof/alloc_objects # top10 → 对: 先降分配热点 (见逃逸分析三板斧) # 参数调节是最后一步, 不是第一步
# 错: limit=1Gi 却设 GOMEMLIMIT=8Gi (无保护) # 或 GOMEMLIMIT=live+10MB (GC 颠簸吃光 CPU) GOMEMLIMIT=921MiB ./app # 对: limit 的 ~90% # 且与 live heap 保持余量, 观测 GC 频率不飙升
GOGC=100 // 错: 理解成"最多用 100% 内存" // 实际: live×2 才触发, 完全没有上限概念 GOMEMLIMIT=900MiB // 对: 控内存用软上限 // 关键: GOGC 只调 CPU/内存兑换比例
delete(m, oldKeys...) // 错: 以为堆会被"压紧" fresh := make(map[K]V, len(m)) // 对: 重建换新实例 for k, v := range m { fresh[k] = v } // → 空洞随旧 map 释放
runtime.SetFinalizer(f, cleanup) // 错: 当资源释放主通道 // 对: 显式 Close 为主, finalizer 只兜底报警 defer f.Close() // → 一轮 GC 即真正释放
runtime.SetFinalizer(obj, func(*Obj) { save(obj.path) }) // 错: 闭包捕获 obj → 复活, 永不释放 runtime.SetFinalizer(obj, func(o *Obj) { save(o.path) }) // 对: 用收到的参数 o, 不额外捕获
buf := make([]byte, 16<<20) // 错: 热路径反复大分配 // → 该请求被 assist 拉去标记, 延迟飙升 buf = bufPool.Get().(*[]byte) // 对: 复用降分配率才是根本
# 错: 只看 pause ns 就宣布"GC 无影响" # gc 12 @8.301s 4%: [8.2+8.4+8.8] ms # ^^ 对: 这个 GC CPU 占比字段一起看 # + go_gc_duration_seconds 趋势; 30% 即拖垮吞吐
# 错: live=800M 就把 GOMEMLIMIT 设 850M # goroutine 栈/cgo/mmap 也占 RSS → OOMKill GOMEMLIMIT=921MiB ./app # 对: limit=1Gi 的 ~90% # → 堆外余量留出来, Pacer 自动加速兜底
debug.FreeOSMemory() // 错: 高峰调用 = 自造全局 STW 毛刺 if lowTraffic() { // 对: 只在定时低峰窗口 runtime.GC() debug.FreeOSMemory() // → 空闲 span 还给 OS }
# 错: 升级 Go 版本后直接上生产 # 对: 同负载压测, 对比 GC 次数与 RSS wrk -t8 -c200 -d60s --latency http://svc/api > new.txt # 与旧版本 old.txt 对齐对比; gctrace 看 GC 轮数变化
type node struct{ next *node } // 错: 全指针, 标记+缓存双杀 type table struct{ nodes []node } // 对: 值切片连续存放 next := int32(i + 1) // int32 下标当"指针"
# 错: P99 毛刺先怪 GC curl -s :6060/debug/pprof/profile?seconds=30 > cpu.pb # 对: 对齐毛刺时刻看 profile — 锁竞争/IO 重试同样常见 # 毛刺时刻与 gc 周期真正重合才能定 GC 的罪
# 错: 常年 GODEBUG=gctrace=1,schedtrace=1000 跑生产 # 对: 采样式开启 (故障复现窗口才打开) # 常态观测用指标: go_gc_duration_seconds # go_memstats_alloc_bytes_total
# 错: GOGC=37 无注释 → 半年后没人知道为什么 env: - name: GOGC value: "37" # 2026-03 压测: P99 -18%, 报告 docs/bench-gc-37.md # 对: 参数+压测报告链接进配置注释, 调整走评审