☰
RAM性能问题排查实战:从free、vmstat到OOM根因定位
2026/10/20 7:08:31 网站建设 项目流程

线上系统出问题,第一反应往往不是 RAM。CPU 打满、磁盘 IO 慢、网络丢包,哪个都比内存显眼;可真到Out of memory把进程杀掉的那一刻,往往已经来不及补证据了。我处理过的性能问题里,RAM 这类最容易被误判——它有时候表现为整机卡死,有时候表现为服务时不时变慢,甚至还出现过某个进程偷偷吃光全部内存后,业务侧还在查 SQL 慢查询。这篇文章把性能问题分析方法里关于 RAM 的部分整理成一套能落地的排查流程,重点讲怎么用 free、vmstat、/proc/meminfo、smaps 这些基础工具,把内存问题从现象一路追到根因。适合经常在线上救火的 SRE、后端开发,以及想从“会用命令”升级成“会分析内存”的同学参考。

1. RAM 出了问题,先用三个“边界题”确认它

1.1 内存问题最容易伪装成“别人”的问题

先讲一个我见过很多次的场面:某服务突然大量超时,监控面板上看 CPU 只有 20%,磁盘 IO 也很低,网络流量正常。所有人开始查慢日志、查连接池、查代码,折腾半天,最后在dmesg里看到一行Out of memory: Kill process ...。这就是内存问题的典型伪装——它不是自己喊疼,而是让业务进程变慢、让别的指标出现异常。

RAM 相关的性能问题,常见的症状有这么几类:

  • 可用内存持续下降,最后触发 OOM Killer,关键进程被系统杀掉;
  • 系统开始频繁使用 swap,si、so数值不停跳,服务延迟被拉高;
  • 某进程 RSS 一直涨,重启后恢复,但过一段时间又涨上去;
  • 内存总量看着充足,但延迟出现周期性毛刺,请求偶尔慢上几百毫秒。

这些现象很容易被误判成“代码效率低”“GC 参数不对”“数据库慢查询”。所以在动手之前,先回答三个边界题:这是不是内存问题?是哪一层的内存问题?是容量问题、延迟问题,还是带宽问题?

1.2 把“RAM 性能问题”拆成三个维度

我习惯把内存问题拆成三个维度来看,避免一上来就钻进进程里找泄漏。

第一是容量维度。物理内存或者 cgroup 限制不够用,导致内核必须回收 page cache、换出匿名页,甚至触发 OOM Killer。这个维度看的是MemAvailable、free、SwapUsage、memory.events。

第二是延迟维度。内存分配本身变慢,或者访问内存路径变慢。常见原因包括 THP(透明大页)拆分时的停顿、缺页异常过多、NUMA 远端内存访问、堆分配器锁竞争。这个维度看的是分配耗时、pgfault、numastat、perf里的采样。

第三是带宽维度。多路服务器上,内存控制器带宽是共享的。某个进程疯狂读写内存,会拖慢整台机器其他程序。表现出来就是:CPU 不高,但程序执行时间边长。这个维度需要用perf stat或者内存带宽工具去测。

把这三个维度分开,后面很多“玄学”问题都能对上号。

1.3 我习惯的基本套路:时间轴 + 分层

排查 RAM 问题,我基本都走固定套路:先用时间轴定位拐点,再按系统层、进程层、分配路径这三层逐层下钻。

时间轴非常重要。开机时内存正常,发版两个小时之后开始涨,这种问题基本跟新代码有关;每天凌晨定时任务跑完后开始涨,那就是任务里某个模块没释放;没有任何规律地突然掉到谷底,大概率是流量冲击。没有时间轴,你只能看到快照,永远看不到趋势。

分层下钻的顺序是从外到内。先看系统级:整机还剩多少可用内存、swap 在不在动、OOM 有没有发生过。再看进程级:哪个进程的 RSS 在涨、它的 rss 和 swap 分别是多少。最后才进到进程内部:地址空间里的映射、堆、mmap、分配器状态。绝大多数问题在第二层就能定位,真正要进到第三层的是少数,但那时已经是“决胜局”。

2. 证据链怎么建:常用指标与工具选型

2.1 系统级先看四样:free、vmstat、sar、/proc/meminfo

很多人习惯先敲free -m,但只盯着 free 列看过没用。free输出里真正要关注的是available这一列——它代表在不触发 swap 的情况下,还能给新进程分配多少内存。Linux 会把空闲内存尽量拿去做 page cache,所以free列经常很低,但这不代表内存不够。

