CPU 利用率陷阱:为什么平均指标会掩盖性能杀手
对于许多工程师来说,当系统变慢时,第一直觉是查看 CPU 利用率图表。如果曲线一直徘徊在 40% 或 60%,通常会得出一个直接的结论:CPU 不是瓶颈。然而,这种对平均利用率的依赖可能是一个危险的陷阱,尤其是在像 Kubernetes 这样的容器化环境中。
当生产环境中的 Go 函数尽管仪表盘显示 CPU 水平健康,却开始出现 context deadline exceeded 错误时,你面对的并不是总容量不足的问题——你可能正在处理 Linux 内核执行资源限制时的细微差别。
平均值的谬误
平均 CPU 利用率是一个成本指标,而不是性能指标。它回答的问题是“我们支付的费用是否超过了实际使用量?”,但它无法回答“我的应用程序现在是否获得了它所需的 CPU 时间?”
对于延迟敏感型工作负载,利用率与等待时间之间的关系是非线性的。基于 M/M/1 排队模型,利用率从 80% 跳到 81% 带来的等待时间增幅,可能远大于从 10% 跳到 11% 的增幅。
| CPU 利用率 | 等待一个 10 ms 请求的时间 |
|---|---|
| 10% | ~11 ms |
| 80% | ~50 ms |
| 95% | ~200 ms |
随着利用率攀升,“余量”会消失,延迟会在 CPU 达到 100% 之前就大幅飙升。但即便如此,这仍无法解释最隐蔽的问题: “不可见”的节流(throttling)。
抽象层的泄漏:CFS Throttling
在 Docker 和 Kubernetes 中,CPU 限制是通过完全公平调度器(CFS)配额系统实施的。当你设置限制为 2000m(2 个 vCPU)时,内核并不会严格限制容器只能使用两个核心;相反,它在每个调度周期(通常为 100ms)内授予一个时间预算。
这就是抽象层泄漏的地方:一个容器可以在宿主机节点上的所有可用核心上消耗完其 200ms 的预算。如果宿主机有 4 个核心,一个资源密集型的请求可以在短短 50ms 的实际墙钟时间(wall-clock time)内,通过所有四个核心的爆发式运行耗尽 200ms 的预算。
一旦预算耗尽,容器就会被节流(throttled)。它会被完全冻结,直到下一个 100ms 周期开始。对于以一分钟为单位计算平均 CPU 使用率的监控工具来说,使用率看起来很低。但对于在第 51 毫秒到达的请求来说,系统在接下来的 49ms 内是完全无响应的。
这创造了一种模式:p99 延迟飙升,而平均 CPU 利用率却保持在令人误解的低水平。Indeed Engineering 对这一现象进行了著名的记录,在他们的案例中,即使应用的使用率远低于分配的核心数,在大部分 100ms 周期内仍会经历节流。
如何检测和缓解饥饿问题
由于标准仪表盘会隐藏这些突发情况,你必须查看内核级指标来寻找真相。
1. 检查 Cgroup 统计数据
查看是否被节流最直接的方法是检查 /sys/fs/cgroup/cpu.stat。寻找以下指标:
nr_throttled: 容器被节流的次数。throttled_usec: 容器被冻结的总时间。
如果这些计数器在上升,说明你的资源限制对于你的突发模式来说太紧了。
2. 监控压力停滞信息 (PSI)
内核 PSI (cpu.pressure) 提供了饱和信号。它报告任务处于可运行状态但无法运行的时间百分比,即使你在 CFS 配额范围内,它也能捕捉到竞争情况。
3. 关注 Steal Time
在虚拟化环境中,检查“窃取时间”(top 中的 %st)。当 hypervisor 为了服务其他租户而从你的 VM 中夺走物理 CPU 时,就会发生这种情况,无论你的内部限制如何,都会导致你的代码停滞。
4. 应用层检测
最稳健的解决方案是在应用程序内部进行饥饿检测。像 Redpanda(通过 "reactor stalls")和 CockroachDB 这样高性能的系统会监控 goroutine 从变为可运行状态到实际运行之间的时间。如果这种延迟超过了阈值(例如 1ms),应用程序可以通过削减后台工作来优先处理前台请求,从而做出反应。
超越图表
开发人员与 IT/运维部门之间往往存在紧张关系。当开发人员要求增加 CPU 以修复延迟飙升时,管理员可能会指着 40% 利用率的图表,并基于“效率”或强制执行严格限制的合规指南来拒绝请求。
为了打破这种僵局,我们必须将对话从利用率转向饱和度。生产系统的目标不是最大化 CPU 使用率,而是确保应用程序平稳运行。通过在平均利用率之外展示节流指标和 PSI,团队可以基于实际性能而非误导性的平均值做出决策。
正如一位社区贡献者所指出的,问题不在于指标本身,而在于解释:“兔子洞深不见底,如果你不知道如何正确解释它们,指标就会对你撒谎。”