本文要点
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——到底该信哪个?
内存排查的难点不在于命令少,而在于几个数字的含义经常被误读:free 与 available 有什么区别,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/meminfoMemTotal: 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_rollupRss: 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
这一次 dd 的顺序读把 1.2G 磁盘内容灌进了页缓存,于是:
- 回收前:
free523Mi、buff/cache1.3Gi - 回收后:
free1.7Gi、buff/cache71Mi
缓存确实被放掉了,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 5procs -----------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(包括换页)上。cache与free:与free -h同源,但能看到逐秒变化。
PSI(Pressure Stall Information) 是更灵敏的指标,内核 4.20+ 默认开启,直接告诉你「有多少时间被内存拖住了」:
cat /proc/pressure/memorysome avg10=0.00 avg60=0.02 avg300=0.00 total=1912672
full avg10=0.00 avg60=0.01 avg300=0.00 total=1192285some:至少有一个任务因内存不足而停顿的时间占比;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"
三行输出分别对应 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
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_rollup | 用 Pss 而不是 Rss | |||
| 有没有换页压力 | vmstat 1 5 | si/so 是否非零、b 列 | |||
| 卡顿有多严重 | cat /proc/pressure/memory | full 的 avg10 是否持续 >1% | |||
| 是不是被 OOM 杀了 | `dmesg -T \ | grep -E "Out of memory\ | oom_reaper"` | 被杀的 PID、anon-rss、oom_score_adj | |
| 保护关键进程 | echo -1000 > /proc/<pid>/oom_score_adj | 目标必须是监听/主进程 | |||
| 缓解手段 | 加 swap 或 zram、cgroup 限额 | 见下方延伸阅读 |
如果这台机器有 swap,排查思路要再加一层:本站 交换空间 讲了 swap 文件与 swappiness 调优,zram 则是用压缩内存当 swap,在内存小的机器上比磁盘 swap 快得多;而要从根上限制某个服务的内存上限、让 OOM 只发生在它自己的 cgroup 里,用 cgroup 资源控制 的 MemoryMax 更合适——那才是容器时代的正解。日常观测工具(htop、iostat、ss 等)可参考 系统实时监控,进程与负载的整体排查见 进程与性能排查。
清理演示产物
演示只产生两个小文件,确认后删掉即可:
rm -f /root/memhog.py /root/memhog.log收个尾:内存排查的核心心法是分清「借出去的」和「真花掉的」。页缓存、可回收 slab 属于前者,available 已经把它们算作可用,盯着 free 惊慌是新手最常见的误判;而 anon-rss、Private_Dirty 属于后者,它们涨上去才是真的回不来。至于 OOM Killer,它不是故障本身,而是内核在无路可走时做的最后取舍——你要做的是提前给它一份正确的候选名单,而不是等它替你选。
评论 (0)
暂无评论,快来抢沙发吧!