我第一次排查线上问题时,在一个内存看起来“只剩 300MB”的机器上折腾了半天,后来才发现Cached占了 20 多 GB,完全可以回收,实际available还有 15GB 以上。这就是典型的把 page cache 当成“已用内存”的误判。

vmstat 1 5可以看内存和交换的实时变化。重点看si和so两列。如果 swap 的 in/out 数值持续大于 0,说明系统正在发生内存页换入换出,这是内存压力的直接信号。另外cache列持续快速增长,说明文件缓存占了大量内存;如果free持续下降而cache变化不大,才更像进程内存问题。

sar -r是看历史的利器。很多现场我们赶不上,但监控系统里只要留了 sar 数据,就能回放内存变化趋势。我习惯在排查之前先拉过去 24 小时的sar -r和sar -B,把内存和缺页趋势图先画出来。这样不用瞎猜,直接看拐点出现在哪个时间段。

真正的信息大头在/proc/meminfo。这里面每一项都是内核的“记账本”。日常排查时,我优先看这几个字段:

字段含义什么时候需要警惕
MemAvailable实际可用的估算值持续走低,逼近 0
MemFree完全空闲的页低不代表危险
Cached文件缓存高是正常的,可回收
AnonPages匿名页,通常是进程堆和栈持续增长,说明有进程在吃内存
SwapCachedswap 缓存页持续高,说明 swap 频繁
Dirty / Writeback待回写脏页大量 D 状态说明 IO 跟不上
Slab内核缓存SUnreclaim 持续涨,内核对象泄漏
PageTables页表占用的内存进程映射数异常时会很大
Committed_AS承诺给进程的内存总量接近 CommitLimit,overcommit 风险高

2.2 进程级再看三样:RSS、PSS/USS、smaps

系统级确认是内存压力后,下一步就是找进程。我最常用的是ps aux --sort=-rss | head -20,直接按物理内存排序,一眼看到最大的占用者。

但这里有个坑:top和ps显示的 RSS 包含了进程独占的匿名页,也包含了共享库中映射进来的一部分。两个进程同时加载同一个 so,RSS 会把共享部分各算一遍,总和可能超过实际物理内存。所以判断“某个进程到底真实占多少”,要看 PSS 或 USS。

  • RSS: Resident Set Size,驻留物理内存总量,包含共享部分;
  • PSS: Proportional Set Size,按共享比例分摊后的内存;
  • USS: Unique Set Size,进程独占的物理内存。

拿到 PID 后,看/proc/PID/status里的VmRSS、VmSwap能快速了解进程状态。要更细的地址空间分布,就看/proc/PID/smaps_rollup,这里会把 RSS 拆成 RssAnon、RssFile、Shared_Clean、Shared_Dirty、Private_Clean、Private_Dirty。如果 RssAnon 很高,说明进程堆里东西多;如果 RssFile 很高,说明它映射了大量文件。

pmap -x PID可以看进程整个地址空间里的分段分布,比如 [heap]、[stack]、某个 so 的映射、某个 mmap 的文件区域。它适合找“哪一块地址段在变大”。

另一个容易忽略的是slabtop。有些内存不是用户进程占的,而是内核对象占的。比如文件系统缓存用了dentry、inode,网络收发包用了skbuff_head_cache。如果SUnreclaim一直涨,说明有内核对象无法回收,典型的像内核模块泄漏、某些驱动没正常释放。

2.3 不要漏掉的场景:cgroup 限制与 NUMA 访问

现在的服务大多跑在容器里,不能只看宿主机。memory.max、memory.usage、memory.events这几个文件是判断容器内存压力的关键。memory.events里如果oom_kill计数在涨,说明这个容器自己触发了 OOM,跟宿主机没关系。

还有一种情况:宿主机内存充足,容器内存也没超限,但性能差。这时候要考虑 NUMA。多路服务器上,CPU 访问本地节点内存快,访问远端节点内存慢。如果进程被分配到 CPU0 上,但内存被分配在 Node1,就会产生 Remote Access。用numastat能看到 numa_hit、numa_miss、other_node 这些指标。numa_miss 高,说明内存分配时跑到了远端节点,延迟自然上去。

3. 四个经典场景:从表象推到根因

3.1 场景一:内存只涨不跌,是泄漏还是膨胀?

