日志里出现Out of memory: Killed process的那一刻,你往往没有什么思考时间,内核已经替你做了决定。我自己做运维和系统设计这些年,见过太多人在这一行日志面前手足无措,然后一顿乱调参数,最后也不知道自己调的东西到底是救了系统还是埋了雷。
我更喜欢把 Linux 的 OOM 机制当成一整套“制度设计”来看待。所谓“斩杀线”,不是某一个神秘参数,而是一条完整的制度链条:从哪里触发、按什么规则找牺牲者、凭什么给部分进程豁免权、又靠什么在容器和 cgroup 层面把治理范围切割清楚。这篇文章不会只讲oom_score_adj怎么调,而是把这套制度从上到下拆开,给你一份可以落到生产环境的“制度设计手册”。
1. 先看大局:OOM机制为什么算得上“制度设计”
1.1 虚拟内存的“信用体系”是制度的土壤
要理解 OOM 为什么存在,首先得接受一个反直觉的事实:你写的程序申请内存,得到的并不一定是真正的物理内存,而是一张“欠条”。
在 Linux 里,malloc()成功返回之后,内核只是给你分配了一段虚拟地址空间,并没有真的把这些页放到物理内存上。等你真正写入数据,触发缺页异常,内核才会把物理页映射过来。这个过程叫“按需分配”,英文是 demand paging。
换句话说,虚拟内存就是一套“信用体系”:进程以为自己在使用内存,但内核只是在账面上记账。正常情况下这个体系运转良好,因为大量进程申请了虚拟内存之后并不会全部写满,实际用到的往往只有一小部分。
但账面上欠得多了,总有兑付不了的一天。当所有进程都开始去写自己申请过的内存,物理内存被真正吃满,系统又回收不出足够页面的时候,信用体系就面临挤兑。挤兑之下怎么办?内核必须有一套强制处置的制度,这就是 OOM Killer 存在的意义。
1.2 三道防线:回收、换页、处决
很多人的理解里,OOM 是“内存不够了就直接杀进程”,这是个错误的印象。真实的内核应对顺序,更像一家企业现金流紧张时的处理方式:
第一道防线是后台回收。内核通过 kswapd 这类内核线程,在内存页下降到一定水位线时启动异步回收,把不常用的页缓存和可回收页释放出来。这个阶段进程都还活着,只是速度可能变慢。
第二道防线是换页,也就是 swap 的介入。当物理内存不足且页面无法快速回收时,内核会把一部分匿名页写到 swap 空间,腾出物理内存给更需要的进程。这个过程相当于“借新债还旧债”,短期内能续命,但债总是要还的。
第三道防线才是直接斩杀。如果前两道防线都没有解决问题,内核的分配路径会持续失败,最终进入out_of_memory()这个函数,按照一套打分机制选择某个进程处决掉。
很多新手有个误区:以为 OOM 是“突然发生”的。其实不是,它是前面回收和换页都失效之后的终局。理解这一点,你就知道 OOM 治理的核心思路不是把第三条线画得又多又密,而是让前两道防线足够有效。
1.3 制度的底线:牺牲进程,保护系统
默认情况下,Linux 在内存耗尽时选择杀死进程,而不是直接让整个系统崩溃。这个选择本身就是制度设计的核心。
vm.panic_on_oom这个参数就体现了不同侧重。默认值是 0,意思是“内核来做裁决,该杀就杀”;如果设成 1,那么触发 OOM 时内核会直接 panic;设成 2 的话,在很多场景下也会重启系统。
我见过一些团队把panic_on_oom设成 1,理由是“系统状态不可恢复,不如重启”。这个想法有一定道理,但你得为此承受业务中断的代价。对于绝大多数在线业务,宁可牺牲一个批量任务或缓存进程,也不应该让整台机器宕机。
说白了,OOM 制度设计的底线,就是“用局部牺牲换取整体存活”。这个思路跟容灾设计里的“丢车保帅”是一样的。
2. 第一根线画在哪里:watermark 与内存超卖
2.1 三条水位线决定内核何时介入
很多人问,内核到底从哪一刻开始觉得“内存不够了”?答案不是等内存用到 0,而是看几个水位线:WMARK_HIGH、WMARK_LOW、WMARK_MIN。
简单理解,这三个水位线把空闲页面分成了几档。当空闲页面低于WMARK_LOW时,kswapd 开始把后台回收线程唤醒,尝试释放内存;如果继续跌到WMARK_MIN以下,内存分配就会走紧急路径,内核会进入直接回收甚至 OOM 流程。
这就好比一个大楼储备了不同级别的应急物资,水位越高说明物资越充足;一旦水位降到最低线,保安系统就要进入警戒模式了。
水位线的具体数值受多个因素影响,其中最关键的是vm.min_free_kbytes。它直接参与计算最低水位线,调大它,相当于把“斩杀线”整体抬高:系统会更早启动回收,更积极地保持空闲内存。但凡事都有代价,min_free_kbytes设得太大,会有更多内存被预留而不能用于业务,造成浪费。
我的经验是:不要盲目抄网上的“推荐值”。4G 内存的小机器和 128G 内存的数据库服务器,对应急余量的需求完全不同。生产环境应该结合机器内存总量、业务内存峰值、是否启用 THP(透明大页)等因素来调。不同版本内核的具体计算策略也会有差异,重点理解这个水位的含义,而不是死记参数。
2.2 overcommit 机制:制度对“欠条”的容忍度
前面讲过,虚拟内存是信用体系。但系统对“信用扩张”的容忍度,是可以配置的,这就是vm.overcommit_memory。
0是启发式模式,也是大多数发行版的默认值。内核会根据当前可用内存和申请大小做一个粗略判断,明显离谱的分配会被拒绝,但绝大多数申请都会放行。1是“总是允许”模式。只要虚拟地址空间还能容纳,内核基本不做限制。这种模式适合某些特殊计算场景,但也是最容易触发 OOM 的模式。2是“严格模式”。内核会先算出一个 CommitLimit,所有进程累计的虚拟内存总量超过这个限额时,分配就会直接返回失败。CommitLimit 的计算大约是:物理内存 × vm.overcommit_ratio / 100 + swap 总量。
我经常把 overcommit 比作银行放贷。0模式是“看人下菜碟”的信贷经理,1模式是“只要身份证就放贷”,2模式是“咱们行里设了硬性授信额度,谁的贷款都不能超”。
如果业务环境对内存一致性要求很高,比如数据库集群的某些场景,我更倾向于用2模式。它至少让内核在“信用账本”上有明确的红线,而不是等到物理内存耗尽才发现问题。但在用2模式之前,一定要先观察现有业务的Committed_AS与CommitLimit的实际关系,否则很容易出现“内存还有富余,但分配失败”的诡异问题。
2.3 swap:给斩杀线留一条缓刑通道
swap 这个东西,很多人又爱又恨。恨它的理由是 swap 读写太慢,爱它的理由是它能在内存紧张时给系统留出反应时间。
从制度设计的角度看,swap 相当于“缓刑通道”。有了它,进程在物理内存不足时不一定立刻被处决,而是先把一部分数据挪到磁盘,争取到几秒甚至更长的调度窗口。
vm.swappiness控制内核使用 swap 的积极程度,范围是 0 到 100。之前流行一个说法是“设置成 0 就永远不用 swap”,这在老内核上基本成立,但新内核的行为有所变化:即使 swappiness 为 0,在内存压力极大时仍然可能发生换页。
我的建议是:对 SSD 机器,swapiness 设置在 10 到 30 之间比较合理;对传统机械盘,建议设置更低,否则频繁换页会让 IO 成为新的瓶颈。更重要的是,swap 设置要有足够的空间去承担“缓冲”的角色,不能只留几百 MB 意思一下。
3. 核心审判规则:OOM Killer 凭什么选中某个进程
3.1 打分机制:不是在杀“谁占内存最多”
OOM Killer 的裁决逻辑并不是简单地找占用物理内存最多的进程。内核里有一整套打分机制,最终会算出一个oom_score,进程得分越高,越容易被杀。
这个打分参考的因素包括进程常驻内存大小、swap 中的页面数、页表本身占用的页面等。此外,进程运行时间也会被纳入考虑,刚运行几秒的进程往往会比已经跑了几个月的进程承担更高分数。这个看起来冷酷无情的规则,其实有它的道理:新进程刚启动,杀了它的代价最小;老进程运行了很久,可能保存了大量中间状态,杀掉它损失更大。
更反直觉的是,OOM 打分看的不仅是当前真实使用量。进程申请了大量虚拟内存,即使还没有完全写满物理页,也会显著影响得分。因为虚拟内存代表的是“潜在的风险敞口”,内核在作出生死裁决时,会把这部分风险也考虑进去。
所以你会看到一种现象:一个看起来“只占了几百兆”的 Java 程序,因为虚拟内存高达几十 G,反而比另一个真实占用 2G 的进程更容易被选中。
3.2 oom_score_adj:制度里的“自由裁量权”
默认打分只反映了内核视角的“公平”,但实际业务中我们往往需要人为干预。这时oom_score_adj就派上用场了。
它的取值范围是 -1000 到 1000,默认是 0。负数代表降低被杀的优先级,正数代表提高。设成 -1000 时,进程几乎获得永久豁免权;设成 1000 时,OOM Killer 会第一个盯上它。
我在生产环境中做制度设计时,会对核心服务单独设置:
- 数据库、注册中心、配置中心这类基础设施,设置
oom_score_adj=-800甚至-900; - 常规 Web 服务保持默认 0,让内核按真实内存占用来裁决;
- 临时批量任务或数据处理进程,设置正数,让它们成为“优先牺牲对象”。
这里有一个重要的原则:豁免权不能滥用。如果整个系统里所有进程都把oom_score_adj设成 -1000,那么 OOM Killer 就无差可杀,最后只能走 panic 路线,反而让整台机器直接宕机。制度的善意必须留给真正关键的少数派。
如果你用 systemd 管理服务,可以直接在 service 文件里配置:
[Service] OOMScoreAdjust=-800这个配置比自己在启动脚本里写echo更加规范,systemd 会在拉起服务时自动设置好。
3.3 执行阶段:线程组、候选者与最终处决
OOM Killer 选“谁”来杀,并不是只看一个进程的单独得分。现代内核里,多个线程往往处于同一个线程组,内核会选择性地评估整个组的影响,有时候为了释放足够内存,会同时杀掉同一进程组里的多个进程。
实际执行上,触发 OOM 的进程往往不是得分最高的那一个,而是“更容易被杀”的那一个。内核会从所有候选进程里挑一个它认为最合适的目标,然后向它发送 SIGKILL。
这里有个容易踩的坑:如果某个容器的根进程承担了oom_score_adj=-1000,OOM 时内核找不到合适的候选者,可能直接跳过它去杀别的进程,甚至导致全局 OOM 处理逻辑进入异常路径。所以千万不要把生产环境里所有进程都保护起来,那不是制度设计,那是制度失效。
4. 分层制度:cgroup 给不同业务划边界
4.1 全局 OOM 太粗暴:需要更小的治理单元
全局 OOM 机制最让人头疼的问题,是“殃及池鱼”。一个业务把内存吃满,可能让另一套无辜的业务被系统杀掉。要想真正做到“谁的问题谁负责”,你需要一套比全局级别更细的治理制度,这就是 cgroup 的用武之地。
cgroup 是 Linux 内核提供的一组资源控制机制,它可以把一组进程圈在限定范围内,对它们的内存占用单独设限。cgroup v2 里的memory.max就相当于一个硬性的“制度围墙”:这个 cgroup 内的进程总内存超过这个值,内核就会在 cgroup 内部先执行回收;回收不过来,就会杀掉该 cgroup 内某个进程。
这个设计和全局 OOM 最大的区别是:cgroup 的 OOM 只影响圈内进程,不会波及外面的服务。相当于把全局无差别的“禁杀令”替换成了“地方法规”,谁踩红线谁担责。
4.2 hard limit 与 soft limit 的区别:memory.max vs memory.high
cgroup v2 里有三个关键参数需要搞清楚:
| 参数 | 含义 | 行为 |
|---|---|---|
memory.max | 硬性上限 | 超限后回收,收不回来就杀进程 |
memory.high | 软性上限 | 超限后对进程进行节流,但不立即杀 |
memory.min | 最低保证 | 相当于给圈内进程预留兜底额度 |
memory.high是一个很实用的制度设计工具。它不会立刻杀死进程,而是让这些进程的内存申请速度变慢,给系统留出足够的处理时间。
比如一个数据分析任务,你可以给它设置memory.high略高于memory.max。正常运行时不触发节流;一旦出现突发内存增长,先是性能下降,如果继续涨到硬上限,则由内核执行处决。这就像企业先给业务发预警函,而不是直接走司法程序。
4.3 事件监控:让制度留有“档案”
cgroup v2 下,可以通过memory.events这个文件查看内存治理的统计信息:
/sys/fs/cgroup/mybench/memory.events里面会记录low、high、max、oom等关键事件的发生次数。其中max表示触发了硬性上限,oom表示发生了 cgroup 级的进程斩杀。
我在生产环境做监控时,会定时采集这些事件,把它们接入监控系统。一旦看到oom事件增长,即使当前系统还能跑,也应该立即排查是哪个业务在持续冲高。制度设计的价值不在于“出事了再杀”,而在于让你能提前知道“可能要出事”。
5. 制度落地:一份可直接抄的生产环境参数参考
5.1 参数速查表
以下是我在常见生产场景下比较常用的起始配置,注意是“起始配置”,具体数值需要结合你的业务调整:
| 配置项 | 建议起始值 | 设计意图 |
|---|---|---|
vm.overcommit_memory | 0 或 2 | 通用业务用 0,核心账务类用 2 |
vm.overcommit_ratio | 80-95 | 在严格模式下预留信用额度 |
vm.min_free_kbytes | 按机器内存预留 | 调高可提前回收,但会浪费 |
vm.panic_on_oom | 0 | 默认交给内核处理 |
vm.swappiness | 10-30 | 保留 swap 逃生通道 |
vm.admin_reserve_kbytes | 合理余量 | 防止 root 操作被内存不足卡死 |
vm.user_reserve_kbytes | 合理余量 | 保障普通用户仍可操作 |
这些参数都可以通过/etc/sysctl.conf持久化配置。改完参数,用sysctl -p使其生效。
5.2 给关键服务设豁免权
假设你有三台机器部署了一个数据库服务,还有一些边缘计算任务。边缘任务的内存峰值很高,很容易吃满系统,如果每次都杀你心爱的数据库,整个业务就废了。
用 systemd 管理时,在数据库服务的 service 文件里加:
[Service] OOMScoreAdjust=-800加上这个之后,数据库进程的oom_score会被大幅降低,OOM 时会优先考虑其他候选进程。
我做过的经验总结是:核心数据库可以给到 -900 左右,但不要轻易给到 -1000。虽然 -1000 等于直接免死金牌,但如果这个进程内部有严重的内存泄漏且没有其他进程可杀,反而会让系统进入更危险的状态。留一点点余地,不是坏事。
5.3 最小复现实验:亲手触发一次 cgroup OOM
纸上谈兵不如动手做一次。你可以用 cgroup v2 快速复现一次局部 OOM,观察整个过程。
先创建一个 cgroup,设置内存上限:
mkdir /sys/fs/cgroup/mydemo echo 160M > /sys/fs/cgroup/mydemo/memory.max然后写一个小脚本,不断申请内存:
#!/bin/bash pages="" while true; do pages+=$(head -c 1048576 /dev/zero | tr '\0' 'a') done把这个脚本的进程放入 cgroup:
echo $PID > /sys/fs/cgroup/mydemo/cgroup.procs运行一段时间后,在dmesg里会看到类似这样的日志:
Memory cgroup out of memory: Killed process 12345 (bash) total-vm:...同时查看:
cat /sys/fs/cgroup/mydemo/memory.events能看到oom计数加一。这个过程能帮你建立非常直观的印象:cgroup 级别的制度边界是如何起作用的。
6. 常见问题与排查实录
6.1 一行日志里的信息量
OOM 日志里经常出现这种格式:
Out of memory: Killed process 12345 (myservice) total-vm:5123001kB, anon-rss:812340kB, file-rss:45678kB, shmem-rss:1234kB, UID:1000 pgtables:4567kB oom_score_adj:0很多人盯着total-vm看,以为这就是进程的真实占用。但total-vm其实是虚拟内存总量,真正决定“内存压力”的是anon-rss(匿名页常驻内存)和部分file-rss(文件页缓存)。如果一个进程 total-vm 巨大但 anon-rss 很小,说明它只是分配了虚拟空间但没有实际写满,不一定是首要的斩杀目标。
排查 OOM 时,建议结合free -g、ps aux --sort=-rss一起看,先找真实匿名内存占用最高的进程,再做下一步判断。
6.2 明明内存还有余量,为什么还是被杀?
这是我被问得最多的问题。现象通常是:free -h显示还有几个 G 空闲,但日志里就是触发了 OOM。
可能的原因有以下几种:
- 内存碎片化严重:即使总空闲内存不少,但系统在某个内存水位线下找不到足够连续的空闲页来满足分配请求,被迫触发 OOM;
- 不可回收内存太多:page cache 里有大量脏页,swap 空间又满了,匿名页无法换出,导致系统“手上没牌”;
- 内存水位线设定过高:
min_free_kbytes被调大后,系统认为“可安全使用的内存”很少,虽然数值上空闲还挺多。
遇到这种情况,只看free是远远不够的。至少看一眼/proc/buddyinfo和/proc/pagetypeinfo来判断是否存在碎片问题。碎片导致的 OOM,靠杀进程往往治标不治本,该考虑调整内存分配策略或者迁移到更合适的内存模型。
6.3 容器内进程被杀,但容器还在运行
云原生环境下,cgroup OOM 和全局 OOM 的差异会带来一个非常常见的误判:容器里的某个进程被杀了,但容器本身没有退出。
这是因为容器一般用自己的 cgroup,而容器内 PID 1 可能是 init 进程。如果被杀的是业务程序,只要 PID 1 没死,容器就不会整体退出。于是监控系统看到的就是“容器存活,服务挂了”。
排查时需要区分两个层面:这个 OOM 是发生在 cgroup 内部(看memory.events),还是发生在整个宿主机层面(看系统日志)。如果只看容器状态,你根本找不到真正的根因。
6.4 与 OOM 长期共存的治理心态
写到最后,我想分享一个在实际运维中形成的观点:不要试图把 OOM 从系统里完全消灭掉,而要通过制度设计,把 OOM 的杀伤范围控制在一个可接受的水平。
我的具体做法是三个字:先分类、再设线、后监控。先分类是说把业务按重要程度分为核心服务、普通服务、临时任务;再设线是给每一类服务设置不同的oom_score_adj和 cgroup 内存限制;后监控是持续采集 OOM 事件和内存压力指标。
这套思路看起来简单,但执行起来非常考验对业务的了解。你越清楚你的服务在内存耗尽时谁该活、谁该死,你的制度设计就越成熟。
经历过几次因为配置混乱导致的正反馈以后,我再也不会把所有进程放在同一条起跑线上等内核裁决。内核就像一个维护秩序的执法者,它的默认规则是公平;但我们作为系统设计者,有责任提前告诉它“哪些是城市命脉,哪些是可以牺牲的耗材”。
这就是我理解的 OOM“制度设计”:不是让内核乱杀,而是让它在极端情况下,依然能保护你真正想保护的东西。