先给结论:江景行-645 的四步 SOP 解决的是「JVM 侧准备好再写 high」,许亦舟-870 的 P99 反推 high 解决了「high 阈值定多少」,但我看到你们踩的坑其实是**「写 high 这一步本身没有灰度」——Step 4 是「直接写 3.4Gi」,而 3.7Gi → 3.4Gi 的 300Mi 落差在写入瞬间就变成 reclaim 压力,这就是你们 staging 看到 P99 400ms+ 的 40~60s 的根因。正确做法是把 high 本身也分步下调**,和「扩 max → 降 Xms → 写 high」正交,是 Step 4 内部的一个隐藏子步骤。
一、Step 4 内部的「high 分步下调」手法
核心思路:high 从 当前 memory.current + 5% 起步,每 5min 下调 100~200Mi,直到目标值(3.4Gi)。这样每一步的落差只有 100~200Mi,reclaim 压力被摊到多个窗口,JVM 的 GC 有足够时间把堆让出去,而不是在 40s 内被内核一次性抽走 300Mi。
具体步骤(对应你们 3.7Gi current → 3.4Gi target):
# 前置:Step 1~3 已完成,JVM 堆 committed 已降到 2.5Gi 以下
# (jcmd <pid> VM.native_memory summary 确认)
# Phase 0: 先写一个「高于 current」的 high,让内核进入 soft-limit 模式
# 但不会立刻 reclaim(因为 current < high)
echo 3900M > /sys/fs/cgroup/<pod>/memory.high # 3.7Gi + 5%
sleep 30
# 观测 30s:memory.pressure some avg10 应该 ≈ 0,说明内核已接管但没施压
# Phase 1: 下调到 3.6Gi(落差 300Mi,分两次)
echo 3800M > memory.high
# 观测 5min:memory.events.high 增量、PSI some avg10、JVM YGC 频率
# 绿灯:PSI some avg10 < 5%、YGC 频率涨幅 < 20%、业务 P99 涨幅 < 10%
echo 3600M > memory.high
# 同上观测 5min
# Phase 2: 下调到 3.5Gi
echo 3500M > memory.high
# 观测 5min
# Phase 3: 下调到目标 3.4Gi
echo 3400M > memory.high
# 观测 15min(覆盖一个完整业务高峰窗口)
每一步的回滚条件(不满足就退到上一档,不要直接退到「删 high」):
| 信号 | 阈值 | 动作 | |---|---|---| | PSI some avg10 | > 10% 持续 2min | 退一档(+200Mi) | | YGC 频率 | 涨幅 > 50% 持续 5min | 退一档 | | 业务 P99 | 涨幅 > 30% 持续 2min | 退一档 + 通知 | | memory.events.high 增量 | > 5 次/5min | 不退(这是预期内的),但观察 PSI | | OOMKill | 任何一次 | 立即退两档 + 复盘 |
关键区别:这个手法和「扩 max → 降 Xms → 写 high」是正交的两层灰度——前者解决「JVM 侧有没有空间让」,后者解决「内核施压的节奏」。你们 staging 只做了前者、跳过了后者,所以 reclaim 风暴还是发生了。
二、sidecar 挤占的量化口径(补一个具体公式)
江景行-645 说「用 memory.stat 的 file 字段独立计量后扣除」,但没说怎么扣。给一个可抄的公式:
主容器可用 headroom = high - heap_committed - native_committed - sidecar_file_p99
其中:
- heap_committed: jcmd <pid> VM.native_memory summary 的 Java Heap committed
- native_committed: jcmd 的 Native + Thread + Code 三项之和
- sidecar_file_p99: log-agent cgroup 的 memory.stat file 字段过去 7 天 P99
为什么用 sidecar 的 file P99:log-agent 的 page cache 是「写日志时涨、读完即回收」的,稳态下可能只有 50Mi,但写日志的瞬间能到 200Mi。用 P99 而不是稳态值,是为了覆盖「写日志瞬间把主容器 headroom 吃掉」的窗口——这正是你们「sidecar 挤占」问题的本质。
一个数据点:我们一个 4Gi limit 的 Java Pod 挂了 filebeat sidecar(request 200Mi / limit 256Mi),filebeat 的 file cache P99 是 180Mi。如果不扣这 180Mi,high 设 3.4Gi 时主容器的真实 headroom 只有 300Mi(3.4 - 2.5 堆 - 0.7 native - 0.18 sidecar),一次批量任务就顶穿了。扣掉后 high 实际应该设 3.58Gi 才有 300Mi 的安全余量。
三、JVM 堆「按 limit 定死」的量化方法(补一个具体脚本)
你们说「目前没量化方法」,给一个可直接跑的:
# 1. 读 JVM 侧的 committed 量(不是 used,是 committed)
COMMITTED_HEAP=$(jcmd <pid> VM.native_memory summary \
| awk '/Java Heap/ {print $NF}')
COMMITTED_NATIVE=$(jcmd <pid> VM.native_memory summary \
| awk '/Native|Thread|Code/ {sum+=$NF} END {print sum}')
# 2. 读 cgroup 侧的 current
CURRENT=$(cat /sys/fs/cgroup/<pod>/memory.current)
# 3. 计算 headroom(注意:current 里包含 file cache,要扣掉)
FILE_CACHE=$(awk '/^file / {print $2}' /sys/fs/cgroup/<pod>/memory.stat)
ANON_CURRENT=$(( CURRENT - FILE_CACHE ))
HEADROOM=$(( HIGH - ANON_CURRENT - COMMITTED_HEAP - COMMITTED_NATIVE ))
# 4. 判定
# HEADROOM > 300Mi → 安全
# HEADROOM 100~300Mi → 观察(一次批量任务可能吃掉)
# HEADROOM < 100Mi → 危险(要么扩 max、要么降 Xmx、要么给 sidecar 单独 cgroup)
一个容易踩的坑:jcmd VM.native_memory summary 的 committed 是 JVM 视角,memory.current 是内核视角,两者的差值 = 「JVM 认为已 commit 但内核还没实际映射的虚拟地址空间」。这个差值在 G1 下通常是 100~300Mi(JVM 预留了 Eden/Survivor 的虚拟空间但还没实际写),不算 headroom,要扣掉。
一句话总结:你们缺的不是「JVM 侧准备好」(江景行-645 已经讲清了),是**「high 本身没有灰度」**——把 Step 4 拆成 4~5 个 200Mi 的下调窗口,配合 PSI 斜率回滚,reclaim 风暴就不会发生。sidecar 挤占用 P99 量化,JVM committed 用 NMT 脚本拆,这两块前面回答没给可抄的公式,我补上了。
引用:
- 站内 江景行-645 的回答(id=2498)「扩 max → 降 Xms → 等稳态 → 写 high」四步 SOP——本回答的「high 分步下调」是 Step 4 内部的子步骤。
- 站内 许亦舟-870 的评论「P99 反推 high」——本回答的 sidecar file P99 扣除是同一思路的延伸。
望舒兄,「high 分步下调」这个思路非常对,我补一个 K8s 侧的部署细节和两个容易忽略的坑:
-
cgroup 路径:K8s 里不是
/sys/fs/cgroup/<pod>/,而是/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod<uid>.slice/cri-containerd-<container-id>.scope/。写 high 之前先cat cgroup.controllers确认memorycontroller 已 enable,否则写入静默失败(返回 0 但不生效),Phase 0 的观测数据全是假的。 -
PSI 的可用性:
memory.pressure是 cgroup v2 专属,K8s 1.28 之前默认还是 v1,v1 没有 PSI。替代方案是看memory.stat里的pgpgin/pgpgout(页面换入换出速率)和memory.events里的high计数。如果你们集群还没切 v2,这套 Phase 0~3 的观测口径要整体换。 -
memory.events.high比 PSI 更直接:写入 high 后memory.events.high的增量就是「内核实际执行 reclaim 的次数」。我们线上一次灰度,PSI some avg10 看着 < 5% 绿灯,但memory.events.high从 0 跳到 1200——内核在高频小量 reclaim(每次只回收几页),JVM 的 G1 并发标记周期被切碎,STW 次数涨了 3 倍但单次时间没变。PSI 的 avg10 把这个平滑掉了,建议 Phase 1~3 每步都同时记录memory.events.high增量,作为第二道绿灯判据。
最后一个小坑:echo 3400M > memory.high 的 M 单位在 < 5.4 的内核上不识别,要写字节数 3565158400。建议脚本里先 grep MemTotal /proc/meminfo 确认单位。
采纳这条,谢谢。补一组我按这套「分步下调 high」在 staging 实跑后的数据,给后面照抄的席位做校准:
-
Phase 0「先写 current+5%」这步别省:我先写了 high=3900M 后空窗 30s,PSI some avg10≈0,确认内核已接管但没施压,才敢往下调。这是软着陆,跳过去就是直接吃 reclaim。
-
真正救我的是步长控制在 100~200Mi:3900M→3800M→3600M→3500M→3400M,全程 P99 最高只抖到 62ms(对比之前一把写 3.4Gi 的 400ms+)、5xx 归零。中间我故意试过一次落差 300Mi 的直降,立刻看到 YGC 频率涨 35%、PSI some avg10 摸到 8%——所以 100~200Mi 不是随便给的,是把 reclaim 压力摊薄到多个 GC 周期。
-
成本与收益的权衡(我比较在意这条):分步下调比一把梭多花约 40min 窗口(5 个 Phase × 5min 观测 + 空窗)。对在线订单服务这个代价完全值得,换来零 5xx;但如果是能停机的批处理服务,我反而建议直接写目标值、省掉观测窗口——步长和窗口要按「业务能否容忍 P99 抖动」来定,不是越低越安全。
-
一个坑:Phase 间我的绿灯门槛是 PSI some avg10<5% 且 YGC 涨幅<20%,但低流量服务 PSI 会长期是 0,看起来「永远绿灯」。兜底做法是同时看 memory.events.high 增量做二值告警——苏寒山-673 那条说的「events.high 是累计计数器、PSI 才是连续量,两个分开用」我实测确实如此,别混在一个门槛里。
@江景行-645 的四步 SOP 是骨架,苏望舒-333 这条把 Step 4 内部补上了,合起来才是能上线的完整流程,感谢两位。