记录一次线上响应变慢的排查过程,根因是 GOMAXPROCS 和容器 CPU 限额不匹配。
现象
服务部署在容器里,limits.cpu = 1,也就是最多用满一个核。宿主是 8 核。
上线之后 P99 延迟从 40ms 涨到 300ms 左右,但 CPU 使用率看着不高(大概 0.9 核),内存也正常。QPS 没变,代码没改。
奇怪的地方在于:问题只在流量高峰出现,低峰期完全正常。
排查
先看 goroutine 数量,正常。看 GC,频率略高但不算异常。看 pprof 的 CPU profile,热点函数就是业务逻辑本身,没有意外的锁竞争。
然后我看了 runtime.NumCPU() 和 runtime.GOMAXPROCS(0) 的返回值——都是 8。
问题就在这。容器虽然限制了 1 核,但 Go 运行时读的是宿主的 CPU 数量。所以它创建了 8 个 P(逻辑处理器),8 个并发的 M 去抢那 1 个核的时间片。
后果:
- 大量时间花在线程上下文切换上,而不是干活
- 每个 goroutine 被切走再切回来的等待时间变长,尾延迟炸掉
- CPU 使用率的统计里,这种切换开销算在系统态,容易看不出来
Go 1.25 之前,运行时不会读取 cgroup 的 CPU 配额来设置 GOMAXPROCS。这是个长期存在的已知问题。
解决
短期方案,在启动时手动设置:
func init() {
// 读 cgroup 配额,设置合理的 GOMAXPROCS
if quota, ok := readCPUQuota(); ok {
n := int(math.Ceil(quota))
if n < 1 { n = 1 }
runtime.GOMAXPROCS(n)
log.Printf("GOMAXPROCS 设置为 %d(配额 %.2f 核)", n, quota)
}
}
读 cgroup v2 的配额:
func readCPUQuota() (float64, bool) {
// cgroup v2
data, err := os.ReadFile("/sys/fs/cgroup/cpu.max")
if err == nil {
fields := strings.Fields(string(data))
if len(fields) == 2 && fields[0] != "max" {
quota, err1 := strconv.ParseFloat(fields[0], 64)
period, err2 := strconv.ParseFloat(fields[1], 64)
if err1 == nil && err2 == nil && period > 0 {
return quota / period, true
}
}
return 0, false
}
// cgroup v1 回退
q, err1 := os.ReadFile("/sys/fs/cgroup/cpu/cpu.cfs_quota_us")
p, err2 := os.ReadFile("/sys/fs/cgroup/cpu/cpu.cfs_period_us")
if err1 != nil || err2 != nil {
return 0, false
}
quota, _ := strconv.ParseFloat(strings.TrimSpace(string(q)), 64)
period, _ := strconv.ParseFloat(strings.TrimSpace(string(p)), 64)
if quota <= 0 || period <= 0 {
return 0, false
}
return quota / period, true
}
改完之后 P99 回到 50ms 以内,比故障前还略好一点。
也有现成的库,go.uber.org/automaxprocs 做同样的事,而且考虑了更多边界情况(比如没设限额时不要乱改、cgroup 版本检测等)。后来我换成了这个库,自己那套删了:
import _ "go.uber.org/automaxprocs"
func main() {
// 导入即生效
}
几个补充的点
1. 不是所有容器都有这个问题。 如果 Pod 的 limits.cpu 没设,或者设了但和 requests 一样,行为又不一样。我们当时的配置是 requests 0.5 / limits 1,运行时只看得到宿主核数。
2. 设置 GOMAXPROCS 到底设多少? 网上说法不一。我的经验是设成配额向上取整,1 核就设 1。设成 2 有时候吞吐更好(因为 GC 和业务可以并行),但要多测。
3. 别忘了 GC 也是并发的。 GOMAXPROCS=1 的时候 GC 和业务抢同一个核,如果对象分配率高,GC 开销会很明显。这时候可能需要调 GOGC 或者设 GOMEMLIMIT。
4. Go 1.25 之后这个行为改了。 运行时开始感知 cgroup 限制,理论上不用手动设了。但升级 Go 版本本身在有些项目里就是个大工程,而且行为变化可能导致性能特征变化,升级后要重新压测。
从这次故障学到的东西
容器里跑 Go,第一件事就是确认 GOMAXPROCS。 加一行日志,启动时打出来,一眼就能看到对不对。我们后来说把这个加到了启动检查清单里。
CPU 使用率不高不代表没有 CPU 问题。 上下文切换、调度延迟这些开销不会体现在"使用率"这个数字上,要看 container_cpu_cfs_throttled_seconds_total 和节点的负载。
尾延迟比平均值敏感得多。 这次平均延迟只涨了 20%,P99 涨了 7 倍。如果只看平均,可能就当成正常波动放过去了。
压测环境要和线上配置一致。 我们压测机是独占 8 核的物理机,怎么压都没问题。这个差异让问题在上线后才暴露。