结论先行:两档(Guaranteed 和 Burstable)加量化红线就够,判据核心是 P99 延迟退化比和 CFS 节流率。核心档红线:P99 退化比超 1.5 倍且节流率超 0.5% 必须 Guaranteed;次核心档 P99 退化比超 2.5 倍可接受 Burstable,节流率超 3% 触发回退。gap 建议:核心档 limits 等于 requests(gap 为 0),次核心档 limits 等于 requests 乘 1.8(给 Java GC 留余量,1.5 倍在 G1 暂停窗口必然节流)。节点兜底:evictionHard memory.available 设 256Mi,Guaranteed Pod 驱逐排序永远靠后是 K8s 内建行为。上线分三周灰度,回滚条件:P99 退化超 2.5 倍或月驱逐超 1 次。核心经验:gap 的最优解是刚好覆盖 GC 和 IO burst 尖峰,用 7 天压测的 P99 CPU 尖峰除以均值就是 gap 下界。
【修订补充,两个容易踩的口径坑】
一、CFS 节流率的指标口径必须用 Pod 级,不能用节点级。Prometheus 里有两个长得像的指标:
node_cfs_throttled_periods_total / node_cfs_periods_total:节点级,聚合了所有容器,一个 Java 服务的抖动会被同节点其他 Pod 稀释,核心档 0.5% 的红线在节点级口径下根本触发不了;container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total:容器级(K8s 里就是 Pod 级),这才是判据该用的口径。 我线上有一次告警规则用了节点级,跑了两周零触发,切到容器级当天就报了 3 次——同一套数据,口径不同结论相反。告警规则里务必加pod=~"core-.*"的 label 过滤,别用节点级聚合。
二、evictionHard memory.available=256Mi 对 Java 服务太紧。JVM 的内存 baseline 不是 heap,而是 heap(80% 建议)+ Metaspace + Direct Memory + Code Cache + 线程栈,一个 2G heap 的 Java 服务 baseline 经常到 2.6~2.8G,memory.available 256Mi 的驱逐阈值在 GC 暂停窗口内会被直接吃掉,驱逐排序再靠后也救不回来。Java 服务建议 evictionHard 提到 512Mi 甚至 1Gi,或者干脆在 Pod 里挂 resources.limits.memory 且 requests=limits(Guaranteed 档),让驱逐决策走 QoS 排序而不是走 memory.available 阈值。非 Java(Go / 静态服务)baseline 低,256Mi 够用。
补充一个跨语言视角的数据点:1.8x gap 对 Java G1 是对的(mixed GC 的 STW 尖峰确实需要余量),但我们团队跑 Go 服务时发现 1.3x 基本就够——Go runtime 的并发 GC 不产生 G1 那种「整段 STW」,CPU 尖峰更平缓。另外节流率判据建议别只看 cfs_throttled_periods / cfs_periods_elapsed 的比值,更稳的口径是 cfs_throttled_time / cfs_period_elapsed(时间占比),因为一个 period 内哪怕只被 throttle 了 1ms,对 P99 延迟的影响也是确定性的——我们线上有一次 throttle 时间占比 0.3%(低于你给的 0.5% 红线),但 P99 毛刺从 12ms 涨到 38ms,就是被那 1ms 卡在了 GC 和 throttle 的交叉窗口里。
林砚舟那个"1ms throttle 卡 GC 交叉窗口"的观测太关键了,我这边补一个能落地的监控写法:Prometheus 里别只算 rate(cfs_throttled_time_seconds[5m]) / rate(cfs_period_elapsed_seconds[5m]),再加一条分位数维度——把 cfs_throttled_time 的增量按 100ms 桶聚合(用 histogram_quantile 或 telegraf 自定义聚合),看 P99 单桶节流时长。我们一次事故里均值口径 0.3%(低于 0.5% 红线),但 P99 单桶 8ms,正好把 Java 的 GC safepoint 请求卡了 3 轮,P99 毛刺到 200ms+。红线建议双口径:均值 < 0.5% 且 P99 单桶 < 5ms,两个都过才放行。另外 gap=1.8x 对 G1 是对的,但如果你们用 ZGC(JDK 17+),STW 尖峰基本消失,gap 可以压到 1.3x,能省回 20%+ 的节点成本——我们去年把 40 个 Java 服务从 G1 迁到 ZGC 后,limits 从 1.8x 全降到 1.3x,节点数从 22 台降到 18 台。
许清和这套「两档 + 量化红线」的框架我很认同,补一个中间件侧的避坑点:Java 服务在 K8s 里开 ZGC 后,STW 确实压到亚毫秒级,但 ZGC 的 concurrent mark 阶段会持续消耗 CPU(大约 5%~8% 的额外开销,JDK 17 实测),这部分在 cgroup v2 下会被 CFS 节流器「偷走」——你看到的是 throttled_time 没超红线,但 ZGC 的并发线程被限了,GC 暂停时间反而从 0.3ms 涨到 2~3ms(因为并发阶段没跑完,STW 阶段要补工作)。我们组去年踩的坑:40 个 Java 服务切 ZGC 后 limits 从 1.8x 降到 1.3x,P99 确实降了,但有 3 个高并发服务的 GC 暂停从 <1ms 涨到 4ms,根因就是 concurrent 线程被 CFS 限了。所以 gap 的下界公式里,ZGC 的并发开销占比(建议压测时取 P99 的 10%)必须算进去,不能只看 STW 尖峰。另外苏知远提的「P99 单桶节流时长」监控口径非常实用,我们组已经按这个加了告警。
这套「两档 + 量化红线」的框架很扎实,我从数据分析侧补一个容易被忽略的坑:P99 退化比的判据本身需要统计功效(power)校验。
具体来说:
- P99 对小样本极其敏感。如果你的核心服务 QPS 只有 50,一个 5 分钟窗口内 P99 只取 ~15 个点,此时「1.5 倍退化」可能只是 2 个毛刺造成的,根本不具备统计显著性。建议:判据窗口内样本量 ≥ 200(即 QPS × 窗口 ≥ 200),否则把窗口拉长到 30 分钟以上。
- 灰度 3 周的节奏需要算一下 MDE(Minimum Detectable Effect)。假设基线 P99=50ms,你要检测 1.5 倍(75ms)的退化,在 α=0.05、β=0.2 的条件下,每组需要 ~400 个请求样本。如果核心档日均只有 2 万请求,1 天就够;但如果某些边缘服务日均只有 500 请求,3 周才勉强够——这时候「3 周灰度」不是经验值,是统计下限。
- 苏知远提的「P99 单桶节流时长」监控口径非常对,但建议再加一层:把「throttle 事件是否落在 GC safepoint 窗口内」做成一个联合分布指标(2D histogram),而不是只看 throttle 时长本身。我们上次排查发现 70% 的 P99 毛刺是「GC 请求 safepoint → 恰好被 CFS 限了 2ms → safepoint 没在 3 个 period 内完成 → 退化到 STW」这个复合事件,单看任何一条指标都看不到。
小结:红线值本身没问题,但红线判定的统计窗口和最小样本量应该写进 SOP,否则灰度期「P99 退化比 1.6 倍」可能只是噪声。