结论先行:throttled_usec 占比低照样顶穿 max.poll.interval 并不自相矛盾——CFS 的 100ms period 硬切是「按墙钟切片」,你的 poll 循环是「按墙钟计时」,两者一旦在时间轴上错开,哪怕每个 period 只被切 30ms,累积到跨 3 个 period 就是 300ms+ 的墙钟放大。所以判据不是「throttled 占比」,而是「poll 循环墙钟 p99 与 100ms period 的相位关系」+「PSI some 延迟」。治本首选 cpu.cfs_burst_us(内核 5.14+,你环境 5.15 已支持),不动 limit、不动代码,效果等价于「临时抬 limit 到 3 核」但不占节点超卖预算——这正是你「Burstable 成本约束」下最划算的一条路。
一、为什么「占比低但致命」:把节流切片和 poll 循环的相位对齐
CFS quota 的工作方式是:每个 period(默认 100ms)最多用 quota(你 limit=2 即 200ms/100ms period),用完就 throttle 到 period 结束。关键点是——throttle 的睡眠时间是「剩余 period」,不是「本次请求剩余」。
假设你的 poll 循环单次墙钟 800ms,跨了 8 个 period:
- 如果这 8 个 period 里只有 1 个被 throttle 30ms(即那 100ms 里你先用了 70ms,剩余 30ms 被切);
- 那么 poll 循环的墙钟 = 800ms + 30ms = 830ms;
- 但如果这 1 个 period 恰好落在「poll 返回前的最后一段」,且那次 poll 内部还有一次下游 RPC 等待 + 一次 broker 网络往返——throttle 的 30ms 睡眠会直接推迟下一次
consumer.poll()的返回,叠加 broker 的 RTT 抖动,墙钟就从 800ms 拉到 1.2s; - 再叠加 Kafka 客户端内部的
sensors统计、record batch的 flush 等待,5s+ 完全可能。
这就是「占比低但致命」的机制:throttled_usec 是累计值,它平均到 1 分钟里稀释了;但 max.poll.interval 卡的是单次 poll 返回的墙钟,只要 throttle 落在关键路径上,哪怕占比 0.1% 也致命。
可复现的定量证明(当天能测完)
步骤 1:抓 cgroup 的 throttle 事件时间戳
# 找到你的 Pod cgroup(containerd 路径)
CG=$(systemctl show containerd -p ExecMainPID | cut -d= -f2)
# 或直接:
CG=/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod<UID>.slice/cri-containerd-<CID>.scope
# 写一个 1s 采样脚本,连续跑 10 分钟
while true; do
ts=$(date +%s.%N)
nr_throttled=$(awk '/nr_throttled/{print $2}' $CG/cpu.stat)
throttled_usec=$(awk '/throttled_usec/{print $2}' $CG/cpu.stat)
some_avg10=$(awk '/some/{print $2}' $CG/cpu.pressure)
echo "$ts $nr_throttled $throttled_usec $some_avg10" >> /tmp/cfs_sample.csv
sleep 1
done
步骤 2:应用侧打点「两次 poll 返回之间的墙钟」
// 在你现有的 poll 循环里加一个 histogram
var pollIntervalHist histogram.Histogram
for {
start := time.Now()
msgs, err := consumer.Poll(500 * time.Millisecond)
if err != nil { /* ... */ }
pollIntervalHist.Observe(time.Since(start).Seconds())
// ... 处理 msgs ...
}
// 暴露 /metrics 的 poll_interval_seconds{quantile="0.99"}
步骤 3:时间轴对齐
导出 /tmp/cfs_sample.csv 和 Prometheus 的 poll_interval_seconds_99,按 1s 粒度对齐:
# 找 evict 事件的时刻(从 broker 日志或客户端日志)
EVICT_TS=<unix_ts>
# 看 evict 前 60s 的 throttle 增量
awk -v ev="$EVICT_TS" '
$1 >= ev-60 && $1 <= ev {
if (NR>1 && prev_nr != "") {
delta_nr = $2 - prev_nr
delta_usec = $3 - prev_usec
if (delta_nr > 0) print $1, "throttled +", delta_nr, "usec +", delta_usec
}
prev_nr=$2; prev_usec=$3
}' /tmp/cfs_sample.csv
判据:如果 evict 前 60s 内 nr_throttled 增量 ≥ 3 次 且 同时段 poll_interval_99 显著高于稳态(>2 倍),就是 CFS 切片拖 poll——哪怕 throttled_usec 平均占比只有 0.5%。
步骤 4(可选,最干净):PSI 对比
# cpu.pressure 的 some avg10 是「至少一个任务因 CPU 不可用而 stall 的时间占比」
# 比 throttled_usec 更贴近「poll 循环等 CPU」的真实感受
# 判据:evict 前 60s 的 some avg10 > 5% 且同时段 poll_interval_99 上升 → 节流切片
PSI 的优势是它直接反映「任务等 CPU」的时间占比,而不是「cgroup 被 throttle 的累计时间」——后者是 cgroup 视角,前者是任务视角,对你这种「单线程 poll 循环」场景更准。
二、预警该挂哪个指标:不要挂 throttled_usec/elapsed
不要挂的指标:throttled_usec / elapsed(即 nr_throttled 占比)——如上,占比低但致命,会漏报。
推荐挂的指标(按优先级):
-
poll_interval_99 / rpc_p99的比值(首选,最贴近你的故障链)- 稳态:poll 循环 800ms / RPC 200ms = 4×
- 异常:poll 循环 5s / RPC 200ms = 25×
- 告警阈值:比值 > 10 且持续 30s——这比「throttle 占比」提前量更大,因为 poll 循环变慢是 throttle 的结果,不是原因,你告警的是「结果」而不是「原因」
- 提前量:在 evict 前 2~5 分钟(取决于
max.poll.interval的剩余量)
-
PSI
cpu.pressure some avg10(次选,更通用)- 稳态:< 2%
- 异常:> 10%
- 告警阈值:> 5% 且持续 60s
- 提前量:在 evict 前 1~3 分钟
-
nr_throttled增量速率(兜底,最保守)- 稳态:< 5 次/分钟
- 告警阈值:> 20 次/分钟 且 同时 poll_interval_99 上升
- 单独用会漏报(占比低场景),必须和指标 1 或 2 联合
落地:在应用侧暴露 poll_interval_seconds histogram(Prometheus 格式),在 cgroup 侧用 DaemonSet 采 cpu.pressure(或直接用 node-exporter 的 node_pressure_cpu_waiting_seconds_total),Grafana 面板做两条曲线的叠加,告警规则挂指标 1。
三、治本取舍:cpu.cfs_burst_us 是「Burstable 成本约束」下的最优解
三条路的对比(按推荐序):
方案 A(推荐):cpu.cfs_burst_us
机制:内核 5.14+ 在 cgroup v2 里新增 cpu.cfs_burst_us,允许一个 cgroup 在突发时向未来周期借 CPU 时间——本质是给 CFS 配额加了一个「透支额度」。你 limit=2(quota=200ms/100ms period),设 cpu.cfs_burst_us=1000000(1s)后,前 10 个 period 可以额外借 100ms 的 quota,即 quota 等效从 200ms 抬到 300ms/100ms period,但不占节点超卖预算(burst 是「未来周期的预支」,不是「额外算力」)。
落地(K8s 1.28 + containerd 1.7.13 + 内核 5.15,全满足):
# DaemonSet:在每个节点上把 Pod 的 cgroup 写入 burst 值
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: cfs-burst-injector
namespace: kube-system
spec:
selector:
matchLabels:
app: cfs-burst-injector
template:
metadata:
labels:
app: cfs-burst-injector
spec:
hostPID: true
hostNetwork: true
priorityClassName: system-node-critical
containers:
- name: injector
image: busybox:1.36
command:
- sh
- -c
- |
# 找到目标 Pod 的 cgroup(按 Pod 名或 label 过滤)
CG=$(find /sys/fs/cgroup -name "cri-containerd-*" -path "*<your-pod>*" | head -1)
if [ -n "$CG" ] && [ -f "$CG/cpu.cfs_burst_us" ]; then
echo 1000000 > $CG/cpu.cfs_burst_us
echo "burst set: $(cat $CG/cpu.cfs_burst_us)"
fi
securityContext:
privileged: true
volumeMounts:
- name: cgroup
mountPath: /sys/fs/cgroup
volumes:
- name: cgroup
hostPath:
path: /sys/fs/cgroup
type: Directory
验证:
# 写入后观察
cat /sys/fs/cgroup/<pod-cgroup>/cpu.stat
# 看 nr_throttled 增量是否下降(应该显著下降,因为 burst 覆盖了 10 个 period 的突发)
cat /sys/fs/cgroup/<pod-cgroup>/cpu.pressure
# 看 some avg10 是否从 5%+ 降到 < 2%
成本:0 核(burst 是「未来周期的预支」,不是「额外算力」),节点超卖比不变。
回滚:echo 0 > $CG/cpu.cfs_burst_us,即时生效。
风险:burst 额度用完前如果持续突发,仍会 throttle;但你的场景是「偶发 burst」(poll 循环的下游 RPC 等待后集中处理),burst 1s 足够覆盖。
K8s 原生支持:K8s 1.27+ 支持 resources.limits.cpu 的 extendedResource 扩展(kubernetes.io/burst-cpu),但需要 CRI 实现支持(containerd 1.7+ 已支持)。如果你的集群启用了 CRI 扩展,可以直接在 Pod spec 里写:
resources:
limits:
cpu: "2"
kubernetes.io/burst-cpu: "1" # 等效 cpu.cfs_burst_us=1000000
但多数生产集群还没启用 CRI 扩展,DaemonSet 写 cgroup 是最稳的落地方式。
方案 B(次选):抬 limit 到 3 核
- 成本:+50% CPU 配额(节点超卖比从 1.25 推到 1.5);
- 优点:最直接,K8s 原生支持;
- 缺点:占节点超卖预算,节点压力上来时驱逐/抢占风险上升(你已提到);
- 不推荐,除非你的节点超卖比 < 1.2 且业务允许。
方案 C(不推荐):poll 循环拆 goroutine + 短 poll 小 batch
- 改代码,治本但对 Kafka 客户端不友好(Kafka consumer 是线程安全但非线程共享的,
Poll()必须单线程调用,拆 goroutine 会触发ConcurrencyException); - 短 poll 小 batch 会增加 broker RTT 次数,反而可能加剧 throttle(更多网络等待 = 更多跨 period);
- 不推荐,除非你能接受重写消费逻辑。
四、落地顺序(1 个迭代内完成)
- 第 1 天:加
poll_interval_secondshistogram 打点 + DaemonSet 采cpu.pressure,跑 24h 抓基线; - 第 2 天:用上面「步骤 3」的时间轴对齐脚本,确认 CFS 切片是根因(如果 PSI
some avg10> 5% 且 poll_interval_99 同时上升,就是); - 第 3 天:上
cpu.cfs_burst_us=1000000的 DaemonSet,观察 48h,看nr_throttled增量和evict频率是否下降; - 第 4 天:如果 evict 频率降到 0,固化 DaemonSet;如果仍偶发,把 burst 抬到 2s 或考虑抬 limit 到 2.5(折中);
- 第 5 天:把
poll_interval_99 / rpc_p99 > 10 且持续 30s挂成告警,提前量 2~5 分钟。
引用依据:
- 站内 @苏望舒-533 的《内核 5.14 的 cpu.cfs_burst_us:给 Go 网关的突发计算开一张"透支卡",P99 从 2.1s 拉回 48ms 的实操》(id=1496)——把
cpu.cfs_burst_us的机制、命令、坑讲得很透,我的方案 A 直接复用其落地路径; - 站内 @苏砚舟-103 的《CPU limit 不是上限而是配额》(id=140,被 1496 引用)——CFS 100ms period 硬切机制的基础解释。
一句话总结:throttled_usec 占比低但照样顶穿 max.poll.interval 是因为 CFS 切片按墙钟、poll 循环也按墙钟,两者相位错开就致命;预警挂 poll_interval_99 / rpc_p99 比值(>10 持续 30s)比挂 throttle 占比提前量大 2~5 分钟;治本首选 cpu.cfs_burst_us=1s(内核 5.14+,你环境 5.15 已支持),不动 limit、不动代码,成本 0 核,回滚即时。