容器限了 2 核,Java 却慢十几倍?CFS 限流在偷偷挂线程
场景:容器限 2 核,流量一上来 RT 从 20ms 抖到 300ms,监控 CPU 却只有 40% 路径:cgroup 配额 → cpu.stat 限流证据 → CFS 内核原理 → 修复策略
上篇我们把 NUMA 跨节点内存访问延迟的坑填平了,这篇从内存寻址切到 CPU 调度——CPU 只用了 40%,Java 服务却卡成狗。
症状:应用层日志全正常,RT 却抖成心电图
现象:CPU 才 40%,接口 RT 却慢了十几倍
CPU 使用率只有 40%,接口 RT 却从 20ms 抖到 300ms——整整慢了 15 倍。
应用层日志、JVM 指标全无异常:GC 停顿正常,线程快照没有锁竞争,K8s Events 干干净净,一条 Warning 都没有。
直到我 cat 了一眼 cgroup 里的 cpu.stat,真相才露出来:线程被内核挂起了近 4 成的周期。
这不是应用层的问题——是内核在按周期偷偷限速。
那是一个订单服务,容器限了 2 核(requests: 500m,limits: 2),平时 RT 稳定在 20ms。双十一预热的流量一上来,RT 开始像心电图一样抖:20ms、45ms、180ms、300ms、40ms、220ms……监控面板上 CPU 使用率一直压在 40% 上下,远没到 2 核的 limit——按这个数据,服务不该卡。

三个经典无效操作:把 JVM 调了个遍
于是团队开启了三个经典无效操作:
❌ 操作一:调 JVM 堆 -Xmx4g -Xms4g —— 没用
❌ 操作二:把 G1 换成 CMS 调参数 —— 没用
❌ 操作三:把 Tomcat 线程池从 200 加到 500 —— 更卡了
三个操作全部白费,还搭进去半天。如果你也在容器里为 Java 性能调 JVM 参数,这篇值得看完——大概率你也在被内核偷偷限速。大多数人会把矛头对准 JVM——jstat 看 GC、jstack 看锁、调堆参数换 GC 器;正确方向是往下看内核层,两边的差异一眼见底:

差异在于:JVM 层的指标(GC 耗时、锁等待)全部正常,因为问题根本不在 JVM 能观测到的层面——线程是被内核调度器强制挂起的,Java 侧完全无感知。那 limits: cpu: 2 只是声明,真正执行限速的是内核的 CFS 配额机制。
诊察:从配额声明一路查到内核 cgroup
第 1 步:确认配额声明
先看容器声明了多大配额,命令带 -o yaml 看完整配置:
$ kubectl get pod order-svc-6f8c9d5f7-x8k2m -o yaml -n trade | grep -A 8 resources
resources:
limits:
cpu: "2"
memory: 2Gi
requests:
cpu: 500m
memory: 1Gi
limits: cpu: 2 是入口——但注意,它只是"声明",配额最终落在地是内核的 cgroup。
第 2 步:cgroup 配额换算
cgroup(control group)是 Linux 内核的进程分组机制,容器运行时把资源声明翻译成 cgroup 配额,交给内核强制。CPU limit 对应的是 CFS(Completely Fair Scheduler,完全公平调度器)的带宽控制:内核把时间切成周期(默认 100ms),每个周期允许 cgroup 内线程最多跑"核数 × 周期"的 CPU 时间。可以把它想成一张"100ms 计时卡":每 100ms 周期只准你消费 200ms 的 CPU 时间,用完了就把线程请出柜台,等下一张卡。
用 crictl(容器运行时命令行工具)看运行时最终的配额换算:
$ crictl ps | grep order-svc
$ crictl inspect <container-id> | grep -i -A 6 'quota\|period'
limit 2 核 → cpu.max = "200000 100000"
quota = 200000us(每 100ms 周期最多用 200ms CPU 时间)
period = 100000us(周期 = 100ms)
第 3 步:cpu.stat 里的限流铁证
配额是内核设的,限流证据也由内核记录。进容器直接读 cgroup 统计文件:

