内存报警之后:从 free 的输出读到 OOM Killer

·约 1800 字 · Linux运维

监控面板弹出「内存使用率 94%」的时候,第一反应往往是找哪个进程在漏内存。但在 Linux 上,这个数字本身经常是误报——它取决于监控 agent 用的是哪一列。这篇按我实际的排查顺序,把内存这件事讲清楚。

free 的每一列到底是什么

$ free -m
               total        used        free      shared  buff/cache   available
Mem:            7962        2140         312         188        5509        5301
Swap:           2047         104        1943

这台机器「只剩 312 MB 空闲」,但真正可用的是 available 列的 5301 MB。区别在于:

  • free:完全没被使用的物理页。这个值低是正常的,空闲内存对内核来说是浪费,它会尽量拿去做缓存。
  • buff/cache:page cache(文件内容缓存)加上内核缓冲区。绝大部分在需要时可以立刻回收。
  • available:内核估算的、在不触发 swap 的前提下可以给新进程使用的内存。做告警只应该看这一列

如果监控用的是 (total - free) / total,那任何一台跑了一段时间的机器都会报警。正确的表达式是:

(MemTotal - MemAvailable) / MemTotal

对应到 /proc/meminfo 里的 MemTotalMemAvailable 两行。

page cache 什么时候会成为问题

干净的 page cache 可以直接丢弃,回收成本几乎为零。有成本的是脏页——已经被修改、还没写回磁盘的部分:

$ grep -E 'Dirty|Writeback' /proc/meminfo
Dirty:             12844 kB
Writeback:             0 kB

脏页多、后端磁盘又慢的时候,进程申请内存会被卡在同步回写上,表现为「明明有内存,但系统很卡」。相关的两个内核参数:

# 脏页占可用内存的比例超过 10% 时,后台线程开始回写
vm.dirty_background_ratio = 10
# 超过 20% 时,写入的进程被强制同步刷盘
vm.dirty_ratio = 20

大内存机器上按比例算出来的绝对值可能有几个 GB,一次性刷盘会造成明显卡顿。这种情况改用字节版本更可控:

sysctl -w vm.dirty_background_bytes=268435456   # 256 MB
sysctl -w vm.dirty_bytes=1073741824             # 1 GB

注意 ratio 和 bytes 是互斥的,设置其中一个会把另一个清零。

被忽略的 slab

buff/cache 里还混着内核自己的对象缓存。当机器上有大量小文件操作,或者容器频繁创建销毁时,dentry 和 inode 缓存能涨到几个 GB:

$ grep -E 'Slab|SReclaimable|SUnreclaim' /proc/meminfo
Slab:            1893472 kB
SReclaimable:    1702208 kB
SUnreclaim:       191264 kB

$ slabtop -o -s c | head -8

SReclaimable 可回收,SUnreclaim 不可回收。后者持续增长而且不随负载下降,才是真正的内核内存泄漏信号,通常和某个内核模块或文件系统驱动有关。

网上流传的 echo 3 > /proc/sys/vm/drop_caches 不要在生产上用。它会丢掉全部 page cache,之后所有读请求都要重新走磁盘,I/O 会瞬间打满,「解决」了一个假问题却制造了一个真问题。它唯一的正当用途是做基准测试前清空缓存。

找到真正的内存占用者

按常驻内存排序是最快的第一眼:

ps -eo pid,comm,rss,vsz --sort=-rss | head -12

但 RSS 会把共享库重复计算,多个同族进程加起来往往超过物理内存总量。要看真实占用,用 PSS(按共享份额均摊):

# 单个进程的 PSS 汇总
grep '^Pss:' /proc/$PID/smaps_rollup

# 全局按命令排序(需要 root)
smem -k -s pss -r | head -12

如果某个进程 RSS 和 PSS 都不高,但系统内存就是紧张,检查一下有没有大量僵尸进程或者 tmpfs:

df -h -t tmpfs
findmnt -t tmpfs

/dev/shm/run 写进去的数据是实打实占内存的,而且不会被自动回收。我见过一次「内存泄漏」,最后发现是应用把临时文件写到 /dev/shm 而没有删除。

OOM Killer 的判定逻辑

当内存真的不够、回收也来不及时,内核会挑一个进程杀掉。日志里能看到完整的现场:

$ dmesg -T | grep -iE 'killed process|out of memory'
[Tue Dec  3 04:12:57 2024] Out of memory: Killed process 21417 (java)
  total-vm:6291456kB, anon-rss:3903120kB, file-rss:0kB, shmem-rss:0kB,
  UID:1000 pgtables:8724kB oom_score_adj:0

选谁取决于 oom_score,它主要正比于进程占用的内存量。也就是说,被杀的通常是内存占用最大的进程,不一定是导致内存耗尽的那个。数据库、缓存这类「大而重要」的进程最容易背锅。

可以给关键进程降低被选中的概率(取值 -1000 到 1000,-1000 表示永不选中):

echo -500 > /proc/$(pidof postgres)/oom_score_adj

更规范的做法是写进 systemd unit,避免进程重启后失效:

[Service]
OOMScoreAdjust=-500
MemoryMax=4G
MemoryHigh=3G

容器里的内存和宿主机不是一回事

在容器里跑 free,看到的是宿主机的数字,这会误导所有基于 free 的判断。cgroup v2 下应该读这几个文件:

cat /sys/fs/cgroup/memory.current   # 当前用量
cat /sys/fs/cgroup/memory.max       # 硬上限,超过即触发 OOM
cat /sys/fs/cgroup/memory.high      # 软上限,超过后被节流回收
cat /sys/fs/cgroup/memory.events    # low/high/max/oom/oom_kill 计数

memory.events 里的 oom_kill 计数是判断「容器是不是被 OOM 过」的权威依据,比翻 dmesg 可靠得多。MemoryHighMemoryMax 更友好:它触发的是回收和节流而不是直接击杀,适合给不确定用量的服务留缓冲。

另外,JVM 从 10 开始默认开启 UseContainerSupport,会按 cgroup 限额而不是宿主机内存计算堆大小。老版本或者显式设了 -Xmx 又超过容器限额,结果就是容器被反复 OOM 重启。

swap 要不要开

常见的观点是「服务器一律关 swap」。我的做法是:留少量 swap,但把 swappiness 调低

sysctl -w vm.swappiness=10

理由是完全没有 swap 时,内核在内存压力下唯一的选择就是杀进程;留一点 swap 至少能把长期不用的匿名页换出去,为回收争取时间,也让监控有机会先报警。当然,如果服务对延迟极度敏感(比如低延迟交易或者 Redis),那关掉 swap 是对的。

观察 swap 是否真的在被使用,看 si/so 两列而不是总量:

vmstat 1 5

总量不为零只说明历史上换出过,只有 si/so 持续非零才代表当前正在颠簸。

小结

  1. 告警指标改用 MemAvailable,能消掉八成的误报。
  2. buff/cache 高不是问题,脏页高才是;slab 里的 SUnreclaim 持续增长才值得深究。
  3. 定位占用者用 PSS,别用 RSS 做加法。
  4. 容器场景一律看 cgroup 文件,尤其是 memory.eventsoom_kill
  5. 关键服务配 OOMScoreAdjustMemoryHigh,别等 OOM Killer 替你做选择。

← 返回首页