“内存只涨不跌”是我遇到最多的内存问题。但要先分清楚:是真泄漏,还是进程某些缓存结构本来就会膨胀到一定程度然后稳定下来。

判断方法很简单:把时间维度的采样拉长。如果 RSS 曲线一直上升,并且重启后能回到基线,但过了一段时间又继续上升,而且没有触顶回落,高度怀疑是泄漏。如果它涨到某个水平就稳定了,那更可能是缓存膨胀。

接着要判断是“谁”在涨。Java 进程先看堆内和堆外,jmap -heap和 GC 日志能告诉我们 heap 使用情况;如果 GC 之后老年代还在涨,那是对象引用泄漏。C++ 进程要看分配器,用 jemalloc 的 prof 功能或者 tcmalloc 的统计,能拿到分配栈。实在不行,可以用/proc/PID/smaps_rollup看是 RssAnon 在涨还是 RssFile 在涨。RssAnon 涨,多数是堆;RssFile 涨,多半是 mmap 的文件区域没释放。

说白了,泄漏的本质不是“内存会涨”,而是“该释放的东西没有释放”。所以排查的一路要盯住引用关系和释放路径。

3.2 场景二:Swap 抖动,物理内存却看起来够用

内存明明还有很多,系统却一直在 swap,这是另一个容易迷惑人的场景。Swap 触发不只看“剩余内存少”,还要看内存回收的压力和swappiness参数。

当系统申请内存时,内核会先尝试回收 page cache,如果回收速度跟不上,就会把一些匿名页换到 swap。在某些配置下,即使 available 还有几个 G,内核也会因为要满足突发分配页面而启动回收。vmstat里如果si、so持续为非零值,说明内存在频繁换入换出,这会直接把服务延迟拉到毫秒级以上。

遇到 swap 抖动,先把vm.swappiness调低,比如从默认 60 改成 10 或 0。这会让内核更倾向于回收 page cache,而不是换出匿名页。但这不是治本,只是降低概率。真正要查的是为什么会产生大量匿名页,是不是有进程分配了超大块内存并且长期不释放。

还有一种情况是容器场景:cgroup 的memory.max到了,内核会把容器内匿名页换出。这种时候宿主机内存可能还很充足,但容器本身在承受“假性 swap”。用cat /sys/fs/cgroup/.../memory.stat看pgpgin、pgpgout就能看到页交换次数。

3.3 场景三:被 OOM Killer 半夜叫醒

OOM 是内存问题最激烈的表现,但它本身不是根因。dmesg -T | grep -i -E "oom|killed process"能直接看到 OOM Killer 杀掉了哪个进程、当时的total-vm、anon-rss、file-rss是多少,以及哪个进程触发了这次 OOM。

很多人以为 OOM 一定是“物理内存用光了”,其实还有两种常见情况:一是 overcommit 策略导致申请内存时被拒绝,二是 cgroup 限制导致容器内触发 OOM。查的时候一定要分清楚。

如果是物理内存用光,/proc/meminfo里MemAvailable在小数点级别徘徊,ps能看到一组进程的 RSS 加起来超过物理内存。如果是 cgroup OOM,memory.events里的oom_kill会告诉我们,而且被杀进程的 RSS 不一定很大。

某些服务被 OOM 杀了一次之后会自动重启,然后又因为流量打满内存被再次杀掉,这个循环很容易被误认为“负载高导致宕机”。处理方法是先调整oom_score_adj保护重要进程,再把根本的内存占用摸清楚。

3.4 场景四:内存看着充足,延迟却高频毛刺

这个是几个场景里最隐蔽的。机器 free 出来内存有 20G,进程 RSS 也正常,但请求延迟不定期出现几百毫秒的毛刺。

我踩过的一个典型坑是 THP。透明大页默认在很多发行版里是开启的,它用 2MB 大页降低 TLB miss。但当内存碎片比较厉害时,分配或者拆分大页会触发很长的同步操作。比如某些服务在做字符串复制、memcpy时,遇到大页的 split/defrag,就会卡上几十甚至上百毫秒。这种情况用/sys/kernel/mm/transparent_hugepage/enabled查看,perf里也能看到thp_*相关的耗时。

另一个容易忽视的是内存带宽。多个 CPU 核心同时大块拷贝内存时,内存控制器带宽会被占满。这种情况下 CPU 占用率看着不高,但程序的 wall time 会变大。perf stat -e cycles,instructions,stalled-cycles-backend如果后端停顿极高,就要怀疑内存子系统。

