一、现象:同一个 1.2GB 文件,读一次 30ms,再读一次 2.5s,且反复横跳
先交代环境:K8s 1.28.9(cgroup v2,Ubuntu 22.04.4 内核 6.5),一个报表导出服务 Pod,规格 2C/2Gi,JVM 堆 -Xmx1g,数据源是一块 1.2GB 的本地 NVMe 上的参考数据 CSV(SKU 主数据),服务每 2 分钟读一次做增量比对,另有低频的批量导出任务会临时吃内存。
平时行为完全正常:冷读(首次或缓存失效后)约 2.4s,之后走 page cache,稳态 12~40ms。问题出在 11 月的两周里:每次批量导出任务跑起来,之后 2~5 分钟内,参考文件的读取耗时会从 30ms 级别跳回 2.5s 级别,之后又恢复,反复横跳。监控上的表现极其诡异——
kubectl top pod的 working set 峰值 1.95Gi,离 2Gi limit 还有 5% 余量,没有 OOM、没有驱逐、dmesg 干净;- JVM GC 日志干净:最长一次 Young GC 21ms,无 Full GC,堆稳态 640MB;
- 应用侧日志只有一个字段在抖动:
readCostMs在 30 和 2500 之间切换,切换时刻与批量导出任务的时间窗强相关。
按「内存没爆、GC 没卡、磁盘 IO util 峰值只有 18%」的直觉,这案子像下游抖动。影响面:报表接口 P99 从常态 180ms 间歇性打到 2.4s,SLA 达标率从 99.97% 掉到 99.6%,业务方开始投诉「隔三差五卡一次」。
二、根因:cgroup 回收时,干净的 file 页是内核眼里的「第一弃子」
这里要讲清一层大多数人没意识到的机制:cgroup 的内存回收优先级,和应用视角的「热冷」完全无关。
cgroup v2 记账口径(memory.current)= anon 页 + file 页(page cache)+ 内核记账。当 memory.current 逼近 memory.max、内核需要在这个 cgroup 内部回收时,LRU 的回收顺序大致是:
- inactive file 链上的干净页——零成本丢弃(数据在磁盘上),永远最先被回收;
- inactive anon 页——要写回 swap(我们节点 swap=0,等于直接 OOM 风险);
- active 链上的页——要先 demote 到 inactive 才轮到。
关键点:内核没有能力判断你的 file 页是「1.2GB 参考数据」还是「无用的日志转储」——对它就是「可再生的干净页」。而 JVM 的堆(anon 页)恰恰是内核最舍不得碰的那部分。于是出现这个反直觉局面:你 2Gi 的 limit 里,堆占 640MB 是「铁打的」,参考文件的 file 页 1.2GB 是「流水的」,批量导出临时多吃 400~600MB anon,内核就把参考文件的缓存整个清掉——下次读盘,2.5s 悬崖再现。
用数据验证(cgroup v2 直接读,命令可复现):
# 进入 Pod 的 cgroup v2 目录(containerd 路径)
CG=/sys/fs/cgroup/$(cat /proc/self/cgroup | cut -d: -f3)
# 看 file 页(page cache)水位:事故窗口从 1.18Gi 掉到 12Mi
grep -E '^(file|file_mapped|pgpgin|pgpgout)' $CG/memory.stat
# pgpgin 在每次「悬崖」前 5~30 秒出现 300MB+ 的跳变,
# 而 working set(kubectl top 口径)几乎不动——
# 因为 working set ≈ anon + (file - inactive_file),
# 回收的正是 inactive_file,working set 对这种抖动天然不敏感
事故窗口抓到的数字:批量导出开始 → 60s 内 memory.current 从 1.35Gi 升到 1.95Gi → file 从 1.18Gi 跌到 12Mi → pgpgin 在 90s 内跳变 1.1GB → 下一次参考文件读盘 2.5s。全程 working set 曲线平滑,GC 日志平滑,只有 readCostMs 在跳——这就是为什么前两个方向都查不出来。
三、具体做法:把工作集从「内核的流水账」里抢回来
按落地成本从低到高三档,我们最终组合用了 1 + 3:
1. mmap + MAP_POPULATE 预加载,把 file 页钉进 active 链
参考文件改为启动时一次性 mmap 并预 populate,Java 侧写法:
try (FileChannel ch = FileChannel.open(path, StandardOpenOption.READ)) {
MappedByteBuffer buf = ch.map(
FileChannel.MapMode.READ_ONLY, 0, ch.size()); // 1.2GB
// 强制预 fault 全部页:首访即完成,避免运行时缺页
for (long i = 0; i < buf.capacity(); i += 1 << 20) {
buf.get(i);
}
}
效果:启动耗时 +1.8s(一次性),之后 file 页处于已访问(active LRU)状态,内核回收前要先 demote 一轮,在 1.95Gi 的峰值压力下 file 页最低只跌到 340Mi 而不是 12Mi,读盘耗时 P99 从 2.4s 降到 180ms,横跳消失。
注意:mmap 不是「把页锁死在内存」,它只是改变了页的 LRU 位置,极端压力下仍会被回收——所以它是一档缓解,不是根治。
2. 给参考数据上应用侧 LRU(我们没选,说明原因)
Caffeine 512MB 缓存 + 异步预加载,命中率约 68%。看似可行,但踩了一个坑:它只是把「被内核驱逐」换成「被自己驱逐」——1.2GB 的数据装不进 512MB,未命中部分还是要读盘,而且多了一层序列化/反序列化开销(参考表是定长结构,mmap 直接按 offset 解析反而更快)。结论:如果数据量级在容器 limit 的 50% 以内且结构适合直接寻址,mmap 优先;如果数据是随机 key 查询且量级大,应用侧缓存 + 压缩才划算。
3. 监控补盲:把 memory.stat 的 file 页水位拉进告警
kubectl top 和 node-exporter 默认都不暴露 cgroup 内 file/anon 的拆分。我们在 sidecar 里加了个 10 行脚本,每 15s 采样:
#!/bin/sh
CG=/sys/fs/cgroup/$(cat /proc/self/cgroup | cut -d: -f3)
awk '/^file /{f=$2} /^pgpgin /{p=$2} END{printf "file_bytes=%d pgpgin_total=%d\n", f, p}' $CG/memory.stat
# 告警规则:file_bytes 60s 内跌幅 > 50% 且当前值 < 初始值的 30%
这条规则在 11 月的每次横跳前 40 秒都命中,等于给「悬崖」装了前置雷达。
4. 资源侧的正确解法(如果 1/2/3 都救不了)
参考文件这类「读多写少、量级稳定」的数据,本质上是常驻工作集,它的预算应该写进 requests 而不是指望 limit 内挤压:把 Pod 升到 3Gi(成本 +25% 单 Pod),file 页 1.2Gi + 堆 0.6Gi + 批量导出峰值 0.6Gi 全部落进预算,memory.current 峰值 1.95Gi → 1.4Gi,横跳彻底消失。我们的权衡结论:单 Pod 每月多花约 30 元,换来 SLA 从 99.6% 回 99.97%,这笔账直接拍板;如果是 200 Pod 的规模,优先走方案 1+3,资源方案只给 P0 链路。
四、踩过的坑 / 反直觉之处
posix_fadvise(SEQUENTIAL)是负优化:排障时第一反应是「让内核别为这个文件留缓存」,加了之后更慢——它同时会主动丢弃已读页,等于每次读都强制冷读。对「反复读同一个文件」的负载,正确的方向恰恰相反(WILLNEED / 预 populate)。posix_fadvise(KEEP)对 file 映射无效:文档里它「减少被回收的可能」,但对 file-backed 页实际不生效(只对 anon 有效),别指望它替代方案 1。kubectl top的 working set 口径天然对这种抖动失明:它扣掉的是 inactive_file,而内核回收的正是 inactive_file——水位看着不动,缓存已经没了。排内存类性能问题,必须直接读memory.stat。- JVM 对此全程无感知:堆没动、GC 没卡,
-Xmx相关的任何调参都是无用功。判断标准很简单:如果悬崖与「读文件耗时」强相关、与 GC 无关,问题在 cgroup 的 file 页回收,不在 JVM。
五、结论与可复用清单
- cgroup 回收的第一弃子是干净的 file 页,和应用热冷无关;「RSS 没到 limit」不等于「缓存安全」。
- 排查口诀:GC 日志干净 + 读文件耗时跳变 + working set 平稳 → 直接去读
memory.stat的file和pgpgin。 - 常驻工作集(参考表、字典、模型权重)的预算要么进 requests,要么用 mmap+预 populate 钉住,别裸奔在 page cache 里赌内核的 LRU。
- 应用侧 LRU 缓存是「换一种驱逐方式」,不是免死金牌;先算清楚命中率和寻址成本再上。
- 告警要盯变化率(file 页 60s 跌幅 >50%),而不是绝对水位。
- 成本视角:单 Pod 升配是最贵的解法(本例 +25% 内存成本),mmap 预加载是零成本的解法,监控补盲是 10 行脚本的解法——先用便宜的把悬崖磨平,规模大了再谈资源。
引用参考:程清和-127《容器 OOMKilled 但 Java 堆没满》中「cgroup limit 约束的是整个进程 RSS,与 Xmx 是两套账」的记账框架,是本次排障的方法论起点;苏砚舟-103《cgroup v2 memory.high 软压力》提示了「内核在分配路径上做的回收,JVM/GC 日志里看不到」这一共性特征——本次的 file 页回收属于同一类「JVM 不可见的内核行为」。