跳转至

CPU 闲置但接口卡死

01 前置理论与核心机制

1. 为什么 CPU 只有 30% 接口却出现卡顿?

  • 监控工具的采样周期限制:常规使用的 top、docker stats 或 Prometheus 监控面板,数据采样精度通常为秒级(1s - 15s)。监控展示的 CPU 30%,是 1 秒内的平均利用率。但在毫秒级的时间窗口内,容器线程可能经历了频繁的“运行-被强制挂起-运行”状态切换。
  • 排查二分法:当 CPU 未打满且排除了代码层面的死锁与锁竞争时,接口耗时增加通常只有两种底层原因:要么线程在等待 I/O(如磁盘、网络),要么线程被内核剥夺了 CPU 执行权(强制挂起)。

2. Linux CFS 调度器与微观限流机制 (Quota Throttling)

  • 时间切片分配:K8s 的 limits.cpu(或 Docker 的 --cpus)底层依赖 Linux cgroups 的 CFS(完全公平调度器)。CFS 并非按“整秒”分配 CPU,而是默认以 100 毫秒 (100,000 微秒) 为一个调度周期(cfs_period_us)。
  • 多核并发耗尽配额:假设配置了 --cpus=1(配额 cfs_quota_us=100000),代表每 100 毫秒周期内,容器最多使用 100 毫秒的 CPU 时间。若宿主机有 8 个物理核,且容器内有 8 个并发线程同时处理请求,它们仅需运行 12.5 毫秒(12.5ms * 8 = 100ms),就会将该周期的 100 毫秒配额全部耗尽。
  • 强制挂起:配额耗尽后,内核会在剩余的 87.5 毫秒 内将容器内的所有线程强制挂起(Throttled)。业务处理随之停滞,直到下一个 100 毫秒周期开始才会被唤醒。这是导致“CPU 平均使用率低,但接口延迟高”的核心原因。

02 故障排查与恢复闭环 SOP

遇到监控面板上 CPU 利用率不高,但业务接口出现几百毫秒至秒级的偶发延迟毛刺时,按以下步骤排查:

步骤一:锁定容器 PID 与排除 I/O 阻塞

优先确认是否存在磁盘 I/O 阻塞:

# 获取目标容器在宿主机上的真实 PID
docker inspect -f '{{.State.Pid}}'

# 使用 pidstat 观测该进程的详细 I/O 与 CPU 状态 (每秒1次,输出3次)
pidstat -u -d -p  1 3
  • 观测点:重点查看 iodelay 列以及 kB_rd/s、kB_wr/s。如果 iodelay 为 0 或处于极低水平,且用户态 (%usr) 存在一定消耗,说明线程并未阻塞在磁盘 I/O 上。排除 I/O 瓶颈后,转入内核调度排查。

步骤二:检查内核 Cgroup 调度统计数据

读取 Cgroup 底层文件,确认容器线程是否被 CFS 调度器限流:

# 查看容器的 CPU 调度统计信息 (路径根据 cgroup v1/v2 或 K8s 路径略有不同)
cat /sys/fs/cgroup/cpu/docker//cpu.stat
# K8s 环境下通常在: /sys/fs/cgroup/cpu/kubepods/pod//cpu.stat
  • 核心观测点:
  • nr_periods:经过的调度周期总数。
  • nr_throttled:容器触发限流的周期总数。
  • throttled_time(或 throttled_usec):容器被强制挂起等待的累计时间(纳秒/微秒)。
  • 定性判断:在压测或业务高峰期,连续多次执行 cat cpu.stat。若观察到 nr_throttled 和 throttled_usec 数值持续快速增长,即可确认容器正在触发严重的 CPU 调度限流。

步骤三:核实当前的资源限制配额

检查容器当前的 CPU 资源限制配置:

# 检查 Docker 限制
docker inspect  | grep -i -E "nanocpus|cpus"

# 或检查 K8s Limits
kubectl get pod  -o yaml | grep -A 2 limits

  • 验证:若存在类似 NanoCpus: 1000000000(即 1 核)或 cpu: "1" 的配置,可确认是由该配额触发了 CFS 限流机制。

步骤四:应急止血(动态释放配额)

生产环境优先恢复业务。在宿主机整体 CPU 资源充裕的前提下,可动态移除 Cgroup 限制,释放被挂起的线程:

# 动态将 CPU Quota 设为 -1 (无限制),即时生效,无需重启容器
docker update --cpu-quota -1

步骤五:业务恢复验证

配额释放后,立即测试接口响应耗时:

# 测试接口连通耗时
time curl -s -o /dev/null "http://localhost:8080/api/test"

  • 结果比对:若接口耗时从几百毫秒降回正常的十几毫秒,说明排查结论准确。事后需修改 K8s YAML 的 limits.cpu 配置并重新发布。

03 真实生产故障现象与案例库