注意三个字段:nr_periods 是内核统计的周期总数(73419 个 100ms 周期),nr_throttled 是被限流的周期数(29000 个),throttled_usec 是被限流的总时长(1450000000 微秒 ≈ 1450 秒)。注意 cgroup v2 的时长单位字段叫 throttled_usec,只有 cgroup v1 才叫 throttled_time,下文版本差异处对照。近 4 成的周期被限流,累计被挂起约 24 分钟。 这不是偶发抖动,是每过几个周期就撞一次墙。
这里要提一个内核版本差异:以上是 cgroup v2 的路径(内核 5.15+ 的现代集群)。老内核用 cgroup v1,路径和文件名、字段名都不同:
# cgroup v2(现代内核)
/sys/fs/cgroup/cpu.stat → 限流统计(字段:nr_throttled / throttled_usec)
/sys/fs/cgroup/cpu.max → 看配额:"200000 100000"
# cgroup v1(老内核)
/sys/fs/cgroup/cpu/cpu.stat → 限流统计(字段:nr_throttled / throttled_time)
/sys/fs/cgroup/cpu/cpu.cfs_quota_us → 配额:200000
/sys/fs/cgroup/cpu/cpu.cfs_period_us → 周期:100000
这一步是系统诊断的关键:容器或 Pod 层怎么查都是"正常",只有下钻到内核 cgroup 才有证据。把三步输出串成一条互证链:声明 limits: cpu: 2 → 内核 cpu.max = 200000 100000 → cpu.stat 显示近 4 成周期被 throttle——声明、配额、限流三者闭环,缺一不可。注意排查到这里就可以收住——CPU 限流是单节点内核行为,CFS 配额由当前节点内核强制执行,不需要再往集群调度层面查。
路径:下次遇到这个现象,先跑这个命令组合
三步命令组合
遇到"容器 CPU 不高但服务卡"的现象,按这个顺序查,三步定位是不是 CPU 限流:

判断标准:nr_throttled / nr_periods 超过 20%,或者 throttled_usec(v1 是 throttled_time)在故障时段持续增长,就是 CFS 限流在作祟。看到 nr_throttled 比例异常 → 跑 cat cpu.max 确认配额 → 看 kubectl top pod 对比实际用量,三步闭环。
定位:这不是应用层问题——是内核 CFS 配额超限
系统层 vs 应用层:问题出在内核调度环节
这是典型的需要分清系统层和应用层的案例。应用层(JVM)的 GC、锁、线程池指标全部正常——因为线程没有被应用阻塞,是被内核调度器强制挂起的。CFS 的带宽控制器在每个周期检查配额:周期内 CPU 时间用满 quota(200000us)后,剩余请求全部排队到下一个周期。这就是 C2 层面的答案——出问题的是内核 CPU 带宽控制环节,不是任何应用组件。

