CPU 才 50% 却卡成狗:你的服务没算力不够,是被内核掐了脖子

2026-11-07 1 0

先给场景。上周有个服务找我,描述很玄学:

「QPS 没涨,流量没涨,CPU 也没打满,但 P99 延迟每隔 100 毫秒就抖一下,一抖就是几十毫秒。重启一下能好半小时,然后照旧。」

我第一反应是「GC」「网络」「GC」「GC」。但查下来都不是。最后定位到一个极其朴素、极其容易被忽略的东西:CPU 被 cgroup 节流了(CFS throttling)。

这篇咱们把这个「明明 CPU 没满,却慢得像满了」的坑讲透。不谈玄学,只谈内核里那 100 毫秒的账怎么算的,以及怎么在十分钟内把它揪出来。


一、先破除一个误解:CPU 使用率 100% ≠ 你的进程吃满了 CPU

你在 Grafana 看到的「container CPU 使用率 100%」,测的是被 cgroup 允许使用的时间片已经被用完,而不是「宿主机 CPU 真的忙不过来」。这两件事差别巨大。

宿主机可能还有 60% 的 CPU 闲置,你的容器却因为把配额用超了,被内核强行按住——按下限的时间,整个 cgroup 里的所有进程一起停摆。这个现象有一个非常形象的名字:节流(throttling)。

所以典型的迷惑现场就是:

  • 宿主机的 mpstat 显示 Load 不高,CPU 还有大量空闲;
  • 你的容器里 CPU 使用率却长期贴着 100%;
  • 延迟曲线不是随机抖动,而是有节奏的锯齿;
  • 把容器 CPU 配额往上调一档,延迟立刻改善。

第四条是最有说服力的自证。如果真是网络问题、真是 GC,调 CPU 配额不会有任何效果。


二、内核是怎么「抢」走你的 CPU 的

Linux 的 CFS(Completely Fair Scheduler,公平调度器)不只管「谁先跑」,还管「你能跑多少」。这个能力叫 CPU 带宽控制(bandwidth control)。

它的模型非常朴素,就两个数:

  • quota(配额):每个周期内这个 cgroup 最多能用多少 CPU 时间;
  • period(周期):一个周期多长,内核默认 100 毫秒。

假设你配了 --cpus=2,容器实际拿到两核,那 quota 就是 200000(单位微秒),period 是 100000。翻译成人话:

每 100 毫秒里,你这个容器最多可以花 200 毫秒的 CPU 时间(因为它有两个核,可以并行)。花完了,就等着下一个周期开始。

关键在「等着」这两个字。配额耗尽的瞬间,内核不会温柔地降速,它会把 cgroup 里的任务全部 suspend,直到 period 结束。这意味着:你的进程可能正好停在一次 socket 写、一次 GC 标记、一次 mutex 获取的中间,整个进程冻结最长 100 毫秒。

而请求到达的时间是随机的,于是每个周期被冻结的请求落点也随机。表现出来就是:延迟分布里出现一簇一簇的毛刺,簇与簇之间大约间隔 100 毫秒。

这就是为什么这种故障最容易被误诊成「偶发网络抖动」——因为它确实长得像抖动,而且不重启不消失(重启后 CPU 使用率曲线暂时回落,一两分钟又爬回去,然后毛刺重现)。


三、确诊:三个文件,三分钟出真相

cgroup v1 和 v2 的路径不一样,别搞混。先看你是哪一套:

# 看自己是不是 cgroup v2(统一层级,目录里全是 cpu.max 这种平铺文件)
stat -fc %T /sys/fs/cgroup/

# v2
cat /sys/fs/cgroup/cpu.max        # 形如 "200000 100000",第一个是 quota,第二个是 period
cat /sys/fs/cgroup/cpu.stat        # 关键:nr_throttled 和 throttled_usec

# v1
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
cat /sys/fs/cgroup/cpu/cpu.cfs_period_us
cat /sys/fs/cgroup/cpu/cpu.stat     # 关键:nr_throttled 和 throttled_time(纳秒)

判断标准非常干脆:只要 nr_throttled 在涨,你就中招了。

# 隔 10 秒采两次,看节流次数是否在快速增长
watch -n 10 'cat /sys/fs/cgroup/cpu.stat | grep -E "nr_throttled|throttled"'

如果你在 Kubernetes 上,别去每个容器里手撸,cAdvisor 已经把这件事变成了 Prometheus 指标:

# 节流发生的时间占比,理想值 0
rate(container_cpu_cfs_throttled_seconds_total{container!=""}[5m])
  / rate(container_cpu_cfs_periods_total{container!=""}[5m])

# 有节流的容器
container_cpu_cfs_throttled_seconds_total > 0

把这条 rate(throttled)/rate(periods) 画成图挂在 Grafana 上,比任何猜测都管用。我的经验是:只要这个比例长期大于 1%,你的尾延迟就一定在受苦,哪怕 P50 看起来漂亮得不像话。

顺便说一句,很多团队的监控只画了「CPU 使用率」,而节流问题的特征恰恰是使用率高、宿主机还空闲。不加 container_cpu_cfs_throttled_seconds_total,你就永远在看一个自欺欺人的仪表盘。


四、别搞错凶手:三种「假节流」

确诊之后,先排除这几位嫌疑人,不然容易白折腾:

1)宿主机抢不到核(Steal Time)。你在虚拟机里跑,宿主机本身超卖。这时看 vmstat 的 st 列,或者 Prometheus 的 node_cpu_seconds_total{mode="steal"}。宿主机抢走你的 CPU 和 cgroup 限你的 CPU,症状像,根因完全不同。