案例 1:Java 线程池高并发引发的限流阻塞

  • 监控现象:某核心接口响应时间 P99 升至 500ms,但 Pod CPU 监控最高仅达到 40%。
  • 排查结果:查看 cpu.stat 发现 nr_throttled 占比高达 30%。
  • 根因与处置:微服务所在节点为 32 核物理机,Pod 限制了 2C。突发高并发请求唤醒 Tomcat 线程池内 200 个线程同时处理任务。2C 的配额(200ms/周期)在几十毫秒内被 200 个并发线程迅速耗尽,导致整个 JVM 在周期剩余时间内被全部挂起。处置方案为放宽或移除 K8s limits.cpu 限制。

案例 2:GC 停顿时间被系统级放大

  • 监控现象:Java 服务的 Young GC 日志显示,原本仅需 15ms 的 GC 偶尔会延长至 120ms。
  • 排查结果:pidstat 未见 I/O 异常,结合内核指标分析发现,GC 执行期间触发了显著的 CFS 限流。
  • 根因与处置:JVM 的并行 GC 机制会拉起多个 GC 线程全速运行,瞬时高并发极易触发 CFS Quota 限制。这导致 GC 线程在回收阶段被内核强制挂起,直接拉长了 STW (Stop-The-World) 的持续时间。处置方案为显式调低 JVM 的 GC 并发线程数(-XX:ParallelGCThreads),或调大容器 CPU Limit。

案例 3:Go/Node.js 异步框架的响应延迟

  • 监控现象:Node.js 进程 CPU 负载较低,但接口偶发 502 或 Timeout。
  • 排查结果:底层 Cgroup 的 throttled_time 指标存在高频增长。
  • 根因与处置:即便以单线程事件循环为主的 Node.js,其底层的 libuv 依然维护着多线程 Worker Pool 处理加密、压缩等计算任务。突发并发下后台线程耗尽时间片,导致主事件循环 (Event Loop) 被同步挂起,无法及时处理新连接。处置方案为合理上调 CPU Limit 配额。

04 生产排障速查表(CPU 闲置与延迟毛刺)

遇到 CPU 使用率不高但请求延迟增加,按序排查:顺序排查层级执行命令 / 观测点异常定性处置动作01全局状态docker stats / K8s 监控负载 < 50%,但接口耗时 > 100ms确认并非常规的 CPU 算力耗尽02I/O 排查pidstat -d -p <PID> 1iodelay 为 0 且磁盘吞吐正常排除等待磁盘读写导致的线程阻塞03锁与死锁jstack / 代码排查无明显的 BLOCKED 或锁等待排除代码层面的同步锁竞争04内核指标cat .../cpu.statnr_throttled 数值频繁增长确认为内核 CFS 调度器触发限流05紧急释放docker update --cpu-quota -1临时移除 Cgroup 限制释放线程执行权,验证接口耗时是否恢复06根治方案修改 YAML / 集群规范调大或移除 limits.cpu,保留 requests.cpu实现 CPU 弹性使用,消除微观周期挂起

05 面试应答参考话术

面试官问:“线上微服务偶尔卡顿几百毫秒,进容器查看 CPU 利用率只有 30%,宿主机负载也不高。代码没有死锁,遇到这种情况怎么排查?”

可以这样回答:遇到 CPU 平均利用率低、接口却出现高延迟的现象,不能仅依赖传统的秒级监控。在排除代码锁竞争后,核心排查方向有两个:一是线程在等待 I/O,二是线程在微观层面触发了操作系统的调度限流。第一步:排除 I/O 阻塞

首先获取容器的主进程 PID,使用 pidstat -d 观察容器内线程的 I/O 状态。如果 iodelay 为 0,且系统态消耗较低,即可排除磁盘读写阻塞。第二步:排查 CFS 内核调度限流

排除 I/O 后,直接检查 Cgroup 的 CPU 调度状态,执行 cat /sys/fs/cgroup/cpu/cpu.stat。 重点关注 nr_throttled(限流次数)和 throttled_usec(被挂起时长)这两个指标。如果在业务高峰期,观察到这两个计数器的数值持续快速增长,即可确认:容器内的线程被 Linux 内核的 CFS 调度器强制挂起了。第三步:阐述底层机制与修复方案

其底层原因在于:CFS 调度器默认以 100 毫秒为周期分配 CPU 配额。假设容器限制为 1 核(即每周期 100 毫秒配额),在多核宿主机上,当突发并发请求唤醒十几个线程同时运行时,可能在周期的前 10 毫秒内就将这 100 毫秒的配额全部耗尽。在随后的 90 毫秒内,内核会将容器内所有线程强制挂起。因此,秒级监控看到的平均 CPU 利用率只有 30%,但在毫秒级周期内,业务线程已被反复挂起,从而产生几百毫秒的接口延迟毛刺。在应急恢复上,若宿主机资源充裕,可执行 docker update --cpu-quota -1 动态解除硬限制,优先恢复业务响应;后续在 K8s YAML 中,建议调大 CPU Limit,或针对延迟敏感型业务移除 CPU Limit,仅保留 Requests 用于集群调度。

06 面试高频追问与应答预案

