一台 64G 内存的机器,free -h 显示 available 只剩 1.2G,群里立刻有人提议扩容。同一时刻 dmesg 里没有任何 OOM 记录,所有服务的 RSS 加起来不到 20G。剩下的内存去哪了?答案通常在 buff/cache 那一列。
另一个方向:容器被 OOM Killer 杀掉,监控上的内存曲线却一直很平。因为监控采的是 RSS,而杀进程的是 cgroup 的内存上限,两者根本不是同一个数。
free 的四个数不是一回事#
free -m 的输出里,total 是物理内存总量,free 是完全没有被使用的页,buff/cache 是块设备缓存和可回收的 slab,available 是内核估算的「不触发 swap 就能分配给新进程」的量。
available 才是判断内存够不够的依据。它由 free 加大部分可回收的 page cache,再减去不可回收部分和低水位保留得来。所以 free 很小、available 很大是健康状态:内存被拿去缓存文件了,需要时立刻能回收。
free -m
grep -E 'MemTotal|MemAvailable|Cached|Dirty|Writeback|Committed_AS|CommitLimit' /proc/meminfo
Dirty 是等着回写的页,Writeback 是正在回写的页。Dirty 长期很高说明写入速度超过了磁盘,该查的是 dirty_ratio(默认 20%)和 dirty_background_ratio(默认 10%),而不是加内存。
虚拟内存和常驻内存差在哪#
进程的 VSZ 可以到几十 G,实际驻留的 RSS 可能只有几百 M。VSZ 包含已映射但从未触碰的匿名页、共享库、以及 mmap 的文件区间。malloc 拿到地址不等于拿到物理页,第一次写才触发缺页中断去分配。
RSS 也有水分:多个进程映射同一个共享库,每个进程的 RSS 都把这段算一遍。要看真实占用,用 PSS(按共享比例分摊)或 USS(独占):
smem -t -k -P 'java|nginx'
# 内核 4.14+ 可以直接读汇总,比逐个解析 smaps 快
awk '/^Rss/{print $2/1024 " MB"}' /proc/<pid>/smaps_rollup
采集脚本里用 smaps_rollup,一次系统调用拿到整个进程的汇总。逐个 smaps 里的每个 VMA 去读,进程映射一多就会明显拖慢采集端本身。
还有一个差异来自运行时是否愿意把内存还给内核。glibc 释放大块内存时会用 madvise(MADV_DONTNEED) 还回去,RES 会掉;JVM 的堆即使在 Full GC 之后通常也不缩,RES 只涨不跌。这解释了为什么同样是「内存泄漏」的曲线,有的服务能自己降下来,有的只能靠重启。
页缓存:free 少不是浪费#
Linux 会把空闲内存尽量用于 page cache。读文件时数据从磁盘进缓存,第二次读直接命中;读还有预读(readahead),顺序读时内核会提前把后面的页拉进来。写文件默认也先进缓存并标记 dirty,由回写线程在后台刷盘。
想知道某个文件有多少页真的在缓存里,用 fincore <file>(util-linux 自带),它按 mincore 的结果统计驻留页。排查「为什么这个读请求还是慢」时,它比看整机 free 有用得多。
这意味着两件事。第一,free 少是设计目标,不是故障。第二,OOM 发生前内核会先回收 page cache,回收不动才动手杀进程——所以「缓存把内存吃光了」这个说法在绝大多数情况下不成立。
真正不可回收的是匿名页:堆、栈、匿名 mmap。它们只能换出到 swap,swap 关闭时就只能被杀。判断方向很简单:/proc/meminfo 里 AnonPages 在涨还是 Cached 在涨,两者的处置方式完全相反。
有人习惯用 echo 3 > /proc/sys/vm/drop_caches 来「释放内存」。它只是把缓存丢掉,下一次读还得从磁盘重新拉,除了做基准测试,生产上找不到用它的理由。
OOM Killer 是怎么挑人的#
内核在分配内存失败、且回收后仍无法满足时触发 OOM。日志长这样:
Out of memory: Killed process 28471 (java) total-vm:8388608kB, anon-rss:7518204kB, file-rss:1024kB
oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/app,task=java,pid=28471,uid=1000
第一行是被杀进程的内存构成,第二行是判定上下文。task_memcg=/app 说明这是 cgroup 级别的 OOM,不是整机 OOM,处理方式完全不同:整机 OOM 要找出真正的大户,cgroup OOM 要调 memory.max 或者修内存泄漏。
/proc/<pid>/oom_score 是当前得分,oom_score_adj 可调(-1000 到 1000)。把关键进程设成 -1000 能显著降低被杀概率,但代价是别的进程替它死,不是护身符。
得分是按进程占用内存的比例算的,这带来一个反直觉的结果:一个刚启动、RSS 还很小但马上要吃满内存的进程,得分很低,它会一直膨胀到把别人挤死才被盯上。所以「OOM 杀错了人」这件事,多数时候不是内核判断错,而是被杀时它确实最大。
cgroup v2 下看计数:
cat /sys/fs/cgroup/app/memory.max
cat /sys/fs/cgroup/app/memory.current
cat /sys/fs/cgroup/app/memory.events # oom / oom_kill 计数
memory.events 里 oom 是触发次数,oom_kill 是真正杀掉的次数。前者大于后者,说明出现过「差点被杀、但回收成功」的情况,这是扩容的早期信号,比等进程真死要早得多。
几个容易踩的边界#
vm.overcommit_memory=0 是启发式,允许适度超额;=1 永远答应 malloc,风险自担;=2 严格按 CommitLimit,容易让正常进程申请失败。Redis 这类会 fork 的进程在 =2 下经常起不来。
vm.swappiness 不是「用不用 swap」的开关,而是回收匿名页相对 page cache 的倾向。设 0 也只是降低倾向,不等于禁用。
还有透明大页(THP)。开启 always 时,进程的 RSS 会按 2M 对齐膨胀,top 里的 RES 看着比实际用量大不少。要准确统计就关掉它,或者用 AnonHugePages 单独核对。
cgroup v2 还有一层 memory.high。超过它不会立刻触发 OOM,而是把进程放进强制回收状态,速度变慢但能继续跑。用它做缓冲,比直接把 memory.max 卡死在峰值上更稳。
| 指标 | 含义 | 怎么用 |
|---|---|---|
| free | 完全空闲的页 | 小是正常的,别当告警 |
| available | 可安全分配的量 | 判断够不够看这个 |
| Cached | 页缓存 | 可回收,涨不一定是坏事 |
| AnonPages | 匿名页 | 不可回收,涨才是真吃内存 |
| Dirty | 待回写页 | 高说明盘慢,不是内存问题 |
用 PSI 看真实压力#
/proc/pressure/memory 给出 some 和 full 两行,avg10 是过去 10 秒的阻塞时间占比。some 表示有任务因内存不足停住,full 表示所有可运行任务都被卡住——后者非零,说明内存已经不是紧张而是告急了。
cgroup v2 的每个组也有自己的 memory.pressure。容器内存快不够时,先看这个值再决定扩不扩,比盯 RSS 曲线直观。它只统计本组任务的阻塞,所以能区分「是容器自己撑爆」还是「宿主整体紧张」。
结论#
- 判断内存够不够看 available,不看 free;free 小、cache 大是正常状态。
- VSZ 是承诺,RSS 是现状,PSS 才接近真实占用;采集脚本优先用
smaps_rollup。 - 先区分整机 OOM 和 cgroup OOM,前者查大户,后者改
memory.max或修泄漏。 memory.events的 oom 计数是扩容前的预警,比等进程被杀更早。- 加内存之前先确认占用是匿名页还是可回收缓存,否则扩完还是同一条曲线。
完