关键词: Kubernetes, CPU Throttling, CFS Quota, Linux Cgroups, Java GC, 性能优化
👻 引言:监控仪表盘上的"幽灵"
你是否经历过这样的灵异事件:
- Java 应用的接口响应时间(RT)偶尔出现几百毫秒甚至秒级的尖刺。
- 查看 Pod 监控,CPU 使用率(Usage)平稳得像一条直线,只有 10% - 20%,远未达到 K8s 设置的 Limit。
- GC 日志正常,没有 Full GC,也没有长时间的 STW。
- 应用代码查不出任何死锁或阻塞逻辑。
既然资源充足,应用为何卡顿?
答案通常藏在操作系统的底层调度器里:Linux CFS (Completely Fair Scheduler) 的带宽控制机制。你看到的"平均低负载",是统计学对你的欺骗。
一、 核心概念:CPU 不是"速度",是"时间片"
在 K8s 中,当你设置 resources.limits.cpu: "1" 时,你以为你限制的是并发度(比如只能跑满 1 个核),但实际上,Docker/Containerd 到底层是通过 Cgroups 的两个参数来控制的:
cpu.cfs_period_us(周期): 默认通常是 100ms (100,000us)。cpu.cfs_quota_us(配额): 你拥有的 CPU 时间预算。
L i m i t = Q u o t a P e r i o d Limit = \frac{Quota}{Period} Limit=PeriodQuota
如果你设置 Limit = 1 Core,意味着:在每 100ms 的周期内,你的容器最多可以使用 100ms 的 CPU 时间。
二、 案发现场:多线程的"加速燃尽"陷阱
问题的核心在于:Quota 是按"CPU 时间"计算的,而不是"物理时间"。
对于单线程应用,这没问题。但 Java 是典型的多线程应用(Tomcat 线程池、ForkJoinPool、GC 线程)。
场景还原:
假设你给 Pod 限制了 Limit = 1 Core (Quota = 100ms / Period = 100ms)。
监控显示当前 CPU 使用率只有 20%(即平均每秒只用了 200ms CPU 时间)。看起来非常安全,对吧?
但在微观的某 10ms 里,发生了什么?
- 一个复杂的 HTTP 请求进来,触发了业务逻辑。
- 应用瞬间唤醒了 10 个线程 并行处理(或者 GC 触发了并行回收)。
- 这 10 个线程同时在 CPU 上跑了 10ms。
计算一下消耗:
10 (Threads) × 10 m s (Run Time) = 100 m s (CPU Time) 10 \text{ (Threads)} \times 10ms \text{ (Run Time)} = 100ms \text{ (CPU Time)} 10 (Threads)×10ms (Run Time)=100ms (CPU Time)
后果:
- 物理时间只过去了 10ms。
- CPU 配额 (100ms)在第 10ms 这一瞬间被全部耗尽。
- Linux 内核介入: "对不起,你这个周期的粮票用完了。"
- Throttling (节流): 在接下来的 90ms (100ms - 10ms) 里,这个 Pod 的所有线程都会被操作系统强制挂起(Throttled),无法获得任何 CPU 时间片。
这就是"诡异停顿"的真相:
即便你下一秒没有任何请求,CPU 使用率平均下来很低,但在那关键的 90ms 里,你的应用处于"假死"状态。外部看来,就是一个原本 10ms 能处理完的请求,硬生生变成了 100ms+。
三、 为什么 Java 受伤最深?
Java 应用相比其他语言(如 Node.js 单线程),更容易触发这个问题,主要有两个原因:
-
GC (垃圾回收): 即使是 Minor GC,也需要 Stop-The-World (STW)。为了缩短 STW,JVM 默认会开启并行 GC 线程(
ParallelGCThreads)。如果你有 4 个 GC 线程,它们一旦启动,就会以 4倍速 燃烧你的 CPU Quota。一旦 Quota 耗尽,GC 线程被挂起,STW 的时间就会被操作系统强行拉长。- 现象:GC 日志显示 User Time 远大于 Real Time。
-
线程池模型: Java Web 容器(Tomcat/Jetty)通常预设几十上百个线程。流量突发时,线程同时争抢 CPU,极易触达 Quota 天花板。
四、 如何证实与自救?
不要猜,看指标。
1. 关键指标 (Prometheus)
不要只看 container_cpu_usage_seconds_total。
必须通过以下 PromQL 监控 Throttling 的严重程度:
promql
# 统计每分钟被节流的周期比例
rate(container_cpu_cfs_throttled_periods_total[1m])
/
rate(container_cpu_cfs_periods_total[1m])
如果这个数值大于 0 (哪怕是 1%-5%),说明由于 Limit 限制导致的卡顿正在发生。
2. 解决方案
-
方案 A(最直接):移除 CPU Limits
- 只设置
requests,不设置limits。让 Pod 可以利用宿主机空闲的 CPU 资源。 - 风险: 可能会出现"吵闹邻居"问题,需要配合节点级别的 QoS 策略。但在很多公司,这是解决延迟敏感型应用(如交易系统)的最佳实践。
- 只设置
-
方案 B(调大):增加 CPU Limits
- 如果你一定要设 Limit,请根据峰值而非均值来设。通常建议 Limit 至少是 Request 的 2-4 倍。
-
方案 C(升级):利用内核特性 CPU Burst
- 较新的 Linux 内核(及支持该特性的 K8s 版本)支持 CFS Burst。它允许容器在空闲时"积攒"一部分未使用的 Quota,以此应付突发的流量尖峰,避免立即被 Throttled。
-
方案 D(JVM 调优):限制并行度
- 显式告诉 JVM 不要以为自己拥有宿主机所有的核。
-XX:ActiveProcessorCount=N:限制 JVM 看到的处理器数量,减少 GC 线程数。
五、 总结
"CPU 使用率低"不代表"CPU 够用"。
在云原生环境下,CPU 资源不再是连续的流,而是被切碎的"时间票据"。Java 应用的多线程突发特性,极易在短时间内花光票据,导致被操作系统按在地上摩擦。
排查此类问题,请务必关注 throttled_seconds,而不是仅仅盯着 Usage 看。