暗色模式

Linux 内存排查:从 free 到 /proc/meminfo 与 OOM Killer 定位

技术教程
2026-09-12
7
0
本文要点
  • free 里的 free 列几乎没用:本文实测机器只剩 72Mi 空闲,available 却有 1.6Gi——Linux 把闲置内存全拿去做了页缓存,需要时随时回收
  • 手动 drop_caches 释放 1.3Gi 缓存后,available 从 1.6Gi 到 1.6Gi 一点没变——这证明缓存本来就计入可用内存,生产上随手清缓存除了让磁盘重新读一遍,没有任何收益
  • ps 按 RSS 排序会把共享库重复计算:实测一个 sshd 进程 Rss: 9528 kB,而 Pss 只有 3716 kB,差额是 7.6MB 的共享页
  • OOM 不是「突然崩」,而是内核主动挑一个进程杀:实测 1.9G 无 swap 的机器上,一个 Python 进程申请到 1700MB 时触发 python3 invoked oom-killer,内核按 oom_score_adj 挑中了它自己
  • 保护关键进程只需一行:把 sshd 监听进程的 /proc/<pid>/oom_score_adj 写成 -1000,它就会从 OOM 候选名单里彻底消失(实测 OOM 任务表里 sshd 的分数正是 -1000)

Linux 内存排查:从 free 到 /proc/meminfo 与 OOM Killer 定位

「服务器内存快满了」大概是运维群里最常见的一句话。可当你登上去敲下 free -h,看到的往往是这样一幅自相矛盾的画面:free 只剩几十兆,available 却还有一个多 G——到底该信哪个?

内存排查的难点不在于命令少,而在于几个数字的含义经常被误读freeavailable 有什么区别,buff/cache 那 1.7G 算不算被占用,ps 里的 RSS 加起来为什么比物理内存还大,以及最吓人的那句 Out of memory: Killed process 到底是怎么被选中的。

本文在一台 Ubuntu 22.04 云服务器(KVM,1 核、1.9G 内存、无 swap、内核 5.15.0-30-generic)上把这条链路完整跑一遍:先用 free/proc/meminfo 看清内存去向,再用 smaps_rollup 说明 RSS 为什么会重复计算,接着实测页缓存回收与内存压力指标,最后真的把内存耗光,让 OOM Killer 在 dmesg 里留下完整的作案现场。文中所有命令与输出均为真实执行结果。

第一步:free 的三个数字,只有一个是能信的

先看最原始的一眼:

free -h
               total        used        free      shared  buff/cache   available
Mem:           1.9Gi       183Mi        72Mi       0.0Ki       1.7Gi       1.6Gi
Swap:             0B          0B          0B

一台上线没多久、几乎没跑业务的机器,free 只有 72Mi,看着像随时要崩。但注意最后两列:

含义该不该慌
free完全没被使用的物理内存数字小是正常的,Linux 不会让内存闲着
buff/cache块设备缓存(buffers)+ 文件页缓存(cache)都是可回收的,内存紧张时内核自动让出
available内核估算的「新程序还能申请到多少内存」这才是该看的数字

available 不是简单的 free + buff/cache,而是内核综合了可回收的页缓存、可回收的 slab、以及各 zone 的水位线之后给出的估计值。它回答的是那个真正重要的问题:现在启动一个新服务,够不够内存?

本例中 available = 1.6Gi,说明这 1.7Gi 缓存随时可以让出来。所以判断内存是否紧张,正确的姿势是看 available 相对 total 的比例,而不是盯着 free

第二步:用 /proc/meminfo 拆开 buff/cache

free 只给了四个大类,要看清内存到底被什么占着,得翻内核的原始账本:

grep -E '^(MemTotal|MemFree|MemAvailable|Buffers|Cached|Dirty|Slab|SReclaimable|Committed_AS|SwapTotal)' /proc/meminfo
MemTotal:        2025116 kB
MemFree:           74028 kB
MemAvailable:    1639556 kB
Buffers:           84784 kB
Cached:          1512956 kB
SwapTotal:             0 kB
Dirty:               756 kB
Slab:             210064 kB
SReclaimable:     165724 kB
Committed_AS:     420536 kB