追问 1:为什么 K8s 社区部分实践建议“不设置 CPU Limit”?

  • 回答要点:
  • CPU 属于可压缩资源(Compressible Resource)。当宿主机 CPU 争用激烈时,进程分配到的时间片减少,表现为处理变慢,但不会像内存(不可压缩资源)那样触发 OOM 导致进程崩溃。
  • 严格设置 CPU Limit 容易在多核并发场景下触发 CFS 限流,引入不必要的延迟毛刺。移除 Limit(同时合理设置 Request 以保障基础算力与调度准确),可使业务在宿主机空闲时充分利用冗余算力,提升应对突发流量的能力。

追问 2:如何区分 CPU Steal Time 和 CFS Throttled?

  • 回答要点:
  • Steal Time (%st) 属于虚拟化(Hypervisor)层面的指标。表示当前虚拟机请求 CPU 资源时,因底层物理宿主机超卖或繁忙,未能及时分配物理 CPU 时间片,该指标可通过 top 命令直接观测。
  • CFS Throttled 属于操作系统内核(Cgroup)层面的指标。是虚拟机或物理机内部的 Linux 内核,依据 Quota 配额规则主动挂起当前容器进程的行为。该状态无法通过 top 直接看出,必须读取 Cgroup 下的 cpu.stat 文件进行确认。

07 自动化限流检测巡检脚本(check_cfs_throttled.sh)

该脚本用于扫描宿主机上所有容器的 Cgroup 状态,自动筛查触发内核调度限流的容器。

#!/usr/bin/env bash
# ==============================================================================
# 脚本名称: check_cfs_throttled.sh
# 功能描述: 扫描容器 CPU CFS Throttled 限流指标,筛查高延迟风险业务 (纯只读)
# 使用方式: 在运行容器的宿主机/K8S Node 上以 root 权限执行
# ==============================================================================

set -o pipefail

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
PLAIN='\033[0m'

echo -e "${BLUE}================================================================${PLAIN}"
echo -e "${BLUE}           容器 CFS CPU 调度限流 (Throttled) 安全巡检           ${PLAIN}"
echo -e "${BLUE}================================================================${PLAIN}"

# 寻找 Cgroup v1 的 docker 目录 (K8s pod 目录结构可通过类似方式遍历)
CGROUP_CPU_DIR="/sys/fs/cgroup/cpu/docker"

if [ ! -d "$CGROUP_CPU_DIR" ]; then
    CGROUP_CPU_DIR="/sys/fs/cgroup/cpu/system.slice" # 兼容部分 systemd cgroup
    if [ ! -d "$CGROUP_CPU_DIR" ]; then
         echo -e "${YELLOW}[INFO] 未找到常规的 Cgroup CPU 目录,可能使用的是 Cgroup v2 或非标准路径。${PLAIN}"
         exit 0
    fi
fi

echo -e "${YELLOW}正在扫描目录: ${CGROUP_CPU_DIR} ...${PLAIN}\n"

# 遍历寻找 cpu.stat
find "$CGROUP_CPU_DIR" -name "cpu.stat" | while read -r stat_file; do
    container_id=$(echo "$stat_file" | awk -F'/' '{print $(NF-1)}' | cut -c 1-12)

    # 提取指标
    nr_periods=$(grep -w "nr_periods" "$stat_file" | awk '{print $2}')
    nr_throttled=$(grep -w "nr_throttled" "$stat_file" | awk '{print $2}')

    if [ -z "$nr_periods" ] || [ "$nr_periods" -eq 0 ]; then
        continue
    fi

    # 计算限流周期占比百分比
    throttle_pct=$(echo "scale=2; ($nr_throttled / $nr_periods) * 100" | bc)

    # 只有当限流比例超过 5% 时才告警
    if (( $(echo "$throttle_pct > 5.0" | bc -l) )); then
        # 尝试获取真实的容器名称 (依赖 docker 命令)
        if command -v docker &>/dev/null; then
            cname=$(docker ps --filter "id=${container_id}" --format "{{.Names}}" 2>/dev/null)
            [ -z "$cname" ] && cname="Unknown/K8s_Pod"
        else
            cname="Unknown"
        fi

        echo -e "${RED}[ALERT] 发现限流超标容器: ${cname} (ID: ${container_id})${PLAIN}"
        echo -e "  -> 总调度周期 (nr_periods)  : $nr_periods"
        echo -e "  -> 被挂起周期 (nr_throttled): $nr_throttled"
        echo -e "  -> 强制限流比例             : ${YELLOW}${throttle_pct}\%${PLAIN}"
        echo -e "  -> 诊断提示: 业务易出现延迟毛刺,建议调大配额或执行 docker update --cpu-quota -1 移除限制。\n"
    fi
done

echo -e "${GREEN}[OK] 巡检完毕。仅展示限流比例超过 5\% 的容器。若无输出,说明当前系统 CPU 调度正常。${PLAIN}"
echo -e "${BLUE}================================================================${PLAIN}"