Skip to content

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 开始。这是最容易被误读、也最容易被误判的一条命令。

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:

grep -E '^(MemFree|Buffers|Cached|SReclaimable|SUnreclaim|Shmem|Slab|MemAvailable)' /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)持续非零。

vmstat 1 5

你应该看到什么(每 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/O
  • b 列伴随走高 → 进程被阻塞在换页上,这就是"系统没死但业务超时"的根源

为什么说"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 完全没用。

ps aux --sort=-rss | head -15

你应该看到什么:

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:按真实内存排序

# 需要安装:apt install smem 或 yum install smem
smem -rsk -t   # -r 排序 -s 按 rss -k 人类可读 -t 汇总

你应该看到什么:

  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 文件页

内存紧张时内核要找"可回收的内存",分两类:

  1. 文件页(page cache):把脏页写回磁盘(writeback)就能释放。这是首选,因为它不需要交换、只是把已缓存的数据落盘,代价小。这就是为什么 buff/cache 高时内存压力通常还能扛——内核在默默回收文件页。
  2. 匿名页(anonymous):进程的堆、栈、mmap 匿名映射。没有磁盘后端,必须换到 swap(或 zram/zswap)才能释放。代价是磁盘 I/O,这就是 si/so 的来源。

所以 swappiness 只影响"内核多愿意回收匿名页",不影响文件页回收。文件页回收永远优先,除非脏页写回跟不上。

4.2 kswapd:后台回收(无感)

每个 NUMA 节点有一个 kswapd 内核线程。当空闲内存跌破 low watermark,kswapd 被唤醒,在后台异步回收,目标是把空闲内存恢复到 high watermark。这个过程中进程无感知,不阻塞。

# 看 kswapd 是否在忙碌(占用 CPU 高说明回收跟不上)
ps aux | grep kswapd | grep -v grep

4.3 direct reclaim:直接回收(进程卡顿的元凶)

如果 kswapd 回收速度跟不上分配速度,空闲内存继续跌破 min watermark,分配内存的进程就会亲自下场做回收——这就是 direct reclaim(直接回收)。此时进程在分配路径上同步等待回收,表现为 vmstat 的 b 列升高、free 阴跌、应用延迟暴涨。这是"系统活着但业务在跳秒"的直接原因。

4.4 三档水位

水位 含义 触发动作
high 高水位 kswapd 停止回收(回血完毕)
low 低水位 kswapd 被唤醒,开始后台异步回收
min 最低水位 进程被迫 direct reclaim,同步阻塞

查看当前水位和阈值:

cat /proc/zoneinfo | grep -A 30 'Node 0, zone Normal' | grep -E 'min|low|high|nr_free'

你应该看到什么(示例,单位页):

        min      45
        low      60
        high     75
        nr_free  20000

经验值: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 分数,分数最高者被选为受害者。简化公式:

badness ≈ 进程的内存占用(RSS + swap + 页表等) + 权重修正
          除以 该进程的 oom_score_adj 相关因子

关键变量:

  • 占用越大,越容易被杀(这是主项,大内存进程首当其冲)
  • 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 发生后,第一手证据在内核日志:

dmesg -T | grep -i -E 'out of memory|killed process|oom' | tail -30

你应该看到什么(完整 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。

cat /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 字段。

grep -E '^(Slab|SReclaimable|SUnreclaim|Dentry|Inode)' /proc/meminfo

你应该看到什么:

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:定位是哪个内核对象在泄漏

slabtop -o   # -o 按占用排序,-s c 按缓存大小排序

你应该看到什么:

 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% 且对象数持续上涨 → 该类对象泄漏
  • dentry 100% 上涨 → dentry 泄漏
  • kmalloc-* 100% 上涨 → 内核模块/驱动泄漏
  • filp(文件句柄)上涨 → 文件句柄泄漏(配合 lsof | wc -l 看)

8.3 进程 RSS 持续增长曲线

用户态泄漏的典型特征:RSS 只涨不跌,或涨上去后不回落。

# 每 60 秒记录一次 RSS,连续采集,观察曲线
watch -n 60 'ps -o pid,rss,vsz,comm -p <pid>'

更精确的曲线采集(写入日志):

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 任务减少临时文件创建,并手动触发回收:

echo 2 > /proc/sys/vm/drop_caches   # 强制回收 dentry + inode + page cache

教训:内存只涨不跌、且所有进程 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,这三件事做在故障前,比任何排障技巧都值钱。