大半夜收到内存告警,登录服务器一看,dmesg 里躺着一行熟悉的字:Out of memory: Killed process。看到 OOM 这不速之客,心其实会凉半截——这说明系统的内存已经到了崩溃边缘,内核替我们做了那个最残酷的决定:挑一个进程“牺牲”掉。
很多人遇到 OOM 的第一反应是“内存满了,得释放内存”。但实际上 OOM 远不止“内存不够用”这么简单,它背后是一整套内存记账、回收、评分和强制清理的机制。这三个字母代表的是 Out Of Memory,是 Linux 内核在物理内存严重不足时,为了保住整机不死机而启动的最后手段。
这篇文章我把自己这些年排查 OOM 的经验完整梳理一遍:内核怎么判定内存不足、凭什么决定杀谁、日志应该怎么读、参数怎么调、哪些坑千万别踩。内容面向运维、后端开发,也适合那些被“内存泄漏”折腾得睡不着觉的新手。看完之后,下次再遇到 OOM,至少能找到下手点,而不是慌急忙慌地重启机器。
1. 先搞清楚OOM到底在干什么
1.1 内核什么时候会启动OOM Killer
要理解 OOM,得先明白 Linux 内核对内存的管理方式。平时我们写代码用的 malloc、Java 里的 new,都只是在用户态申请“虚拟内存地址”,真正落到物理内存,是在程序开始读写这些内存的时候。内核通过“缺页异常”机制,把需要的物理内存页分配给进程。这套机制平时悄悄运作,程序员基本感知不到。
但物理内存总量是有限的。当系统所有可用的物理内存页都被分配得差不多时,内核的分配路径就会出问题。它有两个回收内存的通道:一个是内核线程 kswapd,平时就在后台默默扫描内存,发现空闲页低于某个水位线就尝试回收;另一个是“直接回收”direct reclaim,也就是某个进程正在申请内存时,发现等不及后台线程,只能自己撸起袖子去回收。这两个通道都属于“和和气气地讨内存”。
问题在于,内存回收也有极限。可回收的页缓存、目录项缓存、不活跃的匿名页,能回收的都回收完了之后,如果还有进程在疯狂申请内存,内核就彻底没辙了。这时候它就进入最后阶段:启动 OOM Killer。OOM Killer 不是单独的系统进程,而是内核在分配内存路径上的一连串逻辑,它会挑选一个“在它看来最不重要、但占内存最多”的进程,直接发送 SIGKILL 信号。
有一种容易被忽略的情况:内存碎片化。系统总内存明明还剩不少,但都是零散的页,找不出一块足够大的连续物理内存,也可能触发 OOM。这种情况通常出现在长期运行的高负载服务器上,和进程无限增长导致的内存耗尽,属于两种不一样的问题,排查思路也不同。
1.2 为什么“手动释放内存”救不了场
“Linux 内存释放”这个话题,网上搜出来最多的命令是echo 3 > /proc/sys/vm/drop_caches,意思是手动回收页缓存、目录项和 inode 缓存。这个操作对降低 free 输出里的 buff/cache 数值确实有效,但它对 OOM 的救援作用,说实话非常有限。
原因不复杂:当系统真的走到 OOM 这一步,内核早已经在触发 OOM 之前,把能回收的缓存都回收过一遍了。页缓存本来就是“可牺牲品”,内核在内存紧张时优先清理它们;既然最后还是要杀进程,说明问题不在这些缓存上,而在于不可回收的内存占用在持续增长,比如进程的匿名内存、堆内存、内核栈、页表等。
很多新手看到free -h里 used 很高,就以为内存被“吃光”了,其实 used 里面有一大部分是 buff/cache。真正要看的是 available 这一列,它代表“在不触发换页和回收的情况下,新进程还能拿到的内存”。如果 available 趋近于零,那说明内存真的堵死了。所以drop_caches充其量是个心理安慰,你在 OOM 之后清缓存,不如直接把肇事进程揪出来。
2. 深入OOM的判断逻辑
2.1 overcommit_memory:内存申请其实是在“记账”
在聊 OOM 怎么选进程之前,得先聊一个前置概念:overcommit,也就是“超卖”。Linux 默认允许进程申请比实际物理内存大得多的虚拟内存,这不难理解——程序 malloc 了 1GB,但实际只用了 100MB,这 900MB 的差值就是“超卖”的部分。内核只是先记个账,等进程真正去用内存时再兑现物理页。
内核有一个开关,就是/proc/sys/vm/overcommit_memory,它有三种模式,实际生产中见到的也主要是这三种:
| 参数值 | 模式 | 行为说明 | 典型场景 |
|---|---|---|---|
| 0 | 启发式 | 内核按经验判断,明显超过限额的分配会拒绝,正常分配基本都放行 | 多数发行版默认 |
| 1 | 总是允许 | 所有内存申请都记账成功,直到物理内存耗尽触发 OOM | 某些数据库、HPC 场景 |
| 2 | 严格禁止 | 超过 CommitLimit 的申请直接失败,内核不会“硬扛” | 需要强预算控制的服务器 |
overcommit_memory=0的问题是:内核没法完全预测程序随后会不会真的用到那么多内存,放行得太痛快,结果就是内存压力一来,只能靠 OOM Killer 收场。overcommit_memory=2则走另一个极端,它计算一个 CommitLimit,公式是:
CommitLimit = SwapTotal + MemTotal × overcommit_ratio / 100其中 overcommit_ratio 默认是 50,也就是说,一台 100GB 内存、10GB swap 的机器,CommitLimit 大约是 60GB 左右。超过这个数,malloc 会直接返回失败。这种做法对大多数业务太过强硬,所以默认值才是 0。
排查内存问题时,cat /proc/meminfo里的Committed_AS和CommitLimit也很值得看。如果 Committed_AS 长期贴着 CommitLimit,说明整个系统的虚拟内存记账已经接近天花板,接下来任何大内存申请都可能触发连锁反应。
2.2 oom_score:内核凭什么决定杀谁
很多人以为 OOM Killer 是“谁占内存多就杀谁”,大致方向对,但细节要丰富得多。内核里有一个函数计算每个进程的“坏分值”badness score,简称 oom_score。这玩意儿的计算结果会写入/proc/PID/oom_score,随时可以查。
评分主要考虑几个维度:
- 进程当前实际的物理内存占用,包括 RSS、swap 使用量、页表占用等。占得越多,分数越高。
- 进程的 oom_score_adj 调整值,范围是 -1000 到 1000。默认是 0,负数表示降低被杀概率,正数表示提高。
- 部分版本的内核还会照顾 root 进程和长时间运行的进程,给它们的分数打个八折,目的是别轻易杀掉“老员工”。
你可以在机器上直接跑cat /proc/PID/oom_score看某个进程当前的分值。分数最高的进程,在 OOM 触发时会成为优先被淘汰的人选。要注意的是,进程的 score 是动态变化的,进程申请的内存越多,score 越高;释放一些内存之后,score 也会回落。
这里有一个很容易产生的误解:你心目中的“重要进程”,内核并不知道。它不管你是数据库主节点还是凌晨跑批的脚本,只看分数。所以生产环境里,那些重内存、又关键的进程如果不单独设置保护参数,很可能成为 OOM Killer 的刀下鬼。
2.3 oom_adj与oom_score_adj的关系
老一点的系统里常看到 oom_adj,取值范围是 -16 到 15,其中 -17 是特殊值,意思是“禁用 OOM Killer 对进程的裁决”,也就是永远不会被内核杀。这个机制后来被 oom_score_adj 取代,两者是映射关系:oom_adj 的 -16 到 15,大致映射到 oom_score_adj 的 -1000 到 1000。
现在调优,直接操作 oom_score_adj 就行。比如保护一个数据库进程,可以执行:
echo -800 > /proc/数据库进程PID/oom_score_adj把 PID 换成实际进程号。这个设置只对当前运行进程有效,进程重启后恢复默认,所以用 systemd 管理服务时,更规范的做法是在 service 文件里加:
[Service] OOMScoreAdjust=-800这样服务每次启动都会自动带上保护分值。要提醒一句:负分只是降低被杀概率,不是绝对豁免;如果整台机器内存被一个进程吃光,而这个进程恰好就是被你保护的那个,它的分数照样可能是全场最高,一样会被杀。唯一的绝对豁免是 -1000,但生产环境不建议给太多进程设置这个值,否则内存耗尽时内核找不到可以下手的对象,反而可能造成系统卡死。
3. 实战排查:从告警到定位内存真凶
3.1 第一现场:日志里有什么
OOM 事件发生的那一刻,内核会留下非常详细的日志。最常见的查看方式是:
dmesg -T | grep -i -B5 -A15 "out of memory"也可以看系统日志:
journalctl -k | grep -i oom日志里会出现一段这样的信息(字段大同小异):
Out of memory: Killed process 12345 (mysqld) total-vm:52428800kB, anon-rss:12345678kB, file-rss:1024kB, shmem-rss:0kB, UID:1000 pgtables:12345kB oom_score_adj:0这里有几个字段值得记住:
| 字段 | 含义 |
|---|---|
| total-vm | 进程总的虚拟内存大小,不直接代表物理占用 |
| anon-rss | 匿名内存 resident set size,也就是进程实际占用的物理内存主体 |
| file-rss | 文件映射所占的物理内存 |
| shmem-rss | 共享内存占用 |
| pgtables | 进程页表占用的内存 |
| oom_score_adj | 进程当时的被杀分调整值 |
日志最后还会有一句Killed process ... versus ... sacrifice child之类的记录,说明内核是考虑过多个候选后做的选择。这条日志是整个排查的出发点,通过它你能准确知道:在什么时间点、因为哪个进程的内存压力、最终杀了谁。
如果你的系统开启了 panic_on_oom,那就看不到这种日志了,因为内核直接 panic 重启了,这种配置在生产环境要格外谨慎。
3.2 还原现场:谁在用内存
日志只能说明结果,还得弄清楚内存是怎么一步步被吃光的。排查内存占用,我习惯按这样三步走:
第一步,看整体水位:
free -h重点关注 available 列。如果 available 接近 0,说明系统内存处于极度紧张状态。
第二步,按物理内存占用排序查找进程:
ps aux --sort=-rss | head -20这里的 RSS 是 resident set size,代表进程实际占用的物理内存。要注意,RSS 会把共享库重复计算,所以所有进程的 RSS 加起来可能超过物理内存总量,这是正常的,不代表有问题。
第三步,看内存详情:
cat /proc/meminfo | grep -E 'MemTotal|MemAvailable|CommitLimit|Committed_AS'这几个值的意义在上一节已经讲过。如果 CommitLimit 和 Committed_AS 很接近,就要小心了。另外还可以用 smem 或用脚本汇总每个进程的 PSS(Proportional Set Size)内存,这个指标会把共享内存按比例分摊,比 RSS 更接近真实占用。
smem -t -k -s rss | tail -30在没有 smem 的机器上,也可以遍历/proc/*/smaps手工汇总,但命令相对繁琐,适合写进脚本。
3.3 一个典型泄漏场景的复盘
分享一个我印象很深的排查案例。某公司的一台应用服务器,运行着一个 Java 写的后台服务,机器 64GB 内存,平时内存水位很稳。突然某天开始,每跑 48 小时左右就出现一次 OOM,被杀的还是另一台前置数据库实例的进程,导致一堆调用超时。
第一反应是看监控曲线。内存使用率从一个时间点开始,像起了台阶一样一路往上爬,中间几乎看不到回落。这说明有一个进程的内存只涨不跌,典型的泄漏特征。然后用ps aux --sort=-rss定位到罪魁祸首:Java 进程 RSS 从 8GB 一路增长到 40GB。
有人在想,Java 程序不是有 -Xmx 控制堆大小吗?为什么还能吃到 40GB?这里有个常见的盲区:JVM 的 RSS 不只有堆内存,还有 Metaspace、线程栈、JIT 编译产物、直接内存 NIO 等。当时检查发现,它堆外直接用了一个无上限的缓存数据结构,数据越积越多,最终把系统内存全部耗尽。
处理办法分成两步:短期先给这个 Java 服务加上 native 内存限制,并重启释放内存;长期修改代码,给缓存加容量上限和淘汰策略,同时给它设置 oom_score_adj=-700 的保护值。从此之后这台机器再没出现过 OOM。
这个案例里最关键的一课是:排查内存问题不能只看日志杀了谁,还要回溯“谁是大胃王”。“被杀的进程”往往是无辜的,真正造成系统内存耗尽的可能是另一个看起来还活着的进程。
4. 系统层面的预防与调优
4.1 让内核“晚一点出手”:swap与watermark
想让系统不那么容易触发 OOM,一个很直接的做法是给它准备足够的 swap 空间。swap 相当于内存的“备胎”,内存不够时把不活跃的内存页挪到磁盘上,腾出物理内存给活跃进程。没有 swap 的机器,内存一紧张就只能硬扛 OOM。
现在的服务器配置 swap 是有争议的,SSD 寿命、性能抖动都是考虑因素。我的做法是:物理内存 16GB 以下的主机,swap 配置成与内存等大;32GB 以上的主机给 8GB 到 16GB 的 swap;跑核心内存型数据库的主机,通常不配 swap,由数据库自身机制来管理内存。这没有标准答案,但总比完全不配要稳。
vm.swappiness参数控制内核“愿意使用 swap 的程度”,范围 0 到 100,默认 60。数值越大越积极。但别指望把它调低到 0 就能彻底避免 OOM,它调节的只是换页倾向,当物理内存耗尽时,该触发 OOM 照样触发。
还有两个和内存水位相关的参数可以关注。vm.min_free_kbytes控制内核保留的最小空闲内存,设得太小,紧急情况可能无法分配内存;设得太大,又浪费内存。vm.vfs_cache_pressure控制内核回收目录项和 inode 缓存的强度,默认 100,缓存压力大的场景可以适当调高,让它更积极地回收这类缓存。
4.2 保护关键进程:调整oom_score_adj
如果你已经能分辨哪些进程绝不能被杀,就可以主动告诉内核。前面提到 systemd 的 OOMScoreAdjust,这里再展开说几个实操细节。
对数据库、注册中心这类核心服务,建议设到 -800 到 -900,意思是“除非万不得已,请优先杀别的进程”。对离线任务、批处理脚本这些可重跑的任务,可以设置成正数,比如 500,让它们成为“优先牺牲对象”。
命令行的设置方法是:
echo 500 > /proc/批处理进程PID/oom_score_adj如果有多个相关进程,还可以把它们统一放进同一个 cgroup,并利用 cgroup 层级管理。容器平台里,通常是在编排配置里给容器直接设置 memory limit,一个容器内存超标,只会杀掉容器内的进程,物理机上的其他业务不受影响。
需要特别强调:保护操作只是改变内核杀进程的“优先序”,不代表业务没有内存风险。该有的内存监控、存量告警、自动拉起机制一样都不能少。我在实际运维中就见过把数据库设为 -1000 的团队,结果某次内存泄漏把所有普通进程杀完,数据库靠 -1000 豁免活了下来,但机器上其它服务全部崩溃,系统基本没法正常对外服务。
4.3 调整内核参数与使用cgroup做隔离
调参的思路分两层:内核层和应用层。内核层的常见配置集中在/etc/sysctl.conf:
vm.overcommit_memory = 0 vm.panic_on_oom = 0 vm.min_free_kbytes = 131072panic_on_oom=0是我比较推荐的做法:让内核尽力选择进程杀掉而不是直接宕机。除非你有完整的宕机切换机制,否则不要轻易改成 1。
应用层的主要工作,是给所有长驻服务都设置明确的内存上限。Java 服务要设 -Xmx,但要理解 -Xmx 不限制堆外内存;容器环境要给每个容器配置 memory limit;原生进程如果内存增长失控,要用 cgroup 或 systemd 的 MemoryMax 来约束。一句话,谁都不该拥有无限使用内存的自由。
5. 常见问题与排查技巧实录
5.1 现象与原因对照表
整理一张速查表,日常遇到这些现象可以照着排查方向走:
| 现象 | 常见原因 | 处理方向 |
|---|---|---|
| free 显示内存不够,但找不到大户 | 页面缓存或 slab 占用高 | 看 /proc/meminfo 中 SReclaimable,用 slabtop 排查 |
| Java 进程 OOM 被杀,但堆内存没满 | 堆外内存上涨 | 重点检查直接内存、Metaspace、线程数 |
| 容器被杀,物理机内存却充足 | 容器内存 limit 设置过小 | 调整容器 memory 限制或检查容器内累积内存 |
| 数据库进程总被 OOM 选中 | 没设置保护 | 调整 oom_score_adj,或转移到独立主机 |
| 进程进入 D 状态杀不掉 | 磁盘 IO/NFS 挂死 | 先解决 IO 问题,再考虑恢复业务 |
| drop_caches 后内存依旧被吃光 | 匿名内存或内核内存泄漏 | 定位具体进程,用 RSS/PSS 分析 |
5.2 被OOM杀掉的是“最不重要的进程吗?”
这个问题的答案几乎总是“不是”。内核挑选牺牲者,只能基于内存占用和系统资源利用率来判断,它不知道哪个进程对你更重要。如果一台机器上同时跑着数据库和离线计算任务,而两者都没设置 oom_score_adj,离线任务内存涨起来时,数据库完全不占优势,被杀的可能就是数据库。
所以别指望内核“懂事”,务必要用配置告诉它。我在每台服务器上线前的检查清单里,一定包含一项:确认所有关键服务的 oom_score_adj 都设置到位,并且把可被牺牲的任务明确设为正数。这个动作能让系统在紧急情况下做出接近“业务预期”的选择。
5.3 swap用了但OOM照样发生
有些机器明明配置了 swap,为什么还是 OOM?一种情况是 swap 太小,还没等到换页完成,内存又不够了;另一种情况是系统设置了vm.swappiness=0,内核几乎不换页,swap 形同虚设;还有一种情况是内存压力来得太猛太快,类似于短时间内一个进程瞬间申请了几 GB 内存,换页根本来不及。
排查时先看cat /proc/swaps确认 swap 是否真的挂载了,再看日志里 OOM 触发时 swap 是否还有空闲。如果 swap 有空闲还 OOM,那多半是瞬时内存压力太大,不是空间不足;如果 swap 完全占满,那就要考虑加 swap 空间或给进程设上限。
5.4 OOM杀不掉进程怎么办
还有一类特殊情况:系统已经进入 OOM 流程,日志也打了,但进程就是杀掉不掉。原因通常是进程处于不可中断睡眠状态,也就是 D 状态。这种进程可能卡在磁盘 IO、NFS 或者内核驱动上,SIGKILL 无法响应,还占据着内存。
遇到这种局面,只能先解决底层 IO 问题,让进程从 D 状态恢复出来,再观察能否正常杀掉。强行重启系统虽然粗暴,但往往是更快的恢复方式。这类问题的预防靠的是监控和告警:在 IO 还没拖垮系统之前发现问题,总好过事后救火。
5.5 如何避免把“数据库”误杀
单独说这个,是因为数据库在 OOM 事件里实在太冤了。它占用内存大,又无时无刻不在读写,分数天然就高。保护措施集中在两个方向:一是给它高优先级的保护分,比如:
echo -900 > /proc/数据库PID/oom_score_adj二是把它和吃内存的大户隔离。把数据库单独部署,或者关进有明确内存上限的 cgroup/容器里,让它不会被别的进程拖下水。配置容器 memory 限制时要注意,内存限制要有足够余量,否则数据库自身在高峰期申请内存被拒,同样会引发问题。
最后再分享一个我自己的习惯:所有关键服务的启动脚本里,都加一段内存余量检查。低于阈值直接拒绝启动,而不是等启动到一半被 OOM 杀掉。这个动作成本很低,但帮我挡掉了不少发布事故。OOM 的核心教训其实就一句话:给它立规矩、给它留后路、给它做监控。这三件事做好了,OOM 虽然还是会来,但至少不会再让你半夜面对一台半死不活的机器。