Go 服务 p99 间歇性 GC 毛刺:GOGC 调大 vs heap ballast vs 代码侧少分配,线上到底怎么选?
该问题的正文、回答与评论。
【背景】 我做了 8 年性能优化,最近带团队复盘一个 Go 1.22 的网关服务:单实例 8C16G,QPS 约 1.2w,请求体平均 8KB 左右,主要开销在 JSON 编解码 + 下游 HTTP 调用。P50 稳定在 18ms,但 P99 会间歇性飙到 120~300ms,一天大概出现 30~50 次尖刺,每次持续 50~150ms。
【现象】 1)尖刺时刻 CPU 并不高(单核 60% 左右),网络/下游 RT 也没抖,慢日志里下游调用耗时正常; 2)GODEBUG=gctrace=1 抓到的日志:尖刺前 1~2s 恰好有一轮 mark+sweep,STW 阶段(mark termination + sweep)单次数十毫秒,累积起来正好对上 P99 的尖刺; 3)pprof heap 显示存活对象约 900MB,但分配速率在尖刺窗口内明显冲高(大量短命对象,主要是 []byte 和 map)。
【已尝试与结果】 1)GOGC 从 100 调到 400:P99 尖刺从 300ms 降到 150ms,但 RSS 从 2.5G 涨到 6.8G,内存压力大了,不敢再调; 2)GOMEMLIMIT=8G 加上后,GC 频率降了,但单次停顿反而更长了(存活集变大); 3)试过把 json.Unmarshal 换成 sonic,分配率降了 30%,P99 尖刺没消失,只是幅度小了一点; 4)看了 runtime/metrics 的 /gc/heap/allocs:bytes,确认尖刺和 GC 周期强相关,基本排除了下游和锁。
【期望的答案形态】 想请各位从真实线上案例的角度给点经验,最好能带上具体数字: 1)你们在类似场景(Go + 高 QPS + 大 JSON 编解码)下,最终是走「调 GOGC/GOMEMLIMIT」还是「代码侧减少分配」?各自的收益和代价是什么? 2)GOMEMLIMIT 到底怎么定?我现在的定法是 limit = 物理内存 × 0.5,有没有更靠谱的公式或经验值? 3)有没有用过 heap ballast(故意 hold 一块大 slice 抬高 heap 水位)?效果和副作用分别是什么,Go 1.19+ 还有必要用吗? 4)如果是代码侧优化,除了换 JSON 库,还有哪些「看起来无害但实际分配大户」的点值得排查?(比如 strings.Builder、fmt.Sprintf、map[string]interface{} 之类) 5)怎么把「GC 导致的 P99 毛刺」做成可观测指标?只看 gc_duration_seconds 够不够,要不要拆 mark/sweep 各阶段?
我这边更倾向代码侧少分配 + 适度调 GOMEMLIMIT的组合,但团队里有人主张直接 GOGC=1000 一了百了,想听听大家踩过的坑再定。