凌晨三点被内存告警吵醒,这事儿干运维的应该都不陌生。我爬起来看了一眼监控面板:内存使用率 95%,剩余不到 2G。结果登录服务器跑free -h一看,used 才占了一半,大部分是 buff/cache;再跑top看进程,没有一个进程的 RSS 特别夸张。是不是很像见了鬼?内存这种资源跟 CPU 不一样,CPU 使用率飙高,你一眼就能看到是哪个进程在作妖;内存则是多本账叠在一起:进程私有内存、共享内存、page cache、内核 slab、页表、tmpfs……单看任何一本账都找不到真凶。
这是性能诊断系列的第二篇,上一篇我们聊了 CPU 诊断,这一篇就集中收拾内存问题。文章不会停留在“内存不够就加内存”这种层面,而是带你从原理到命令、从现象到案例,一步步把吃掉内存的那个“隐形怪兽”揪出来。不管是刚入门的新手,还是已经在排查路上绕了好几圈的老手,这篇都值得收藏慢慢看。
1. 先搞清楚:内存到底被谁吃掉了
1.1 内存诊断的第一性原理
要揪出内存问题,先把内存分配这件事拆开。我们平时说的“内存使用率”,往往是把物理内存当成一个大水池,但池子里其实分了好几层:用户态进程的匿名内存、内核态对象的 slab 缓存、文件系统用的 page cache、共享内存段、页表本身……这些每一块都可能吃内存,而且它们的“可回收性”完全不同。
从内核视角看,物理内存页分为两类:可回收页和不可回收页。page cache 属于可回收页,系统内存紧张时会自动写回并释放;而进程的匿名内存、内核 slab 中不可回收的部分,属于不可回收页,内存压力大时只能靠 swap 或者直接杀进程。理解了这一点,你再看free命令的输出就不会一头雾水了。
free命令展示的字段,不同版本和不同系统上显示维度有差异,但核心逻辑是一致的:
- total:物理内存总量
- used:已被分配的内存(含不可回收部分)
- free:完全空闲的物理内存
- shared:tmpfs 等共享内存占用量
- buff/cache:文件缓存、块设备缓冲等可回收内存
- available:在不触发 swap 的前提下,还可以分配给进程的估算值
很多人习惯盯着 used 和 free 看,这是最大的误区。used 高不代表内存不够,只要 available 还有余量,系统就能正常服务。反过来,free 接近 0 也不代表危险,page cache 吃掉大量内存是正常现象,Linux 的设计就是这样,用尽量多的空闲内存来做缓存。
1.2 常见的内存消耗类型
我习惯把内存消耗分成以下几类,每一类都对应不同的排查手段:
- 进程匿名内存(anon):进程堆、栈分配的内存,不可回收,属于真正“吃”内存的东西。
- 文件缓存(page cache + buffer):读文件、写文件时产生的缓存,可回收,内存不够时内核会自动清理。
- 内核对象缓存(slab):内核为 dentry、inode、kmalloc 对象等维护的缓存,部分可回收,部分不可回收。
- 共享内存(tmpfs、SysV shm):
/dev/shm、ipcs -m创建的共享内存段,本质占用物理内存且一般不自动释放。 - 页表(page table):每个进程维护虚拟地址到物理地址映射的元数据,进程数量多、虚拟内存大时页表也能吃掉不少内存。
- 设备映射和显存映射:GPU、DMA 等映射到物理内存的区域,这类在服务器上常见于 GPU 计算场景。
把内存分类这一步做好了,后面所有的排查命令才有意义。比如你看到 page cache 占了 80G,你完全可以淡定;但如果你看到 slab 不可回收部分一直在涨,那就要警惕内核模块或者文件系统层出的问题。我见过不少人一看到内存高就直接清 cache,这在生产环境属于典型的治标不治本,页缓存本来就是用来提升文件读写性能的,你把它清了,下次请求又要重新读盘,业务反而变慢。
2. 自上而下的快速定位法
2.1 第一站:free 与 /proc/meminfo
接到内存告警,第一件事不是直接 top,而是先看free和/proc/meminfo,把内存的“大账”拉出来。
free -h我一般会关心 available 这一列,而不是 used。available 是内核综合考虑 page cache 可回收性、slab 可回收性、内存水位等因素后,估算出的“还能安全分配多少内存”的数值。如果 available 已经很低,说明系统真的逼近内存极限了。
接着看/proc/meminfo里的细节:
cat /proc/meminfo重点看这些字段:
- MemTotal:物理内存总量
- MemFree:完全空闲的内存
- MemAvailable:可用内存估算值
- Buffers:块设备缓冲
- Cached:文件缓存页
- SwapTotal / SwapFree:交换空间总量和剩余
- Active / Inactive:活跃和不活跃的匿名页与文件页
- Slab:内核 slab 缓存总量,SReclaimable 和 SUnreclaim 分开看
- Shmem:共享内存(tmpfs)占用
MemAvailable 这个值非常关键,它不像 free 那样容易误判。举个例子,如果 MemFree 只有 200M,但 Cached 有 50G,MemAvailable 可能是 40G,说明系统虽然空闲内存少,但可回收缓存充足,短期内存压力不大;反过来,如果 MemAvailable 只有几百 M,即使 MemFree 还有 2G,也要小心了,因为系统可能随时进入内存回收甚至 OOM 状态。
2.2 top 与 htop 的组合拳
确认整体内存水位之后,下一步是看进程级别的内存占用。top是最常用的工具,但很多人没抓住重点。
top -o %MEM-o %MEM让 top 直接按内存占用降序排列,省去交互按 M 的步骤。在 top 的进程列表里,有几个关键字段:
- VIRT:进程虚拟内存总量,包含共享库、映射文件、堆栈的虚拟地址空间,不是真实物理内存占用,看看就好。
- RES:驻留内存,即进程当前实际占用的物理内存,包含匿名页和文件页。
- SHR:共享内存的大小,表示进程映射的共享内存和共享库的物理页。
注意,RES 是一个被高估的指标。两个进程如果同时映射了同一个 10G 的共享内存段,ps和top里每个进程的 RES 都会显示 10G,但物理内存实际只消耗 10G,不是 20G。这就是共享内存导致的“重复计数”问题。
如果你习惯用 htop,操作更直观:按 F6 选择 sort by,再选 PERCENT_MEM 或 M_RESIDENT,就能按内存排序。htop 还会用颜色把内存条分成 used、shared、buffers、cache 等,一眼就能看出当前内存结构。
实战中我遇到最多的情况是:top 里所有进程的 RES 加起来不到 40G,free显示 used 却有 70G,那多出来的 30G 去哪了?这个时候就要进入下一层,去检查进程 RSS 之外的东西:内核 slab、共享内存段、page cache。这也是我自己总结的一个排查原则:先在“进程账本”里找答案,找不到就立刻跳到“内核账本”和“文件系统账本”。
3. 揪出隐形怪兽的三大手段
3.1 用 ps 把每个进程的账本翻出来
top 适合交互式观察,但要快速导出数据、留作记录,ps更顺手。
ps aux --sort=-%mem | head -20这个命令按内存占用从高到低列出前 20 个进程,%MEM表示该进程 RSS 占物理内存的百分比。实际生产中,我会多跑一条:
ps -eo pid,ppid,user,rss,vsz,args --sort=-rss | head -30把 RSS 以 KB 为单位排出来,进程参数也带上,方便判断这个进程是做什么的。这里有一个细节:rss是 KB,不是字节,在脚本里做阈值判断时要换算,别踩坑。
用 ps 排查时,真正要警惕的不是那些 RES 高的进程,而是那些 RES 不算高,但虚拟内存 VIRT 高得离谱的进程。比如 Java 进程的 VIRT 轻松上几十 G,这是 JVM 预留的堆外内存和直接内存,不一定真的占用物理内存;但如果是 C/C++ 程序 VIRT 持续增长,就要考虑是否存在内存映射泄漏,比如反复 mmap 没有 unmap。
3.2 smem 与 RSS 的真相
我在前面提过,RSS 会把共享内存重复计算。要拿到更真实的内存占用,推荐配合smem工具使用。
smem -t -k -s uss | head -30smem 会输出三列关键数据:USS(Unique Set Size,进程独占的物理内存)、PSS(Proportional Set Size,按比例分摊共享页后的进程内存)、RSS。判断真实内存消耗,优先看 USS 和 PSS。比如一个进程 RSS 显示 10G,但 PSS 可能只有 2G,说明绝大部分是共享映射,不是你该盯的“怪兽”。
典型的例子:Java 的 ParallelGC、G1 都使用了大量共享内存映射;使用 jemalloc 或 tcmalloc 的高并发服务,也可能出现单个进程 RSS 虚高的情况。用smem -m还能查看按映射分类的内存占用,比如哪些文件映射占了多少内存,哪些匿名映射占了多少。
如果没有条件安装 smem,也可以用procfs的手工方式估算 PSS:读取/proc/<pid>/smaps或/proc/<pid>/smaps_rollup,把每个映射的 Private 和 Shared 按比例算出来。不过这个方式比较繁琐,我还是推荐直接用 smem。
3.3 OOM 记录与内核日志
有时候你还没开始排查,系统就已经帮你干掉了肇事进程——OOM Killer 出手了。OOM Killer 是内核在内存严重不足时,根据评分选择杀掉一个或几个进程来释放内存的机制。很多运维第一次遇到服务突然消失,都不知道是被内核杀的。
查 OOM 记录的方法:
dmesg -T | grep -i -E "oom|killed process" | tail -50或者用 journalctl:
journalctl -k -b | grep -i -E "oom|out of memory" | tail -50输出里会看到类似这样的一行:
Out of memory: Killed process 12345 (java) total-vm:4065728kB, anon-rss:3104520kB, file-rss:132kB这几项信息很有用:被杀的 PID 和进程名、虚拟内存、匿名 RSS、文件页 RSS。如果被杀的进程不是你预期中的内存大户,而是某个不重要的小进程,说明 OOM 评分机制认为它“该死”,触发原因可能是它的 oom_score 太高,也可能是内存 cgroup 限制导致。
这里提醒一句:千万别在生产环境直接echo f > /proc/sysrq-trigger来模拟内存压力触发 OOM,这种操作会让整个系统崩溃重启,造成二次事故。应该用systemd的MemoryMax或者 cgroup v2 的memory.high在容器或测试机里复现。
4. 内核态的内存开销不可忽视
4.1 slab 与 kmem 的排查
进程消耗是内存账本里最显眼的一页,但很多“隐形怪兽”藏在内核态。最常见的就是 slab。slab 是内核为了复用对象而维护的缓存机制,简单理解就是内核自己搞了个对象池,避免频繁分配和释放内核数据结构。
排查 slab 占用:
cat /proc/meminfo | grep -i slab重点关注两个值:SReclaimable(可回收的 slab,比如 dentry cache、inode cache)和 SUnreclaim(不可回收的 slab)。SUnreclaim 长期居高不下,通常意味着内核模块或者驱动存在对象泄漏,比如某些网卡驱动、存储驱动的 DMA 缓冲区未正确释放。
再进一步看 slab 里到底谁是占内存的大头:
slabtop -s c-s c表示按 cache 大小排序。常见的占用大户是dentry和inode,这两个是文件系统路径和文件元数据的缓存。如果 dentry 占用极高,说明你的系统在反复访问大量小文件,文件创建和删除频繁。这种情况下,可以适当调低vfs_cache_pressure的默认值吗?实际上,如果 dentry/inode 占用太高,调高vfs_cache_pressure(比如从默认 100 调到 150)会让内核更积极地回收这些缓存,但副作用是文件系统路径解析的性能下降。我个人的建议是:先找根因,比如某个日志组件每次写入都创建临时文件,这才是根本问题;缓存的参数调整只能救急。
4.2 页表与内存碎片
页表是另一个容易被忽略的内存消费者。每个进程都有自己的页表,用来维护虚拟地址到物理地址的映射。页表本身也要占内存,规律是:进程虚拟内存越大(VMA 越多、页表项越多),页表占用越高。
查每个进程的页表大小:
grep VmPTE /proc/<pid>/status一个 PTE 通常是 8 字节,映射一个 4KB 的物理页。如果进程的 RSS 是 40G,那么页表大约需要40G / 4K * 8 = 80MB,看着不大,但如果服务器上跑了 100 个这样的进程,页表就要吃掉 8G。这种场景在 Java 微服务的 K8s 节点上很常见——每个 Pod 里都是大堆 JVM,页表总量很容易被忽视。
内存碎片也要提一句。虽然现代内核的 buddy allocator 会努力做内存规整,但在长时间运行、频繁分配和释放不同阶数内存页的服务器上,/proc/buddyinfo里高阶连续内存块可能长期不足。这会导致即使系统还有大量空闲内存,内核也无法分配多个连续的物理页,进而触发内存回收甚至报错。你可以用以下命令观察:
cat /proc/buddyinfo如果Node 0的高阶块(比如 order 10)长期接近 0,说明碎片化比较严重。不过这事一般不需要普通业务运维干预,内核的 compact 机制会自动处理部分场景;真要优化,通常得靠调整 THP(透明大页)策略、避免内存碎片化太严重的分配模式。
4.3 tmpfs 与共享内存
tmpfs 是把内存当作文件系统来用的典型案例,最典型的就是/dev/shm。默认情况下,/dev/shm大小是物理内存的一半,很多程序会自动往里面写临时文件,比如某些数据库、消息队列、浏览器,写多了物理内存就被吃了。
查 tmpfs 占用非常简单:
df -h /dev/shm find /dev/shm -type f -exec ls -lh {} \; | sort -k5 -h | tail如果是 SysV 共享内存,用ipcs -m查看:
ipcs -m看 nattach(挂载进程数)和 cpid(创建进程 PID),定位是哪个进程创建的共享内存段。
共享内存的特点是不会自动释放,即使创建它的进程退出了,只要内核中的 shm 对象没被删除,内存就继续被占着。我处理过一个真实案例:某个消息队列组件的 worker 进程崩溃后,残留的共享内存段没人清理,free -h里 used 一直偏高,进程列表却看不出异常。最后用ipcs -m找到残留段,确认业务上没人再用之后,用ipcrm -m <shmid>手动清理,内存才降下来。
5. 实战案例:一次内存泄漏的完整追踪
5.1 现象与初步判断
光讲理论不过瘾,我拿一个真实处理过的内存泄漏案例来完整走一遍排查流程。场景是一个 C++ 写的长连接网关,部署在 8C16G 的机器上,运行一周后内存使用率从 40% 慢慢爬到 95%,重启进程后又会好两天,反复循环。
第一阶段的排查是这样做的:
- 先用
free -h确认整体内存水位; - 再用
ps aux --sort=-%mem | head看哪些进程内存高; - 发现网关主进程 RSS 从占用 2G 涨到了 9G,其他进程都正常;
- 然后用
top -p <pid>持续观察,心跳正常,但 RSS 每分钟涨几十 MB。
到这里基本可以断定是进程级内存泄漏,不是系统级问题。接下来要回答的是:这个进程里的内存到底漏在哪里。
5.2 用 valgrind 和 jemalloc 的 heap profile 定位
定位内存泄漏,第一反应是 valgrind。但生产环境跑 valgrind 会让程序慢 20 到 50 倍,根本没法直接上线上跑。所以我的做法是:在测试环境用同样的流量模型复现,再上 valgrind。
valgrind --tool=memcheck --leak-check=full --show-leak-kinds=all --log-file=valgrind.log ./gateway跑上十几分钟,让流量把代码路径全部触发一遍,然后对输出里的definitely lost和indirectly lost做分析。这个方案适合程序能离线复现的情况。如果现场程序用的是 jemalloc,还有更轻量的手段——直接用 jemalloc 自带的 heap profile 功能:
MALLOC_CONF=prof:true,lg_prof_interval:30,prof_prefix:/tmp/jeprof ./gateway这样每 30 秒(lg_prof_interval:30里的 30 是 2 的 30 次方字节,也就是每 1GB 分配触发一次 dump,不是秒,注意别搞错)或一定分配量后,jeprof 会生成堆快照文件。再用脚本对比两次堆快照,找出分配增长最多的调用栈。
jeprof --show_bytes --base=/tmp/jeprof.0001.heap /tmp/jeprof.0002.heapjeprof 的输出结果里,哪个函数分配的内存增量最大,基本就是泄漏点了。当时我们定位到是一个连接管理模块在建立每个长连接时,都会 new 一块 8KB 的 buffer,但连接正常关闭时这块 buffer 没有释放,导致连接数越多,RSS 涨得越快。修复方式就是补上释放逻辑,或者改用智能指针。
5.3 修复与验证
修复代码后,不能只是跑一下没问题就完事,内存泄漏的验证要看趋势。
我的验证方法是:
- 在灰度环境部署修复后的版本,连续压测 4 小时,用
ps -o rss -p <pid>周期性采集 RSS; - 把采集数据画成曲线,观察是否趋于平稳;
- 对比修复前后的 RSS 增长斜率,修复前每小时涨 200MB,修复后 4 小时只波动了几十 MB。
同时还要在监控平台上加上“进程 RSS 增长率”的指标。我习惯设置两级阈值:比如 RSS 在 10 分钟内持续增长超过 200MB 就触发黄色告警,30 分钟增长超过 1GB 就触发红色告警。很多内存泄漏不是一下炸掉系统的,而是慢慢把内存池舔穿,过程可能持续几天甚至几周,没有趋势监控根本发现不了。
6. 常见问题与排查技巧实录
6.1 排查后内存高但进程列表看不到
这是我最常被问的问题:“free 显示 used 很高,但 ps/top 里看不到哪个进程吃内存,是不是中毒了?”别急着下结论,先按顺序排查:
- 看 SReclaimable 和 SUnreclaim 是否异常;
- 看 page cache 和 buffer 是否正常;
- 看 tmpfs 和 SysV shm 是否有残留;
- 如果上面都正常,再看是不是跑在容器里,宿主机的
/sys/fs/cgroup/memory/下的 cgroup 统计是不是有不同口径。
我的排查脚本如下:
grep -E "^(MemTotal|MemFree|MemAvailable|Cached|Buffers|Shmem|Slab|SReclaimable|SUnreclaim)" /proc/meminfo ipcs -m df -h | grep -E "tmpfs|shm"绝大部分“进程列表看不到”的场景,都是 tmpfs 或共享内存段在作祟。还有一个隐蔽的场景是内核模块或驱动通过kmalloc分配了不可回收内存,在 slab 里看不出来,但这属于少数,需要结合dmesg日志和厂商驱动文档排查。
6.2 内存告警但实际能正常服务
告警平台显示内存使用率 98%,但业务延迟很低、接口也正常,到底要不要处理?这种场景多半是监控指标的算法和内核 available 的口径不一致。很多监控平台用的是used / total,其中 used = total - free - buff/cache,而 buff/cache 可能高达 70%,导致 used 超过 90%,看起来非常吓人。
正确的做法是:让监控平台改用 available 作为告警依据,或者同时展示 available 和 used 两条曲线。
在 Linux 上,available 的计算逻辑大概是:MemFree + 可回收的 page cache + SReclaimable + 可回收的 swap cache,再减去内存水位的保守值。所以MemAvailable才是“在不影响系统稳定的前提下,还能给进程用多少”的参考值。如果 available 还有 30%,但告警 says 98%,那就是监控口径要改,而不是系统真的要炸。
6.3 定位内存问题的经验口诀
这些年排查内存问题,我总结了一套自己的执行顺序,分享出来供参考:
- 先看 available,不看 used;
- 再看进程 RSS,但要注意共享内存重复计算;
- 然后查 slab 和共享内存段,排除内核态和 tmpfs 的干扰;
- 最后才考虑用 valgrind 或 heap profile 深挖单个进程。
另外还有几个经验值:
- 如果
Cached占了总内存 70% 以上且 available 充足,不用管; - 如果
SUnreclaim持续增长,优先怀疑内核模块或驱动; - 如果某个进程 RSS 和 PSS 差距过大,优先排查共享内存映射;
- 如果容器经常 OOMKilled,优先检查
memory.limit是否设置得过小,而不是一味加大宿主机内存。
最后一个忠告:不要在没搞清楚 root cause 的情况下直接kill -9。内存问题很多时候是业务代码的问题,杀进程只能缓解症状,过两天还会再来。你要做的是把那个进程的堆内存、文件缓存、线程栈、共享内存全部过一遍,找到真正漏内存的那一行代码,然后修复它。
我自己从这些坑里爬出来的体会是:内存诊断这件事,七分靠对原理的理解,三分靠命令熟练度。把/proc/meminfo里的每一列都搞明白,把 RSS、PSS、USS 的区别吃透,再配合几个实例走一遍,以后再遇到内存“隐形怪兽”,心里就有底了。下一次如果你也接到凌晨三点的内存告警,不妨先泡杯茶,按这篇的顺序走一遍,多半能比折腾一晚上重启服务更快找到问题。