NUMA 远端访问也会造成类似问题。进程被调度到 Node0 的 CPU,但内存分配落在了 Node1,每次访存都要跨节点访问,延迟翻倍。用numastat -m可以看到每个节点内存使用情况,用numastat -p PID能看到进程在哪个节点分配了内存。

4. 实操复盘:从“内存上涨”到“抓到分配栈”

4.1 现场保全:挂一个轻量采样脚本

一旦怀疑有内存问题,第一件事不是马上找工具分析,而是把现场数据留下来。我就吃过亏:发现问题时没留历史,等到想分析的时候,内存已经回来了一半,无从查起。

所以我会在异常机器上先挂一个轻量脚本,5 秒采样一次,追加到本地日志。脚本很简单,关键是睡得久、不打扰。

#!/usr/bin/env bash # 保存内存现场:free、meminfo、OOM日志、RSS Top 20 LOG_DIR=/var/log/memtrace mkdir -p "$LOG_DIR" LOG_FILE="$LOG_DIR/$(date '+%Y%m%d').log" while true; do { echo "==== $(date '+%Y-%m-%d %H:%M:%S') ====" free -k echo "---- /proc/meminfo ----" grep -E 'MemFree|MemAvailable|Buffers|Cached|SwapCached|AnonPages|Dirty|Writeback|Slab|SReclaimable|SUnreclaim|PageTables|Committed_AS' /proc/meminfo echo "---- dmesg tail ----" dmesg -T | tail -30 echo "---- RSS Top 20 ----" ps aux --sort=-rss | head -21 | awk '{printf "%s %s %s %s %s\n", $1, $2, $4, $6, $11}' echo } >> "$LOG_FILE" sleep 5 done

这个脚本本身占用很少,关键是不挑磁盘空间,跑足 24 小时也就几十 MB。等下一轮问题出现时,这个日志就是最完整的“现场”。

4.2 锁定时间线与被杀的进程

有一次我在处理一个常驻服务的内存问题时,靠这个脚本拿到了完整时间线。日志显示从当日上午 11 点开始AnonPages一栏稳步上涨,每小时多出约 800MB。到晚上 10 点多,MemAvailable见底,dmesg里出现了 OOM 记录,明确写着Killed process 12345,那个进程的anon-rss是 4.3GB,file-rss只有 200MB。

这里有个重要信息:file-rss很低,说明它不是靠文件映射撑起来的,而是靠匿名内存,基本可以锁定为进程堆/匿名映射区的问题。这时候再看ps aux --sort=-rss,能确认 PID 12345 对应的服务名称。

锁定时间线还有一个好处,可以和发布记录、定时任务日志做交叉对比。那次正好下午有个小版本发布,内存从发布后半小时开始涨,嫌疑就落在了新版本某个模块上。

4.3 进入进程内部定位分配来源

确认了进程后,我继续往下钻。先用cat /proc/12345/smaps_rollup,看到RssAnon占到了 4.1GB,RssFile只有 200MB,所以匿名内存的嫌疑最大。

然后看地址空间分布:pmap -x 12345 | sort -k2 -n -r | head -20,发现有几个大小超过 512MB 的匿名内存段,在/proc/12345/maps里对应的权限是rw-p,没有关联任何文件,说明是匿名 mmap。这种大块匿名映射通常来自两种路径:要么是分配器一次性向内核申请的大内存块,要么是某个第三方库用 mmap 单独映射了一块缓存。

那一次查到这一步,基本就能断定是一个第三方库把某个字典表全量加载进了内存,并且没有做回收。因为进程内部是 C++ 服务,我用 jemalloc 自带的prof功能拿到了分配栈,直接看到调用链是“初始化字典 → 加载全量词表 → 常驻”。

如果手头没有 allocator prof,还可以用 gdb 附加进程后调用mallinfo()看分配器的统计信息。Java 进程则可以jmap -dump:live看对象直方图,哪一类对象实例数异常多,就往对应代码路径找。

4.4 验证修复效果,不要只看几分钟

找到根因后,修复方式是把那个字典表改成按需加载,并且限制缓存上限。改完上线,我把采样脚本又挂了一周,观察点只有两个:一是AnonPages是否回到基线并保持稳定,二是dmesg里 OOM 记录不再新增。

很多同学会在改完代码后重启进程,看到内存降下去了就收工。但重启本身就会把内存清一遍,不能说明问题解决了。必须等它运行足够长时间,确认曲线是平的才闭环。