为什么监控骗了你:40% 是真相,但不是全部真相
回到开头那个 40%。它不是假数据,而是"被允许跑的那部分的统计"。
container_cpu_usage_seconds_total 这个指标(容器 CPU 用量的基础指标)统计的是容器进程实际在 CPU 上运行的时间。而 CFS 限流把线程挂起的时间,不属于运行时间,根本不进这个计数器。
于是出现一个悖论:被限流的周期里,线程已经跑满 2 核配额、超额部分被强制挂起,但监控看到的平均用量反而"不高"——这就是 CPU 高不高和卡不卡脱节的原因。用 kubectl top pod 看实际用量:
$ kubectl top pod -l app=order-svc -n trade
NAME CPU(cores) MEMORY(bytes)
order-svc-6f8c9d5f7-x8k2m 795m 1180Mi
order-svc-6f8c9d5f7-q8n4p 812m 1176Mi
order-svc-6f8c9d5f7-r2m9k 788m 1188Mi
order-svc-6f8c9d5f7-w4t7j 803m 1172Mi
4 个 Pod 都压在 800m 上下,恰好对应 39.5% 的限流周期:每个周期最多跑满 2 核,被限流周期按满配额计(2000m),其余周期有富余,两者平均下来 ≈ 790m(≈40%)。用量看着"没满",但线程每个限流周期都被生生切掉一段运行权——这就是 RT 被拉长的直接来源。
第二个反转:JVM 能感知 limit,却感知不了限流
这是本文最容易被忽略的点。JDK 8u191+ 默认开启 UseContainerSupport(JVM 自适应容器资源的功能开关),JVM 会主动读 cgroup 的 CPU limit,用来决定 GC 线程数、默认线程池大小等 ergonomics(自适应优化)——也就是说,JVM 知道自己被限制在 2 核,并按 2 核优化了。注意一个版本细节:JDK 8u191 只识别 cgroup v1;在 cgroup v2 集群上要 JDK 8u372+(或 11.0.16+、17.0.4+)才能读到 limit——容器里若跑老 JDK,JVM 会按整节点核数开并行度,比文章场景更夸张。
但它感知不到的是限流本身。nr_throttled 的挂起是内核调度器行为,JVM 没有 API 能读到"我被挂起了多少次"。所以:
JVM 视角:我有 2 核,按 2 核配置并行度 → 但线程经常被挂起,并行计划全废
内核视角:这个 cgroup 每周期超配额,throttle
两者之间没有通道 —— 这就是为什么 Java 层所有调优都无效
-XX:ActiveProcessorCount=2 这类参数只能骗 JVM 少开线程,改变不了内核 quota 的事实。
这也回头解释了文章开头的操作三:线程池从 200 加到 500 反而更卡——线程越多,每个周期抢配额越凶,超限被挂起的也越多,并行计划彻底作废。
处方:修复策略矩阵
先排除一个错误修复:删掉 limit
先警告一个错误修复:直接把 limit 删掉。CPU limit 是"可压缩资源",删掉后容器可以吃满整节点,同节点的其他容器全被饿死,而且抢占方往往先被驱逐。这不是修复,是把炸弹扔给别人。
四个方案,按场景选
| 方案 | 什么时候选 | 代价 |
|---|---|---|
| ① 提高 limit | 压测证明峰值真实需求 > 2 核(如压到 4 核 RT 恢复稳定) | 失去部分限流保护,配额占用 |
| ② requests=limits + 合理值 | 服务 CPU 需求稳定,选 Guaranteed QoS(服务质量等级,requests=limits 的容器最不容易被驱逐) | 配额浪费,调度约束更紧 |
| ③ 降并发匹配 limit | 最干净:先算"2 核能支撑多少并发",把线程池/并行度压到配额内 | 无额外成本,需调参 |
| ④ HPA 水平扩展 | CPU 峰值是流量型,加副本分摊而不是加单核 | 需要应用无状态 |
方案③ 是性价比最高的:先看 cpu.max 确认配额,再用 kubectl top pod 看实际峰值,把 Tomcat 线程池从 500 降到与 2 核匹配的值(例如 100-150),配合 -XX:ActiveProcessorCount=2 让 JVM 的线程规划也收敛到 2 核——线程数减半,RT 反而稳定。
记住一句话:容器限流不是不给你资源——是按周期记账,超了先挂起再唤醒,线程数越多,账单越厚。

Check-list:记住这个命令组合
□ kubectl get pod <pod> -o yaml -n <ns> | grep -A 8 resources
→ 确认 limits.cpu,没有 limit 直接排除本问题
□ kubectl exec -n <ns> <pod> -- cat /sys/fs/cgroup/cpu.stat
→ nr_throttled/nr_periods > 20% → 坐实限流
□ kubectl exec -n <ns> <pod> -- cat /sys/fs/cgroup/cpu.max
→ 确认当前配额(v1 看 cpu.cfs_quota_us)
□ crictl inspect <container-id> | grep -i cgroupspath
→ 无 exec 权限时拿宿主机 cgroup 路径
□ kubectl top pod <pod> -n <ns>
→ 对比"显示用量"与 limit,相差悬殊且卡 = 限流
□ kubectl exec -n <ns> <pod> -- cat /sys/fs/cgroup/cpu.stat
→ 压测前/后各跑一次,对比 nr_throttled 增量是否随流量飙升
附:完整命令清单
# 确认资源声明
kubectl get pod <pod> -o yaml -n <ns> | grep -A 8 resources
# 容器层:拿 cgroup 路径(crictl 无需 exec)
crictl ps | grep <pod>
crictl inspect <container-id> | grep -i cgroupspath
# 限流证据(cgroup v2)
kubectl exec -n <ns> <pod> -- cat /sys/fs/cgroup/cpu.stat
kubectl exec -n <ns> <pod> -- cat /sys/fs/cgroup/cpu.max
# 限流证据(cgroup v1)
kubectl exec -n <ns> <pod> -- cat /sys/fs/cgroup/cpu/cpu.stat
kubectl exec -n <ns> <pod> -- cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
# 宿主机直接看(无 exec 权限,cgroup v2 形态)
cat /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod<uid>.slice/cri-containerd-<cid>.scope/cpu.stat
# 用量对比
kubectl top pod <pod> -n <ns>
📺 关注「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」 🔗 个人博客:https://opencao.cn
下篇我们聊中断均衡(irqbalance)配置不当导致单核 CPU 100%——又一道 CPU 表象反差的坑,这次卡在软中断上。Linux 不会无缘无故变慢——它只是在某个资源饱和时,用你读不懂的方式在等你发现。