几个容易看错的字段:

  • Cached(1.5G):文件页缓存,读过的文件内容留在这里,下次读同一文件直接命中内存。这是 buff/cache 的大头,也是「内存被吃掉」的元凶外观——其实它随时可丢弃。
  • Buffers(84M):块设备元数据缓存(文件系统位图、inode 块等),和 Cached 是两回事,量级通常小得多。
  • Dirty(756 kB):已被修改、还没写回磁盘的页。这个值长期偏高(比如几百 MB 且不下降)才是真问题——说明磁盘写入跟不上,sync 会卡,极端情况会阻塞分配。
  • Slab(210M)与 SReclaimable(165M):内核对象缓存(dentry、inode 等)。SReclaimable 是其中可以回收的部分——本例 210M 里有 165M 属于可回收,剩下那 45M 才是真正不可回收的内核占用。排查内核内存泄漏时看的正是这个差值。
  • Committed_AS(420M):所有进程已承诺(malloc 过)的虚拟内存总量。它超过 MemTotal + Swap 时,说明系统处于超额承诺状态——平时相安无事,一旦这些承诺被同时「摸一遍」(写第一个字节触发实际分配),OOM 就来了。是否允许超额由 vm.overcommit_memory 控制(本站 sysctl 内核参数调优 有完整说明)。

第三步:谁在吃内存?RSS 排序会骗你

定位到「缓存占大头」之后,如果 available 真的在往下掉,就该找进程了:

ps -eo pid,comm,%mem,rss --sort=-rss | head -8
    PID COMMAND         %MEM   RSS
  96924 snapd            1.9 39508
  26775 multipathd       1.3 27096
    752 unattended-upgr  0.9 20124
  27874 packagekitd      0.8 17492
    650 networkd-dispat  0.8 17408
      1 systemd          0.6 13116
  24246 systemd-journal  0.6 12684

--sort=-rss 让内存大户排在最前(- 表示降序)。这里要看的是 RSS(Resident Set Size,进程实际驻留在物理内存里的页),而不是 VSZ——VSZ 包含进程申请的虚拟地址空间,一个 malloc(1G) 但没写过的进程,VSZ 涨 1G、RSS 纹丝不动,用 VSZ 排序只会被误导。

但 RSS 有个更隐蔽的坑:它会把共享页重复计算。systemd、snapd 这类进程都链接了同一份 libc,这些共享库的物理页在每个进程的 RSS 里都会各算一遍——把全机 RSS 相加,得到的数字可能远超物理内存。

要看清「这个进程真实独占了多少」,得看 PSS:

cat /proc/$(pgrep -o sshd)/smaps_rollup
Rss:                9528 kB
Pss:                3716 kB
Pss_Anon:           1628 kB
Pss_File:           2088 kB
Pss_Shmem:             0 kB
Shared_Clean:       7844 kB
Shared_Dirty:          0 kB
Private_Clean:        56 kB
Private_Dirty:      1628 kB
Anonymous:          1628 kB
Swap:                  0 kB

关键对比:

  • Rss: 9528 kB,但 Pss: 3716 kB——按 RSS 算这个进程占 9.3M,按 PSS 算只占 3.6M。
  • 差额来自 Shared_Clean: 7844 kB:这是与他人共享的干净页(libc、libcrypto 等共享库代码段),PSS 会把每个共享页按共享进程数均摊再计给本进程,所以不会重复。
  • Private_Dirty: 1628 kB 才是这个进程真正独占、且改过的内存(堆、栈)。想估算「杀掉它能省多少内存」,看的就是 PSS(或更粗的 Private_* 之和),而不是 RSS。

这也解释了 ps 里各进程 RSS 相加总是偏大的原因。

第四步:页缓存真的能回收吗?drop_caches 实测

缓存既然是「随时可回收」,那手动回收会发生什么?先读一遍磁盘把缓存喂大,再清一次缓存:

dd if=/dev/vda1 of=/dev/null bs=1M count=1200 status=none; free -h; sync; echo 3 > /proc/sys/vm/drop_caches; free -h

读 1.2G 磁盘喂大页缓存后执行 drop_caches:buff/cache 从 1.3Gi 掉到 71Mi,free 从 523Mi 涨到 1.7Gi——但 available 纹丝不动

这一次 dd 的顺序读把 1.2G 磁盘内容灌进了页缓存,于是:

  • 回收前:free 523Mi、buff/cache 1.3Gi
  • 回收后:free 1.7Gi、buff/cache 71Mi

缓存确实被放掉了,free 也确实涨回来了。但请注意最右边那一列

  • 回收前 available = 1.6Gi
  • 回收后 available = 1.6Gi

一点没变。这正是本文最想说明的一点:页缓存本来就计入 available,它不是被浪费的内存,而是被临时借用的内存。手动 echo 3 > /proc/sys/vm/drop_caches 只是把借出去的书提前还回来,代价是接下来所有文件读取都要重新走一遍磁盘 I/O——生产环境除了做基准测试,没有任何理由执行它。(1 清页缓存、2 清 dentry/inode、3 全清,写之前记得先 sync 把脏页落盘。)

第五步:内存压力看 vmstat 与 PSI

缓存多少不代表压力大小,真正的「内存不够用」表现为分配变慢。两个工具值得常备。

vmstat 一次给出内存、换页、IO、CPU 的全景:

vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 0  0      0  74028  84784 1678680    0    0     3    72   30   48  1  0 98  0  0
 0  0      0  74028  84784 1678716    0    0     0     0   39   40  0  0 100  0  0
 0  0      0  74028  84784 1678716    0    0     0     0   27   36  0  0 100  0  0
 0  0      0  74028  84784 1678716    0    0     0     0   37   47  0  1 99  0  0
 0  0      0  74028  84784 1678716    0    0     0     0   31   36  0  0 100  0  0

判读要点:

  • si / so(swap in/out):换页进出的速率。本例因为没有 swap,恒为 0;有 swap 的机器上,si/so 持续非零就意味着内存压力已经大到要拿磁盘当内存用了。
  • r / b:可运行队列长度 / 阻塞进程数。b 长期大于 0 往往意味着进程卡在 I/O(包括换页)上。
  • cachefree:与 free -h 同源,但能看到逐秒变化。

PSI(Pressure Stall Information) 是更灵敏的指标,内核 4.20+ 默认开启,直接告诉你「有多少时间被内存拖住了」:

cat /proc/pressure/memory
some avg10=0.00 avg60=0.02 avg300=0.00 total=1912672
full avg10=0.00 avg60=0.01 avg300=0.00 total=1192285
  • some:至少有一个任务因内存不足而停顿的时间占比;full:所有任务都被卡住的时间占比。
  • avg10/avg60/avg300 是 10 秒 / 60 秒 / 300 秒的滑动平均,full 的 avg10 一旦持续超过 1~2%,就说明系统已经在拿性能换内存了,此时 available 可能还很漂亮——这是 PSI 比 free 更早报警的原因。
  • 本例两组数据都是 0.00,配合 total 计数缓慢增长,说明这台机器目前完全没有内存压力。

第六步:把内存耗光,看 OOM Killer 怎么挑人

上面都是和平时期的观测。真正的内存事故是这样的:无 swap 的机器上内存被申请干净,内核回收不出任何东西,于是 OOM Killer(oom-kill) 出手,按分数挑一个进程杀掉。

先准备一个「内存黑洞」脚本:

import time
blocks = []
while True:
    blocks.append(bytearray(100 * 1024 * 1024))
    print(f"已申请 {len(blocks) * 100} MB", flush=True)
    time.sleep(0.3)

每轮申请 100MB 并立刻写入(bytearray 会清零,真正触碰每一页),这样 RSS 会实打实涨上去。执行前,先把 sshd 的监听进程保护起来,避免演示过程中把自己锁在门外:

for p in $(pgrep -x sshd); do echo -1000 > /proc/$p/oom_score_adj; done

然后把它丢到后台,等内存见底:

nohup python3 /root/memhog.py > /root/memhog.log 2>&1 &

这台机器 available 只有 1.6Gi,所以第 17 次申请就撞墙了:

已申请 100 MB
...
已申请 1600 MB
已申请 1700 MB
Killed

进程是被信号直接杀掉的,日志没有报错、没有堆栈。内核那边则留下了完整记录:

dmesg -T | grep -E "invoked oom-killer|Out of memory:|oom_reaper"

dmesg 里的 OOM 现场:谁触发的 oom-killer、杀掉了哪个进程、anon-rss 是多少、被谁回收

三行输出分别对应 OOM 事件的三个阶段:

  • python3 invoked oom-killer: gfp_mask=..., order=0, oom_score_adj=0:是谁在申请内存时发现无内存可分配,从而触发 OOM。注意 gfp_mask 里的 GFP_HIGHUSER_MOVABLE——这是用户态可迁移内存的常规申请,说明是普通进程申请,而不是内核自己出问题。
  • Out of memory: Killed process 97458 (python3) total-vm:1864060kB, anon-rss:1791632kB, file-rss:4kB, shmem-rss:0kB, UID:0 pgtables:3572kB oom_score_adj:0:核心证据。被杀的进程 PID、名字、total-vm(虚拟内存 1.8G)、anon-rss:1791632kB(真实驻留的匿名内存 1.7G)、以及它当时的 oom_score_adj。看到这一行就能确认:是内存真的用光了,而不是进程踩到别的错误。
  • oom_reaper: reaped process 97458 (python3), now anon-rss:0kB...oom_reaper 内核线程把被杀进程的内存收走,事件结束。

顺带一提,OOM 报告里还附了一张全进程任务表(Tasks state),形如:

[  pid  ]   uid  tgid total_vm      rss pgtables_bytes swapents oom_score_adj name
[  45981]     0 45981     3858     1180    69632        0         -1000 sshd
[  24246]     0 24246     7843     1005    86016        0          -250 systemd-journal
[  96924]     0 96924   462866     3345   258048        0          -900 snapd
[  97458]     0 97458   466015   447909  3657728        0             0 python3

