先给场景。上周有个服务找我,描述很玄学:
「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 使用率」。前者告诉你资源有没有被抢走,后者只告诉你抢得有多努力。
工具链能给你的只有「看得见」,剩下的是「改不改得动」。而最难的第三步通常是:承认自己不是算力不够,是配额设计得不对。
—— 完 ——