2)真的 CPU 不够。mpstat -P ALL 1 看核心利用率,如果各核都贴着 100% 且 st 接近 0,那可能真就是算力不足,加配额就是正解,别搞复杂。

3)GC 或锁竞争伪装成了节流。Go 服务可以看一眼 GODEBUG=gctrace=1,Java 服务抓一份 JFR。GC STW 通常伴随明显周期和单独的指标,不看就容易误判。

判断顺序:先看宿主机有无 steal → 再看是不是真的算力不足 → 最后才怀疑节流。三步都排除不了,再去翻 cgroup。


五、修复:先量配额,再谈技巧

确诊节流后,手段其实很朴素,按性价比排序:

1)给足配额,并且把请求量和配额对齐。很多团队的 requests 和 limits 设成一样,等于告诉内核「你随时会被掐」。经验值是把 limit 设为 request 的 2~3 倍,让突发流量有余量;同时把 HPA 的目标设成 limit 的 50%~60%,而不是贴着 limit 跑。

resources:
  requests:
    cpu: "500m"          # 调度用
  limits:
    cpu: "2"            # 上限
# HPA target CPU utilization: 55  ← 按 limit 的 55%,不是 requests 的 55%

注意 cpu.max 是整核倍数,500m 就是半核,cpu.max 会写成 50000 100000。半核配额意味着你连一个完整的突发都扛不住。

2)用 burst 换掉「硬停 100ms」。Linux 6.7 之后 cgroup v2 提供了 cpu.max.burst,允许在周期边界临时透支一段额度,用来做平滑,效果立竿见影:

echo "200000 100000" > /sys/fs/cgroup/cpu.max
echo "50000"        > /sys/fs/cgroup/cpu.max.burst   # 允许临时多用 50ms

内核不支持的话,老实说还得靠加配额。

3)降低单请求的 CPU 成本。节流是「结果」,不是「原因」。压缩日志级别、砍掉正则回溯、把 JSON 序列化挪出热路径、少做几次 fmt.Sprintf——每一个省下来的毫秒都是在给配额松绑。Go 服务尤其值得看 pprof 的 CPU profile 里有没有那种「宽得离谱的单帧」。

4)用副本数换配额。3 副本各拿 1 核,通常比 1 副本拿 3 核的尾延迟更好,因为配额是按 cgroup 独立计算的,而且多副本还能扛住单点的抖动。

5)顺手修掉运行时的 CPU 认知错误。这就是 Go 的老坑:1.25 之前 runtime 只看 sched_getaffinity,容器里限额 2 核、宿主机 64 核,它就敢开 64 个 P,疯狂调度疯狂 GC,把你本来就紧张的配额烧得更凶。1.25 之后 runtime 会主动读 cgroup 限额,官方堵上了;老版本请显式设置:

// 容器启动时
GOMAXPROCS=2 /app/server

// 或者代码里
runtime.GOMAXPROCS(2)

Java 那边对应的是 -XX:ActiveProcessorCount 与 container support(8u191 之后默认开启),同样别让 JVM 以为自己在 64 核机器上。


六、一个容易被忽略的细节:节流是「整组」的

最后提醒一个反直觉的点:被节流时,冻结的是这个 cgroup 里的所有进程,包括你的 sidecar、包括日志收集器、包括那个你以为跟业务无关的健康检查进程。

所以有时候你会看到很奇怪的现象:主进程 CPU 才 60%,日志 agent 占了 40%,然后整个 Pod 卡成狗。此时的优化方向可能不是「给主进程加资源」,而是给 sidecar 限个 CPU:

# 给 sidecar 单独设个上限,别让它抢主进程的口粮
resources:
  limits:
    cpu: "100m"

以及别忘了 CPU request/limit 的粒度问题:100m 的 limit 在很多集群里是按 10ms 粒度执行的,实际会向上取整成 200ms 或 1000ms 内核周期。别指望精细到毫秒级的配额,那种精度 CPU 调度器本来也不支持。


七、写在最后

这类问题的教训很朴素:你以为在解决「CPU 不够」,其实问题在于「CPU 被抢走了」。

前者要加资源,后者要改配额和节奏,是两条完全不同的路。判断依据也很朴素——

  • 宿主机 CPU 空闲、你的容器 100%、延迟呈周期性锯齿 → 节流;
  • 宿主机 CPU 也满、各核都 100%、steal 为 0 → 真缺算力;
  • steal 明显偏高 → 你在别人的虚拟机上抢食,换宿主机或者换机型。

至于监控,我只留一句忠告:把「节流占比」这条曲线加到仪表盘上,优先级高于「CPU 使用率」。前者告诉你资源有没有被抢走,后者只告诉你抢得有多努力。

工具链能给你的只有「看得见」,剩下的是「改不改得动」。而最难的第三步通常是:承认自己不是算力不够,是配额设计得不对。

—— 完 ——

相关文章

你的 JSON 序列化,正在偷偷吃掉 30% 的 CPU:Go 后端的 pprof 现场
你的 SQL 没写错,为什么还是会死锁?MySQL 锁机制实战与排查手册
数据库没慢,是连接池在排队:一条公式算清你的池子该开多大
库存还剩 1,却卖出了 3 单:MySQL 行锁、间隙锁和一次真实死锁的现场复盘
你的 Go 服务在偷偷漏 goroutine:context 取消、channel 阻塞,以及那些看不见的泄漏
凌晨三点,所有副本同时醒来:定时任务惊群实战解剖

发布评论