最后那列 oom_score_adj 就是「挑人」的依据:内核在候选进程里优先选分数高的。表里 sshd 是 -1000(我们刚设的),systemd-journal 是 -250、snapd 是 -900(systemd 单元自带),而 python3 是 0——在 447909 页的体量面前,它被选中毫无悬念。

第七步:用 oom_score_adj 保住关键进程

杀谁不杀谁是可以人为干预的。每个进程在 /proc/<pid>/oom_score_adj 里都有一个 -1000 ~ 1000 的调节分(默认 0),内核算出基础分后加上它再比较:

for p in 1 $(pgrep -x sshd) $(pgrep -x snapd) $(pgrep -x multipathd); do printf '%-6s %-16s oom_score_adj=%s\n' "$p" "$(cat /proc/$p/comm)" "$(cat /proc/$p/oom_score_adj)"; done

当前几个关键进程的 oom_score_adj:sshd 监听进程 -1000,snapd -900,multipathd -1000,而 sshd 的会话子进程仍是 0

1      systemd          oom_score_adj=0
45981  sshd             oom_score_adj=-1000
97667  sshd             oom_score_adj=0
96924  snapd            oom_score_adj=-900
26775  multipathd       oom_score_adj=-1000

几个值得留意的细节:

  • 系统本来就给一些服务加了保护snapd 是 -900、systemd-journald 是 -250,这些来自 systemd 单元的 OOMScoreAdjust 配置,可以用 systemctl show <单元名> -p OOMScoreAdjust 查;而 multipathd 的 -1000 是它自己在运行时调低的(单元配置里写的是 0)。设计良好的服务往往会自己「要保护」。
  • 同一个程序的不同进程分数不同45981 是 sshd 的监听进程sshd: /usr/sbin/sshd -D [listener]),我们把它设成了 -1000;而 97667 是本次登录的会话子进程sshd: root@notty),分数是 0。所以「保护 sshd」时要认准监听进程——会话进程被杀只是断一条连接,监听进程被杀才是全员失联。
  • -1000 是完全豁免:等于告诉内核「无论如何都别选它」。好处是保住 SSH,风险是如果所有大内存进程都被设成 -1000,内核无处可杀,最终会触发 panic_on_oom 逻辑(可能直接 panic)。所以只保护真正关键的少数进程。

配置持久化的两种方式:

# 方式一:systemd 单元级,重启后仍生效(写入 override)
systemctl edit ssh
# 在打开的编辑器里写入:
# [Service]
# OOMScoreAdjust=-1000

# 方式二:运行时临时生效(重启即失效)
for p in $(pgrep -x sshd); do echo -1000 > /proc/$p/oom_score_adj; done

内存排查的顺序清单

把上面的内容压缩成一张可照着敲的表:

现象命令看什么
想知道内存够不够free -h只看 available,对比 total
内存被谁占着`grep -E 'Cached\Slab\SReclaimable\Dirty' /proc/meminfo`缓存占比、Dirty 是否积压、Slab 可回收比例
哪个进程吃内存`ps -eo pid,comm,%mem,rss --sort=-rss \head`RSS 排名(注意会重复计算共享页)
进程真实独占多少cat /proc/<pid>/smaps_rollupPss 而不是 Rss
有没有换页压力vmstat 1 5si/so 是否非零、b
卡顿有多严重cat /proc/pressure/memoryfullavg10 是否持续 >1%
是不是被 OOM 杀了`dmesg -T \grep -E "Out of memory\oom_reaper"`被杀的 PID、anon-rssoom_score_adj
保护关键进程echo -1000 > /proc/<pid>/oom_score_adj目标必须是监听/主进程
缓解手段加 swap 或 zram、cgroup 限额见下方延伸阅读

如果这台机器 swap,排查思路要再加一层:本站 交换空间 讲了 swap 文件与 swappiness 调优,zram 则是用压缩内存当 swap,在内存小的机器上比磁盘 swap 快得多;而要从根上限制某个服务的内存上限、让 OOM 只发生在它自己的 cgroup 里,用 cgroup 资源控制MemoryMax 更合适——那才是容器时代的正解。日常观测工具(htopiostatss 等)可参考 系统实时监控,进程与负载的整体排查见 进程与性能排查

清理演示产物

演示只产生两个小文件,确认后删掉即可:

rm -f /root/memhog.py /root/memhog.log

收个尾:内存排查的核心心法是分清「借出去的」和「真花掉的」。页缓存、可回收 slab 属于前者,available 已经把它们算作可用,盯着 free 惊慌是新手最常见的误判;而 anon-rssPrivate_Dirty 属于后者,它们涨上去才是真的回不来。至于 OOM Killer,它不是故障本身,而是内核在无路可走时做的最后取舍——你要做的是提前给它一份正确的候选名单,而不是等它替你选。

发表评论

暂无评论,快来抢沙发吧!