Linux 内存监控实战与 OOM 排障:free/vmstat/ps 字段解读 + cgroup v2 内存限制决策树
我入行第一年就吃过一次大亏:一台 64G 的数据库节点半夜被 OOM killer 干掉了主进程,但监控面板上内存曲线一切正常,报警系统也没响。第二天早上业务方来问,我才发现 free -h 的 used 一直在 50G 左右,看起来"健康",可 buff/cache 那条线早就悄悄涨到了 60G——内存其实一直在被页缓存和 slab 蚕食,available 已经见底了。
内存故障和 CPU 故障有个本质区别:CPU 满了你会肉眼可见地"卡",内存满了系统往往一声不吭地杀人。OOM killer 不打印任何业务日志、不留堆栈、不写 core 文件,只是按 oom_score 挑一个进程 SIGKILL 掉。你看到的现象只是"进程莫名挂了",重启后一切正常,然后下个月再来一次。
这篇我把踩过的坑完整梳理一遍:从 free 五字段到底怎么读,到 vmstat 的 si/so 换页判断,再到 cgroup v2 的 memory.events 和 OOM killer 的 badness 算法,最后附上 5 个我亲手排过的生产事故。所有阈值、参数我都写到具体值,命令都是可直接复制的。
一、free -h 五字段解读:used 高不代表紧张
排查内存永远从 free -h 开始。这是最容易被误读、也最容易被误判的一条命令。
你应该看到什么(32G 机器、运行中的生产节点):
total used free shared buff/cache available
Mem: 31Gi 9.2Gi 1.5Gi 312Mi 20Gi 21Gi
Swap: 8.0Gi 0B 8.0Gi
这一行有 5 个真正要读的字段(shared 通常小,可以忽略),每个字段高都是不同的信号:
| 字段 | 含义 | 高 / 低 说明什么 |
|---|---|---|
| total | 物理内存总量 | 固定值,排除内存条或 VM 规格问题 |
| used | 应用 + 内核实际占用 | 高 ≠ 紧张,见下文 |
| free | 完全未分配的内存 | 长期接近 0 是正常现象,Linux 喜欢"用满" |
| buff/cache | 页缓存 + buffer + slab | 可回收部分是"软内存",是系统主动吃掉的内存 |
| available | 真正可分配的内存 | 这才是排障要看的列,而不是 used |
1.1 为什么 used 高不代表紧张
我刚带新人时,他们第一反应永远是"used 20G,是不是要爆了?"——不是。Linux 的内存策略是**"内存闲着就是浪费":只要不用,内核就把空闲内存拿来缓存磁盘文件(page cache)、缓存目录项(dentry)、缓存 inode 等。所以 used + buff/cache 几乎总是接近 total,free 常年是个位数 G 甚至几百 M,这恰恰是健康**的表现。
真正的信号在 available 这一列。它是内核用 MemAvailable 算出来的**"如果现在有个进程要申请大内存,系统能立即给多少"**。它已经扣掉了"不能立刻回收的那部分 cache",比 used 和 free 都靠谱。
1.2 buff/cache 可回收 vs 不可回收
关键问题是:buff/cache 到底有多少能回收?答案藏在 /proc/meminfo:
你应该看到什么:
MemFree: 1572864 kB
Buffers: 204800 kB
Cached: 18446744 kB
SReclaimable: 8388608 kB
SUnreclaim: 1048576 kB
Shmem: 319488 kB
Slab: 9437184 kB
MemAvailable: 22020096 kB
拆解一下:
- Cached:文件页缓存,绝大部分可回收(除非是
mapped且正在被写,需要写回) - Buffers:块设备元数据,量小,可回收
- SReclaimable:slab 中可回收部分(dentry、inode、一些内核缓存),这是好消息
- SUnreclaim:slab 中不可回收部分(内核对象、驱动分配),这是坏消息,回收不掉
- Shmem:tmpfs 共享内存,不可回收(除非删除 tmpfs 文件),常被误当成 cache
- Slab = SReclaimable + SUnreclaim:内核对象缓存总量
判断口径就一句话:buff/cache 里的 SReclaimable + Cached 才是"软内存",SUnreclaim + Shmem 是"硬占用"。如果一台机器 available 很低但 Cached 很高,大概率还能回收救回来;如果 Cached 不高而 SUnreclaim 和 Shmem 高,那就是真的吃紧。
经验阈值:
available低于total的 5% 且持续超过 5 分钟,就该拉警报了。别等 0,等 0 的时候 OOM 已经开始了。
1.3 真实案例 A:页缓存误判
有一次生产上 Java 应用被 OOM kill,我接到告警上机器一看 free -h:used 9G / total 32G / buff/cache 21G。第一反应是"内存还多得很,怎么会被 OOM?"
再看 MemAvailable:只剩 1.2G。再查 cgroup:这个 Java 应用跑在容器里,容器 memory.max = 4G,而容器视角下的 buff/cache(进程读过的文件页)全算在容器头上,memory.current 早就顶到 4G 了。宿主机可用内存多,容器视角下已被自己的页缓存 + 堆撑爆。
结论:free -h 看的是宿主机,容器排障必须看 cgroup,宿主机 buff/cache 高是红鲱鱼。后面第六节展开。
二、vmstat 换页判断:si/so 才是内存压力的真信号
free 告诉你"现在剩多少",vmstat 告诉你"内存正在怎么被使用"。内存压力最硬核的证据不是 free,而是 si/so(swap in / swap out)持续非零。
你应该看到什么(每 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
2 0 10240 1572864 204800 18446744 0 0 12 18 1024 2048 8 2 89 0 0
3 0 10240 1548288 204800 18446744 0 0 15 20 1100 2100 9 2 88 0 0
2 1 10240 1523712 204800 18446744 120 340 40 380 1200 2400 12 4 82 0 0
要读的列(其余列在内存专题里不用太纠结):
| 列 | 含义 | 阈值 / 信号 |
|---|---|---|
| si | swap in:每秒从交换区读回内存的 KB | 持续 > 0 且非零 = 内存压力 |
| so | swap out:每秒从内存换到交换区的 KB | 同上 |
| r | 运行队列:可运行进程数 | 长期 > CPU 核数 = 就绪队列堆积 |
| b | 阻塞队列:不可中断睡眠的进程 | > 0 且伴随 si/so = 在等换页,卡顿元凶 |
| free | 空闲内存 | 看趋势,别单看绝对值 |
判定口诀:
si/so长期为 0 → 内存没压力,问题在别处si/so偶尔非零 → 启动期或缓存刷盘,正常si/so持续非零(连续几个采样) → 内存真紧张,进程在拿磁盘当内存用,磁盘换页是"内存假象",真正卡的是 I/Ob列伴随走高 → 进程被阻塞在换页上,这就是"系统没死但业务超时"的根源
为什么说"swap 抖动比内存不足更可怕"?因为 swap 写回是同步磁盘 I/O。
so持续几百 KB/s 时,每次缺页中断都要等磁盘,应用延迟从毫秒级跳到百毫秒级,且 CPU 看起来不忙(都在等 I/O),监控上"CPU 正常 + 内存正常 + 业务巨慢"这种诡异组合,九成是 swap 抖动。
2.1 free 列的趋势比绝对值重要
vmstat 里的 free 列是"完全未分配"的内存,Linux 正常运行时它常年很低。所以别盯着 free 是不是 100M,要看它是不是在持续往下走。free 缓慢阴跌 + si/so 抬头,是最典型的"内存正在被慢慢吃掉"曲线,常见于内存泄漏(第九节)。
三、ps aux 内存解读:RSS vs VSZ 的坑,smem 的 PSS 才是真相
定位到进程后,用 ps aux 看谁在吃内存。但这里有两个坑:RSS 会高估共享内存,VSZ 完全没用。
你应该看到什么:
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
app 1234 12.5 8.2 4289344 2684352 ? Sl Oct20 45:02 java -Xmx2g ...
mysql 2345 3.1 15.4 2187652 5051392 ? Ssl Oct18 18:33 mysqld
redis 3456 0.8 1.2 154624 393216 ? Ssl Oct19 2:11 redis-server
3.1 RSS 与 VSZ 的区别(必考)
- VSZ(Virtual Set Size,虚拟内存):进程申请过的地址空间,包含未实际分配的部分、mmap 的库、共享内存映射。完全不能反映真实占用,一个
mmap了 10G 但只碰了 100M 的进程 VSZ 能到 10G,你看着吓人其实没事。 - RSS(Resident Set Size,常驻内存):进程实际驻留在物理内存的页数。但它是按页计数、不除重——两份进程共享同一个
libc.so的页,RSS 会各算一次,两个进程 RSS 加起来能超过物理内存总量,这就是 RSS 高估共享内存的原因。
所以:VSZ 别信,RSS 偏高估。判断单进程真实内存,用 smem 的 PSS(Proportional Set Size),它把共享页按进程数均摊,所有进程 PSS 之和 ≈ 真实物理内存占用。
3.2 smem:按真实内存排序
你应该看到什么:
PID User Command Swap USS PSS RSS
1234 app java -Xmx2g 0 1.8G 1.9G 2.6G
2345 mysql mysqld 0 2.2G 2.4G 4.9G
3456 redis redis-server 0 300M 305M 390M
-------------------------------------------------------------------------------
0 12.4G 14.2G 19.8G
三列口径对比,一目了然:
- USS:进程独占内存(不共享的页),最小、最"真实自用"
- PSS:独占 + 共享均摊,推荐用这个,加起来最接近物理内存
- RSS:独占 + 共享全算,偏大
排障口径:看谁在吃内存,ps aux --sort=-rss 排出来排前几的进程,用 smem -rsk 复核 PSS。如果某进程 RSS 6G 但 PSS 只有 1G,说明它是"共享大户"(Java 的 mmap 线程栈、JIT 代码缓存常见),别误判成泄漏。
四、内存回收机制:page cache、kswapd、direct reclaim 与水位
要理解 OOM,得先理解内核怎么"弄回内存"。Linux 内存回收有两条路:后台回收(kswapd) 和 直接回收(direct reclaim),由内存水位(watermark) 驱动。
4.1 两条回收路径:匿名页 vs 文件页
内存紧张时内核要找"可回收的内存",分两类:
- 文件页(page cache):把脏页写回磁盘(writeback)就能释放。这是首选,因为它不需要交换、只是把已缓存的数据落盘,代价小。这就是为什么
buff/cache高时内存压力通常还能扛——内核在默默回收文件页。 - 匿名页(anonymous):进程的堆、栈、mmap 匿名映射。没有磁盘后端,必须换到 swap(或 zram/zswap)才能释放。代价是磁盘 I/O,这就是 si/so 的来源。
所以
swappiness只影响"内核多愿意回收匿名页",不影响文件页回收。文件页回收永远优先,除非脏页写回跟不上。
4.2 kswapd:后台回收(无感)
每个 NUMA 节点有一个 kswapd 内核线程。当空闲内存跌破 low watermark,kswapd 被唤醒,在后台异步回收,目标是把空闲内存恢复到 high watermark。这个过程中进程无感知,不阻塞。
4.3 direct reclaim:直接回收(进程卡顿的元凶)
如果 kswapd 回收速度跟不上分配速度,空闲内存继续跌破 min watermark,分配内存的进程就会亲自下场做回收——这就是 direct reclaim(直接回收)。此时进程在分配路径上同步等待回收,表现为 vmstat 的 b 列升高、free 阴跌、应用延迟暴涨。这是"系统活着但业务在跳秒"的直接原因。
4.4 三档水位
| 水位 | 含义 | 触发动作 |
|---|---|---|
| high | 高水位 | kswapd 停止回收(回血完毕) |
| low | 低水位 | kswapd 被唤醒,开始后台异步回收 |
| min | 最低水位 | 进程被迫 direct reclaim,同步阻塞 |
查看当前水位和阈值:
你应该看到什么(示例,单位页):
经验值:min/low/high 三档通常在 40/60/80(页数),比例约 1:1.5:2。空闲页低于 low 就该注意,低于 min 就要准备接 OOM 告警了。
4.5 为什么 direct reclaim 会"杀死"性能而不报错
我遇到过一台 Redis 从节点,监控一切正常,但主从复制延迟从 0.3s 涨到 40s。vmstat 看到 r 不高、si/so 偶发非零、但 b 列长期 2~5,free 在 min 水位附近徘徊。真相是:Redis 在 fork() 做 RDB 快照时复制了内存页表,加上 kswapd 回收跟不上,Redis 主线程反复进入 direct reclaim,每次等待几十毫秒。没有 OOM、没有报错,只是变慢——这就是内存问题"隐蔽"的典型。
五、cgroup v2 内存限制实战:容器 OOM 为什么和宿主机无关
现在大部分生产内存问题都出在容器/K8s 里。容器的内存限制是 cgroup 实现的,和宿主机物理内存是两套账本。这是无数人踩的坑:宿主机 256G,容器只有 512M,容器里进程被杀了,你第一反应是"宿主机内存不够吗"——完全不是。
5.1 cgroup v2 核心三件套
# 找到进程所在 cgroup(cgroup v2 用相对路径,从 /sys/fs/cgroup 往下找)
cat /proc/<pid>/cgroup
# 输出类似:0::/system.slice/docker-xxxx.scope
# 直接进入该 cgroup 目录看内存控制文件
ls /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.*
关键文件:
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.max # 上限,单位字节,"max" 表示不限制
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.current # 当前已用,含该 cgroup 的页缓存
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.swap.max # swap 上限
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.events # 关键:事件计数
你应该看到什么:
$ cat memory.max
1073741824 # 1G
$ cat memory.current
1048576000 # 接近 1G,已到 97.6%
$ cat memory.swap.max
0 # 容器禁止 swap
$ cat memory.events
low 0
high 0
max 3421 # 触顶次数
oom 12 # 触发 OOM 的次数
oom_kill 12 # 实际杀进程的次数
5.2 memory.events 是容器 OOM 的"黑匣子"
memory.events 四个计数器,是排容器内存问题的第一手证据:
- low / high:触及低/高水位线的次数,内存开始吃紧的早期信号
- max:
memory.current撞到memory.max的次数 - oom:触发 OOM killer 的次数
- oom_kill:实际被杀掉进程的次数(
oom和oom_kill可能不同,因为杀进程有时能救回)
判定:
oom_kill从 0 变成 N → 容器内进程被杀,这就是 K8s 里OOMKilled的直接证据max在涨但oom_kill不动 → 容器在反复触顶、内存回收压力大,但还没到杀进程high在涨 → 容器快满了,该提前告警
5.3 为什么容器 OOM 杀的是容器内进程,而不是整个节点
OOM killer 的作用范围是触发 OOM 的那个 cgroup 及其子孙。当容器 memory.current 撞到 memory.max,内核在该容器 cgroup 范围内挑一个 oom_score 最高的进程杀掉——不会去动宿主机上其他容器。所以:
- 容器被 OOMKilled,宿主机物理内存可能还有一大半,这完全正常
- 排查方向是
memory.max设小了,不是"加机器" - 容器内进程的
oom_score_adj影响"谁被先杀",见第六节
5.4 memory.current 为什么比我想的高
新手常问:"我的 Java 进程 RSS 才 800M,为什么 memory.current 有 1.2G?"——因为 memory.current 包含该容器触达过的所有页缓存(读过/写过文件留下的页)。进程退出后缓存还在 cgroup 里记着,直到内核回收。所以容器内存排障不能只看进程 RSS,memory.current 才是容器视角的总账。
# 看容器内存构成(memory.stat 里各分项)
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.stat | grep -E '^(anon|file|slab_reclaimable|slab_unreclaimable|shmem|pgfault|pgmajfault)'
你应该看到什么:
anon 512000000 # 匿名页(进程堆栈),这是"真内存"
file 560000000 # 文件页(页缓存),可回收
slab_reclaimable 20480000
slab_unreclaimable 10240000
shmem 40960000 # tmpfs,不可回收
pgmajfault 0 # 主缺页,0 说明没在换页
如果 file 占比大(>50%)而 anon 不大,说明容器内存是被页缓存撑高的,可以调大 memory.max 或等内核回收;如果 anon 接近 memory.max,那是应用真的在涨,要查泄漏或扩上限。
六、OOM killer 原理:badness 算法、oom_score 与 dmesg 解读
当 direct reclaim 也救不回来(回收速度 < 分配速度),内核调用 OOM killer。它不是一个"判断"而是一个"执行":挑进程、杀、结束。理解它怎么挑,你才能主动保护关键进程。
6.1 badness 算法:OOM killer 怎么选受害者
内核遍历所有进程,给每个算一个 badness 分数,分数最高者被选为受害者。简化公式:
关键变量:
- 占用越大,越容易被杀(这是主项,大内存进程首当其冲)
oom_score_adj越小,越不容易被杀(-1000表示永不杀,+1000表示优先杀)- 权重修正:root 进程、直接子进程、已杀过一次的进程会被加权保护,但核心还是"谁占得多杀谁"
查看每个进程的实时分数:
# 分数越高,越容易被 OOM 选中
for pid in $(pgrep -u app); do echo -n "$pid "; cat /proc/$pid/oom_score; done | sort -k2 -n -r | head
6.2 oom_score_adj:给关键进程上保险
oom_score_adj 范围 -1000 ~ +1000,默认 0。这是生产环境必须做的事:
# 数据库/网关等关键进程,压低分数,让它最后才被杀
echo -500 > /proc/<pid>/oom_score_adj
# 批处理/非关键任务,抬高分数,让它最先被杀
echo 500 > /proc/<pid>/oom_score_adj
# -1000 表示 OOM killer 直接跳过该进程(谨慎,可能把系统拖死)
echo -1000 > /proc/<pid>/oom_score_adj
经验配置(以数据库节点为例):
| 进程类型 | 建议 oom_score_adj | 原因 |
|---|---|---|
| MySQL / PostgreSQL | -500 ~ -300 | 关键服务,尽量保住 |
| Redis | -300 | 内存型,但可重建 |
| 监控 agent | -200 | 排障需要它活着 |
| 日志采集 | 100 | 可丢日志,优先杀 |
| 批处理 job | 300 | 非关键,最先杀 |
K8s 里的等价物是 Pod 的 oomScoreAdj,或者通过容器运行时配置。Java 应用建议在容器内设置 -XX:MaxRAMPercentage 并显式给 oom_score_adj。
6.3 dmesg 完整解读:OOM 日志就是事故报告
OOM 发生后,第一手证据在内核日志:
你应该看到什么(完整 OOM 日志示例):
Oct 28 03:12:45 node01 kernel: [123456.789] invoke_oom_killer: gfp_mask=0x100cca(GFP_KERNEL_ACCOUNT), order=0, oom_score_adj=0
Oct 28 03:12:45 node01 kernel: [123456.789] oom-kill:constraint=CONSTRAINT_NONE, nodemask=(null), cpuset=/, mems_allowed=0-1, global_oom, consider_oom_score=1
Oct 28 03:12:45 node01 kernel: [123456.790] Out of memory: Killed process 1234 (java) total-vm:4289344kB, anon-rss:2684352kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:20480kB oom_score_adj:0
Oct 28 03:12:45 node01 kernel: [123456.790] Tasks state (memory values in pages):
Oct 28 03:12:45 node01 kernel: [123456.790] pid 1234 (java): 65600 pages, anon:65536, file:64, shmem:0
Oct 28 03:12:45 node01 kernel: [123456.790] pid 2345 (mysqld): 123200 pages, anon:123136, file:64, shmem:0
逐行拆解,这就是完整的"事故报告":
| 日志片段 | 含义 | 排查指向 |
|---|---|---|
invoke_oom_killer ... order=0 |
内核因分配 order=0(单页)失败触发 OOM |
order 小说明是细水长流型压力,不是大块分配 |
constraint=CONSTRAINT_NONE |
节点级 OOM(全局,不是某个 cgroup) | 说明整机内存爆了,看宿主机 |
constraint=CONSTRAINT_MEMCG |
cgroup 级 OOM | 说明是某个容器触顶,cpuset 显示范围 |
Killed process 1234 (java) |
被杀的是谁、被杀了多少 | 记下 PID 和进程名 |
total-vm / anon-rss / file-rss |
被杀进程的内存画像 | anon-rss 是真占用,file-rss 是页缓存 |
oom_score_adj:0 |
被杀时它的分数偏移 | 如果是 -1000 还被杀,说明内核忽略了保护(危险信号) |
Tasks state ... anon:65536 |
被杀瞬间所有进程快照 | 比对哪些进程 anon 高,判断有没有更值得怀疑的 |
最重要的判断:日志里
constraint=CONSTRAINT_MEMCG是容器问题,CONSTRAINT_NONE是节点问题。方向搞反了会白忙一场——宿主机 OOM 你去看容器上限,或容器 OOM 你去看宿主机内存,都排不到根因。
6.4 真实案例 B:K8s 容器反复 OOMKilled
有一次客户反馈一个 Java 服务在 K8s 里每天被 OOMKilled 几次,kubectl describe pod 显示 OOMKilled,但宿主机 free 显示还有 200G 空闲。
排查路径:
# 1. 看容器的 memory.events
kubectl exec -it <pod> -- cat /sys/fs/cgroup/memory.events
# 输出:oom_kill 18 <- 已被杀 18 次
# 2. 看容器上限和当前用量
kubectl exec -it <pod> -- cat /sys/fs/cgroup/memory.max # 2147483648 (2G)
kubectl exec -it <pod> -- cat /sys/fs/cgroup/memory.current # 2130706432 (1.98G)
# 3. 看 JVM 最大堆 vs 容器上限
kubectl exec -it <pod> -- jcmd <pid> VM.flags | grep -o 'MaxHeapSize=[0-9]*'
根因:容器 memory.limit = 2G,但 JVM -Xmx 设了 3G(MaxHeapSize=3221225472)。JVM 以为有 3G 可用,实际容器只给 2G,JVM 堆涨到 1.9G 时容器内存(堆 + 元空间 + 线程栈 + 页缓存)就触顶了,OOM killer 杀 JVM。
修复:把 -Xmx 降到容器上限的 60~70%(2G 上限 → -Xmx1280m),并加 -XX:MaxRAMPercentage=60 让 JVM 自动感知容器限制,同时给关键进程设 oom_score_adj=-100。
教训:容器内存排障第一件事是核对 JVM 堆和容器上限的比例。-Xmx 大于 memory.limit 是 K8s Java 服务被 OOMKilled 的第一大原因。
七、swap 配置:swappiness、zram/zswap 与 overcommit
swap 用好了是缓冲,用坏了是灾难。这一节讲透配置参数。
7.1 swappiness 的语义(0-100)
/proc/sys/vm/swappiness 控制内核回收匿名页(换出)相对于回收文件页(写回)的偏好,默认 60。
注意:它不是"使用 swap 的概率",而是"在需要回收时,多倾向于回收匿名页"的权重。
| 值 | 适用场景 | 效果 |
|---|---|---|
| 0 | 极度保守,几乎不换出匿名页 | 内存不足时宁可杀进程也不换页 |
| 10 | 数据库 / 延迟敏感 | 极少换出,优先回收文件页 |
| 30 | 通用服务 | 平衡 |
| 60 | 系统默认 | 中等,桌面/普通服务器 |
| 100 | 极度激进 | 频繁换出,延迟暴跌 |
经验值:数据库节点(MySQL/PG/Redis)建议 10,普通 Web 服务 20~30,桌面 60 即可。别把 swappiness 设成 0 当"关掉 swap"——它只是不主动换出,一旦内存真不够,内核会强制换出并引发更猛烈的抖动,不如给个 10 让它温和换出。
7.2 真实案例 C:swap 抖动导致数据库延迟飙升
我遇到过一台 PostgreSQL 主库,si/so 长期在 200~800 KB/s,b 列 2~3,%iowait 15% 左右,业务方反馈"查询偶尔卡 2~3 秒"。free 显示 available 还有 6G,看起来"内存够"。
排查:cat /proc/sys/vm/swappiness 显示 80(被某人调过)。查 /proc/meminfo 的 SwapTotal 用了 4G,vmstat 1 3 确认 si/so 持续非零。
真相:swappiness=80 让内核过度偏好换出匿名页,把 PG 的共享内存(shared_buffers 属于匿名页)换到磁盘,每次查询命中已换出的页就要等磁盘 I/O,延迟从 20ms 跳到 2s。
修复:sysctl -w vm.swappiness=10,并写入 /etc/sysctl.conf 持久化。一小时后 si/so 归零,%iowait 回落到 2%,延迟恢复。
教训:swappiness 过高 + 有 swap = 延迟炸弹。数据库场景务必压到 10 以下,并确认 swap 用量不增长。
7.3 zram / zswap:把 swap 放内存里
既然换到磁盘慢,就把 swap 压缩后放内存——这是 zram/zswap 的初衷。
- zram:创建一块压缩的内存块设备当 swap 用。适合内存不大、不想用磁盘 swap 的场景。压缩比通常 2~4 倍。
- zswap:在现有磁盘 swap 之上加一层压缩缓存,命中就直接从压缩内存读,不落盘。适合有磁盘 swap 但想减少 I/O 的场景。
zram 实战配置(以 2G zram 为例):
# 加载模块并创建一个 2G 的 zram 设备
modprobe zram
echo 2048M > /sys/block/zram0/disksize
mkswap /dev/zram0
swapon -p 100 /dev/zram0 # 高优先级,先于磁盘 swap 使用
zswap 开启:
echo Y > /sys/module/zswap/parameters/enabled
echo 40 > /sys/module/zswap/parameters/max_pool_percent # 压缩池最多占 40% 内存
经验:内存型应用(Redis 等)其实不需要 swap,zram 更适合"内存有点紧但不想加机器"的场景。如果内存真不够,加内存条永远比任何 swap 方案强。
7.4 overcommit_memory 与 overcommit_ratio
内核允许"超卖"内存——进程 malloc 成功不代表真的有物理页。两个参数控制这个策略:
| overcommit_memory | 含义 |
|---|---|
| 0 | 启发式(默认),粗略判断 |
| 1 | 永远超卖,不检查(危险,容易 OOM) |
| 2 | 严格模式,禁止超卖,按 overcommit_ratio 控制 |
cat /proc/sys/vm/overcommit_memory # 默认 0
cat /proc/sys/vm/overcommit_ratio # 默认 50,表示可超卖到 swap+50% 内存
数据库场景建议 overcommit_memory=1(MySQL/PG 的 mmap 大块内存常被启发式误判拒绝),但要配足够 swap 兜底;如果设成 2,要小心 malloc 失败导致应用启动报 Cannot allocate memory,那其实是 overcommit 拒绝而不是真的没内存。
八、内存泄漏检测:/proc/meminfo、slabtop 与 RSS 曲线
内存问题分两种:一次性压力(流量高峰)和慢性泄漏(只涨不跌)。后者更阴险,因为监控告警"内存持续上涨"往往要几天才触发,而根因可能早就埋下了。
8.1 /proc/meminfo 的 Slab 字段
泄漏分两个方向:用户态(进程 RSS 涨)和内核态(slab 涨)。内核态泄漏最容易被忽略,因为它不体现在任何进程的 RSS 上,只体现在 Slab 字段。
你应该看到什么:
Slab: 9437184 kB # 内核对象缓存总量
SReclaimable: 8388608 kB # 可回收部分(dentry/inode 等)
SUnreclaim: 1048576 kB # 不可回收部分(内核对象、驱动)
Dentry: 204800 kB # 目录项缓存
Inode: 102400 kB # inode 缓存
判定口径:
SUnreclaim持续上涨且不回落 → 内核 slab 泄漏,重点怀疑驱动、文件系统、内核模块SReclaimable上涨 → 通常是 dentry/inode 缓存,能回收,压力下会自动降,不是泄漏Dentry异常高 → 目录项缓存泄漏,常见于频繁创建/删除文件的应用
8.2 slabtop:定位是哪个内核对象在泄漏
你应该看到什么:
Active / Total Objects (% used) : 123456 / 234567 (52.6%)
Active / Total Slabs (% used) : 4567 / 7890 (57.8%)
OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
123456 123456 100% 0.02K 1204 102 4816K dentry
78901 78901 100% 0.19K 1924 41 15392K kmalloc-192
45678 45678 100% 0.01K 511 89 2044K filp
- 某类 object 的 USE 100% 且对象数持续上涨 → 该类对象泄漏
dentry100% 上涨 → dentry 泄漏kmalloc-*100% 上涨 → 内核模块/驱动泄漏filp(文件句柄)上涨 → 文件句柄泄漏(配合lsof | wc -l看)
8.3 进程 RSS 持续增长曲线
用户态泄漏的典型特征:RSS 只涨不跌,或涨上去后不回落。
更精确的曲线采集(写入日志):
while true; do
echo "$(date +%H:%M:%S) $(ps -o rss= -p <pid>)" >> /tmp/rss_$(date +%F).log
sleep 60
done
判定:正常进程内存会有波峰波谷(GC 后回落、请求完释放),如果曲线是阶梯式向上、从不在高位回落,基本就是泄漏。Java 场景用 jmap -histo 或 MAT 分析堆;C/C++ 场景用 valgrind 或 tcmalloc 的统计接口。
8.4 真实案例 D:内核 slab 泄漏(内存缓慢上涨)
有一次一台机器内存以每天 2G 的速度上涨,free 的 used 和 available 都正常,但 Slab 字段每天都在涨,SUnreclaim 从 500M 涨到 8G。slabtop 看到 dentry 的 USE 恒 100%、对象数在涨。
排查:这台机器跑了 NFS 客户端 + 一个高频创建/删除临时文件的 cron 任务。dentry 缓存是 NFS 的目录项,NFS 服务器端在删文件后没有正确释放 dentry,内核缓存越积越多。
修复:升级 NFS 客户端内核模块、优化 cron 任务减少临时文件创建,并手动触发回收:
教训:内存只涨不跌、且所有进程 RSS 都正常,一定要查 slab。内核态泄漏不看 ps,只看 slabtop 和 /proc/meminfo 的 SUnreclaim。
九、5 个真实故障排查案例复盘
前面穿插了几个案例,这一节把完整 5 个案例串起来,每个都给出现象 → 排查命令 → 根因 → 修复的完整链路。
案例A:Java 应用被 OOM kill,但 free 显示 buff/cache 很高
现象:Java 服务在容器里被杀,dmesg 显示 Killed process (java)。宿主机 free -h 显示 buff/cache 21G / used 9G,看起来"内存充足"。
排查命令:
# 1. 宿主机真实可用
free -h
grep MemAvailable /proc/meminfo # 只剩 1.2G
# 2. 容器视角
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.events # oom_kill 3
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.max # 4G
cat /sys/fs/cgroup/system.slice/docker-xxxx.scope/memory.current # 3.98G
# 3. dmesg 确认范围
dmesg -T | grep 'Out of memory' | tail -1 # constraint=CONSTRAINT_MEMCG
根因:容器 memory.max=4G,Java -Xmx=3.5G + 元空间 + 线程栈 + 页缓存,memory.current 顶到 4G,触发 cgroup 级 OOM。宿主机 buff/cache 高是"别的进程读过的文件页",与容器无关,是红鲱鱼。
修复:-Xmx 降到 2.5G,memory.max 升到 4.5G,并给 oom_score_adj=-100。
一句话:容器 OOM 看 cgroup,别看宿主机 free。
案例B:K8s 容器反复 OOMKilled
现象:K8s Pod 每天被 OOMKilled 数次,kubectl describe 显示 Last State: Terminated Reason: OOMKilled,宿主机内存充足。
排查命令:
kubectl exec -it <pod> -- cat /sys/fs/cgroup/memory.events # oom_kill 18
kubectl exec -it <pod> -- cat /sys/fs/cgroup/memory.max # 2147483648 (2G)
kubectl exec -it <pod> -- jcmd <pid> VM.flags | grep MaxHeapSize # 3G
根因:memory.limit=2G 但 JVM MaxHeapSize=3G,JVM 按 3G 分配,撞上 2G 的 cgroup 墙。典型"JVM 堆超容器上限"。
修复:-XX:MaxRAMPercentage=60(JVM 自动感知容器)+ -Xmx 显式压到上限 60%,并加 resources.limits.memory 与 requests.memory 对齐。
一句话:K8s Java 服务 OOMKilled,先核对 Xmx 和 memory.limit 的比例。
案例C:swap 抖动导致数据库延迟飙升
现象:PG 主库查询偶尔卡 2~3 秒,free 显示 available 还有 6G,%iowait 15%,业务方以为是磁盘坏了。
排查命令:
vmstat 1 3 # si/so 持续 200~800 KB/s,b 列 2~3
cat /proc/sys/vm/swappiness # 80(被误调)
grep -E '^(SwapTotal|SwapFree)' /proc/meminfo # 已用 4G
根因:swappiness=80 让内核过度换出匿名页,PG 的 shared_buffers 被换到磁盘,命中时等 I/O。
修复:sysctl -w vm.swappiness=10 持久化。观察 1 小时,si/so 归零、延迟恢复。
一句话:数据库节点 swappiness 必须 <10,si/so 非零就是警报。
案例D:内核 slab 泄漏(内存缓慢上涨)
现象:节点内存每天涨 2G,used、available 正常,Slab 持续上涨,所有进程 RSS 正常。
排查命令:
grep -E '^(Slab|SReclaimable|SUnreclaim|Dentry)' /proc/meminfo # SUnreclaim 500M→8G
slabtop -o # dentry USE 100% 且对象数持续涨
根因:NFS 客户端 + 高频临时文件创建,dentry 缓存未被释放,内核态泄漏。
修复:升级 NFS 模块、减少临时文件、echo 2 > /proc/sys/vm/drop_caches 手动回收。
一句话:所有进程都正常但内存涨,查 slabtop,查 SUnreclaim。
案例E:节点整体 OOM,但找不到高内存进程
现象:宿主机 dmesg 报 Out of memory: Killed process,constraint=CONSTRAINT_NONE(节点级)。但 ps aux --sort=-rss 排前几的进程 RSS 都不高,加起来远小于内存总量。
排查命令:
dmesg -T | grep 'Out of memory' | tail -1 # constraint=CONSTRAINT_NONE
# 找所有容器的 memory.events,看哪个 oom_kill 在涨
for cg in /sys/fs/cgroup/system.slice/docker-*; do
echo "$cg $(cat $cg/memory.events 2>/dev/null | grep oom_kill)"
done | grep -v 'oom_kill 0'
# 看是不是内核态占用(slab)或 tmpfs 把内存吃光
grep -E '^(Shmem|SUnreclaim|Slab)' /proc/meminfo
根因:一个容器里跑了个 tmpfs 挂载(Shmem) 写了大量数据,shmem 是不可回收的,它把节点内存吃光但不体现在任何进程的 RSS 里(tmpfs 文件页算 shmem 不算进程)。节点级 OOM,但"找不到凶手"。
修复:清理 tmpfs 数据、给 tmpfs 挂载加 size= 上限、把大文件移到磁盘。
一句话:节点级 OOM 但没高 RSS 进程,查 Shmem(tmpfs)和 Slab(内核态)。
十、内存排障决策树
把上面所有工具串成一条可执行的路径。拿到"内存疑似故障",按顺序走:
内存疑似故障(应用慢 / 进程被杀 / 告警)
│
├─ 1. free -h + /proc/meminfo
│ ├─ available 充足(>10%)→ 问题不在内存,转到 CPU/IO 排障
│ ├─ available 低但 buff/cache 高 → 页缓存在顶,先看能否回收,继续往下
│ └─ SUnreclaim + Shmem 占比高 → 内核态/tmpfs 占用,跳步骤 6(泄漏排查)
│
├─ 2. vmstat 1 5
│ ├─ si/so 持续非零 → 真内存压力,swap 在顶,跳步骤 7(swap 调优)
│ ├─ si/so = 0 但 b 列高 → 不是内存是磁盘 IO 或直接回收,查 iostat
│ └─ free 阴跌 + r 升高 → 内存被慢慢吃掉,跳步骤 6(泄漏)
│
├─ 3. 判断 OOM 是否已发生:dmesg -T | grep -E 'Out of memory|killed'
│ ├─ constraint=CONSTRAINT_MEMCG → 容器级,跳步骤 5(cgroup)
│ ├─ constraint=CONSTRAINT_NONE → 节点级,记下 Killed process,继续
│ └─ 无 OOM 日志但进程挂了 → 不是内存,查应用日志/coredump
│
├─ 4. 节点级 OOM:ps aux --sort=-rss 找高 RSS 进程
│ ├─ 找到凶手 → 看 oom_score / oom_score_adj,保护关键进程,扩容或降内存
│ └─ 找不到(RSS 总和 << 内存)→ 查 Shmem(tmpfs)/SUnreclaim(slab),跳步骤 6
│
├─ 5. 容器级 OOM:cat memory.events + memory.max + memory.current
│ ├─ oom_kill > 0 且 current ≈ max → 容器触顶,核对 Xmx vs memory.limit,扩容或降堆
│ └─ oom_kill > 0 但 current << max → 有瞬时尖峰,查 memory.peak / 大对象分配
│
├─ 6. 泄漏排查:slabtop + RSS 曲线
│ ├─ SUnreclaim / slab 持续涨 → 内核 slab 泄漏,查驱动/NFS/文件系统
│ ├─ 某进程 RSS 只涨不跌 → 用户态泄漏,jmap/valgrind 分析
│ └─ Dentry 高 → 高频文件操作,优化应用
│
└─ 7. swap 调优:swappiness + zram/zswap + overcommit
├─ 数据库节点:swappiness=10,必要时关 swap 或 zram
├─ 延迟敏感:检查 si/so 是否归零
└─ 关键进程:设置 oom_score_adj=-500 保护
入口判断(最快的一条):free -h 看 available → vmstat 看 si/so → 判断有没有 OOM → 决定看节点还是看 cgroup。三步走完,80% 的问题能定位。
十一、15 条命令速查 Cheat Sheet
| # | 命令 | 用途 | 关键输出 |
|---|---|---|---|
| 1 | free -h |
内存总览 | available 才是真实可用 |
| 2 | grep -E '^(MemAvailable\|SUnreclaim\|Shmem)' /proc/meminfo |
真实可用 + 不可回收部分 | 区分"软/硬"内存 |
| 3 | vmstat 1 5 |
换页/运行队列 | si/so 持续非零 = 压力 |
| 4 | ps aux --sort=-rss \| head |
按 RSS 找大进程 | RSS 偏高估 |
| 5 | smem -rsk -t |
按 PSS 排真实内存 | PSS 最接近真实 |
| 6 | cat /sys/fs/cgroup/.../memory.events |
容器 OOM 黑匣子 | oom_kill 计数 |
| 7 | cat /sys/fs/cgroup/.../memory.max |
容器上限 | 核对与 Xmx 比例 |
| 8 | cat /sys/fs/cgroup/.../memory.current |
容器当前用量 | 含页缓存 |
| 9 | dmesg -T \| grep -E 'Out of memory\|killed' |
OOM 日志 | constraint 判断范围 |
| 10 | cat /proc/<pid>/oom_score_adj |
查看 OOM 保护分 | -1000=永不杀 |
| 11 | echo -500 > /proc/<pid>/oom_score_adj |
保护关键进程 | 设负数 |
| 12 | cat /proc/sys/vm/swappiness |
查看 swap 偏好 | 默认 60 |
| 13 | slabtop -o |
内核对象缓存排序 | USE 100% 且涨 = 泄漏 |
| 14 | cat /proc/zoneinfo \| grep -E 'min\|low\|high' |
内存水位 | 空闲 < min 危险 |
| 15 | cat /proc/meminfo \| grep -E '^(Slab\|Dentry\|Inode)' |
内核态内存 | Slab 持续涨 = 泄漏 |
十二、FAQ
Q1:free 显示 used 很高但系统不卡,为什么?
因为 used 里混着大量可回收的 buff/cache(页缓存、dentry、inode),它们只是"系统主动缓存",不是"被占用"。Linux 的原则是"内存闲着就浪费",所以 free 常年低、buff/cache 常年高,这是健康的。要看 available 这一列,它才是"现在能立刻给进程用"的内存。或者用 vmstat 1 5 看 si/so:如果持续为 0,说明根本没有换页压力,内存一点也不紧张。
Q2:容器被 OOMKilled 但宿主机内存还很多,怎么回事?
容器的内存上限是 cgroup 的 memory.max 控制的,和宿主机物理内存是两套账本。容器 memory.current 撞到 memory.max(比如你设了 1G,但 JVM 堆就快 1G 了),内核就在该容器范围内杀进程,宿主机会不会 OOM 跟它无关。排障看容器自己的 memory.events 的 oom_kill 计数、memory.max 和 memory.current,并核对 JVM -Xmx 是否超过容器上限的 60~70%。
Q3:swappiness 设置多少合适?
默认 60。数据库或延迟敏感应用建议 10~30,减少匿名页换出,避免 swap 抖动拖垮延迟。但记住三点:① swappiness 只影响"回收时多倾向匿名页",文件页回收另有一套,别当万能开关;② 设成 0 不等于关掉 swap,内存真不够时内核会强制换出,抖得更狠;③ 数据库场景要同时确认 si/so 归零,光设参数不验证等于没做。
Q4:怎么判断是内存泄漏而不是正常增长?
三个信号叠加:① 某个进程的 RSS 只涨不跌(正常会有波峰波谷,GC/请求结束后回落);② /proc/meminfo 的 Slab / SUnreclaim 持续上涨且不回落(内核态泄漏,进程 RSS 反而不涨);③ slabtop 里某类对象 USE 100% 且对象数持续涨。如果内存涨到一定程度后稳定下来,那是新的正常水位(缓存预热),不是泄漏;只涨不跌才是泄漏。
Q5:为什么 OOM 杀的是我的关键进程,而不是那个大内存的普通进程?
OOM killer 按 oom_score(badness 分数)挑受害者,分数主要看内存占用,但会被 oom_score_adj 修正。如果你的关键进程 oom_score_adj 是 0 或正数,而那个"大内存进程"设了负数,内核会优先考虑分数高的——哪怕它占用更少。所以:关键进程设 oom_score_adj=-500,非关键批处理设正数,让"谁该死"由你说了算,而不是默认规则。
Q6:available 明明还有好几个 G,怎么还是 OOM 了?
available 是"理论上能立即给的内存",但内存分配是瞬间的。比如 JVM 突然要扩 2G 堆,或者一次大查询要 mmap 大量内存,瞬间分配超过 available,直接 reclaim 都来不及,就会 OOM。available 是长期趋势指标,不是"防瞬间尖峰"的保险。看 memory.peak(cgroup v2 有 memory.peak)或 vmstat 的 r 列突变,才能抓到瞬间压力。
写在最后:内存排障的核心就一句话——free 看 available,vmstat 看 si/so,OOM 看 dmesg 的 constraint,容器看 memory.events。四个入口记住,剩下的都是细节。我最想强调的是:OOM killer 杀人不打招呼,提前给关键进程设 oom_score_adj、给容器核对 Xmx 与 memory.max 的比例、给数据库压 swappiness,这三件事做在故障前,比任何排障技巧都值钱。