那次修复后,服务运行 72 小时,MemAvailable一直稳定在 12GB 以上,swap 单独也没再出现,问题才算真正结束。

5. 常见误判与避坑清单

5.1 把 Page Cache 当成内存泄露

这是最普遍的一个误判。在 Linux 上,文件读写的页会被缓存,free显示 used 很高、free 很低,但available可能还有大量空间。page cache 本身就是用来占用空闲内存的,内核会在内存紧张时自动回收。看到Cached高不要慌,重点看MemAvailable是否足够,以及有没有频繁si/so。

5.2 只盯瞬时 TOP,不看历史趋势

内存问题十有八九是趋势问题。某个瞬间 RSS 第一名是服务 A,不代表整台机器的问题就是 A。也许 A 只是正常业务,真正泄漏的是服务 B,只是你抓取快照的下一秒 B 还没被“拍”到。所以大家要养成记录历史趋势的习惯,监控系统、sar、脚本日志都可以,没有历史趋势,内存排查就是摸黑。

5.3 漏掉内核内存:Slab、PageTables、SUnreclaim

进程排查一切正常,但内存就是不够,这时候一定要看内核占用的部分。slabtop如果显示dentry、inode_cache数量异常高,说明可能存在大量未删除的文件对象或目录项泄漏;PageTables如果好几 GB,说明页面映射数量极大,通常对应某进程 mmap 了海量小页面;SUnreclaim持续上涨,很多与内核驱动或文件系统实现有关,这种问题只靠用户态工具查不出来,要结合内核监控一起看。

5.4 忽略 cgroup 限制导致的“隐形 OOM”

容器里被 OOM 杀掉的进程,不一定是物理机内存不足,更可能是 cgroup 到达memory.max。查 OOM 时先打开两个文件:宿主机/proc/meminfo和容器/sys/fs/cgroup/.../memory.events。如果宿主机MemAvailable充足,而容器oom_kill计数在增长,那问题在容器侧的内存占用,别去扩物理机。

5.5 对 THP 和 NUMA 的影响后知后觉

内存充足但性能差,或者延迟出现毛刺,很多人会忽略 THP、NUMA、内存带宽这些硬件级因素。THP 拆分卡顿可以通过关闭 THP 来验证;NUMA 远端访问可以通过numactl调整内存分配策略;内存带宽问题则需要用性能采样工具确认。这类问题不在“内存容量”里,而在“内存访问路径”里,不补这块知识,排查盲区会很大。

6. 五分钟速查:我每次上线救火都会走一遍的流程

第 1 步:先挂数据。把上一节的采样脚本跑起来,即使已经在看问题了,也要先留后续证据。

第 2 步:看系统级状态。free -h看 available,vmstat 1 5看 si/so,sar -r拉历史趋势。如果 available 持续走低,进入下一步;如果 si/so 频繁跳动,重点查 swap 与回收路径。

第 3 步:查 OOM 痕迹。dmesg -T | grep -i -E 'oom|killed process',把 OOM 时间和进程记下来,同时看/proc/meminfo里的Committed_AS是否接近限制。

第 4 步:按 RSS 排序找进程。ps aux --sort=-rss | head -20,找到 TOP 进程,再用pidstat -r -p PID 1观察它的内存增长情况。

第 5 步:深入进程地址空间。cat /proc/PID/smaps_rollup分 RssAnon / RssFile,pmap -x PID看谁在涨,/proc/PID/status里看 VmSwap。

第 6 步:查内核内存。slabtop、/proc/meminfo里看 SReclaimable / SUnreclaim / PageTables,排除内核侧异常。

第 7 步:确认容器和 NUMA。查 memory.events 和 memory.max,用 numastat 看 node 分布。如果以上都正常,再用分配器 profile 或运行时对象统计继续下钻。

这套流程不一定每一步都走得到,但它能保证你从现象到根因之间不跳步。RAM 问题最怕的就是“上来就猜”,而这一套流程本质上是把现象拆成系统、进程、对象、路径四个层面,每一层都有数据和凭证支持,最后得出的结论才能说服自己,也能说服同事。

我个人在实际操作中的体会是:RAM 排查里最贵的东西不是工具,而是历史数据。很多问题在发生时没有留下记录,等你想查的时候,内存已经“自己恢复”了。所以不管性能问题是否紧急,先在机器上留一份轻量采样,成本很低,作用却大——它可能是下次故障时唯一的线索。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询