
1. 从 malloc 成功到进程被处决Overcommit 的动机与真实代价早年我第一次在生产环境看到 Java 进程被整机 OOM Killer 直接杀掉时第一反应是去翻应用日志怀疑是 JVM 堆溢出的 OutOfMemoryError。后来才明白应用层的 OOM 异常只是 JVM 自己在堆空间里兜不住了而 Linux 内核的 OOM Killer 是真正把进程处决的那把枪。两者之间隔着一整套关于 Overcommit内存过度分配的设计哲学很多人平时用着 Linux 却完全没意识到它的存在。这一机制的本质是Linux 默认允许进程向内核申请的虚拟内存总量远大于物理内存加交换空间的总和。你 malloc 一段 1GB 内存绝大多数情况下内核会直接点头哪怕整机只剩 512MB。真正要命的是malloc 返回成功并不代表这段内存在物理上已经属于你它只是一个空头支票。直到你开始逐页写入数据内核才会真的去物理内存里找地方兑现这时候如果找不到了OOM Killer 就会登场。这个设计乍一听非常莽撞好像银行无抵押放贷一样。但正是因为这种先放贷、后催债的策略Linux 才能支撑起 fork 之后的写时复制、共享库映射、以及大量申请了但不一定用的业务内存。要说清楚它为什么利大于弊又为什么会在某些场景下坑死人得先把虚拟内存和物理内存的关系掰开揉碎。1.1 你看到的内存占用可能全是假象进程从操作系统眼里看到的地址空间和物理内存条上的实际字节是完全不同的两个东西。每个进程拿到的是一个独立的虚拟地址空间内核通过页表把这个地址空间映射到物理页面。进程里任何一次malloc、mmap、brk本质都是往自己的地址空间图上画一块地而不是真的去内存条上占位置。比如一个进程里写了char *buf malloc(4096);内核只做了一件事在页表里留一个虚拟地址段并记录这块区域的 VMA 信息。真正映射物理页要等到你第一次写buf[0] 1触发缺页异常时才发生。也就是说进程 RSS常驻内存远小于 VSS虚拟内存是完全正常的现象RSS 才代表它实际压到物理内存上的重量。这也解释了为什么很多新手查top的时候会被虚拟内存那一列吓到动辄几十 GB 的 VIRT其实大部分都是画饼。真正的物理压力要看RES一列。理解了这一点才能继续理解 Overcommit 到底在超什么它超的是虚拟地址空间的承诺而不是物理页面的实际占用。1.2 为什么内核敢做这门无抵押放贷生意内核默认开启 Overcommit 不是拍脑袋决定的。它赌的是大多数进程在大多数时候不会把所有已映射的页面全部写一遍。以 Java 应用为例JVM 启动时可能预留了很大的堆空间但这些堆页是逐步热起来的冷数据一直占着虚拟地址但不产生物理压力。数据库、浏览器、编译器等常见负载都有类似特征地址空间需求大瞬时物理压力小。更关键的是Linux 还有回收机制兜底。当物理内存紧张时内核会先收缩各种缓存页缓存page cache可以直接丢、脏页可以写回、LRU 上的匿名页可以换到 swap。也就是说即使物理内存不够了往往还能先从缓存里挤出内存来用根本走不到 OOM 那一步。Overcommit 敢开得这么宽就是因为回收路径提供了缓冲垫。但这份乐观有一个前提物理内存是真的能被回收出来。如果大量内存被不可回收的匿名页占据swap 又几乎为零内核就会陷入无牌可打的境地。这时候它唯一能做的就是启动 OOM Killer从进程里挑一个杀掉把它的内存释放出来。1.3 超额分配的临界点到底什么时候会爆很多人在自己的开发机上跑了很久也没见过 OOM Killer就以为它只是个传说。其实它在服务器上很常见尤其在容器混部、Java 堆配置过大、或者开了太多无界缓存的场景里。触发点不是 malloc 那一刻而是某次缺页异常后内核进入__alloc_pages_nodemask分配物理页发现所有内存节点都凑不出一个 page 时。换句话说系统面临的是物理内存真正耗尽了且连回收手段都已无用武之地的终极困境。这时候内核的抉择不是优雅地让 malloc 失败——那已经来不及了而是直接找目标进程下手。它杀掉进程不是惩罚而是给整台机器续命。理解这个时序就明白为什么生产环境里预留内存限制容器内存上限这类做法永远比事后调 Overcommit 参数更重要。2. 三个开关值代表的三种世界vm.overcommit_memory 详解Overcommit 的宏观政策由/proc/sys/vm/overcommit_memory控制取值只有 0、1、2分别对应三种完全不同的信贷政策。很多人只记得0 是默认、2 是严格却不知道切换它们会带来什么连锁反应更不知道mode 0的启发式判断到底依据什么。取值政策名称核心行为适用场景0启发式默认单次分配内存时检查当前空闲与可回收内存是否足够明显不合理的巨量申请会被拒累计承诺可以超额绝大多数通用服务器、桌面1总是允许永远不会因为虚拟内存不足拒绝 malloc/mmap极端情况下直接物理内存耗尽触发 OOM科学计算、需要预分配大内存的特定应用2禁止超额以 CommitLimit 为硬上限超过后直接拒绝拒绝的是虚拟内存承诺不是物理分配数据库、金融系统等对稳定性要求极高的场景光看这个表可能觉得那直接设成 2 不就最安全吗实际没这么简单。mode 2虽然把承诺卡死了但代价是很多本来无害的内存申请会被提前拒绝包括那些压根不会写入的预留空间。生产环境里切换这个参数前必须搞清楚 CommitLimit 是怎么算出来的否则可能把自己逼进内存没有耗尽但进程却启动不了的岔路。2.1 默认模式下的启发式到底启发在哪儿mode 0是长期以来的默认值它既不完全拒绝超额也不允许无限超额。它在每次内存申请进来时会粗略估算当前物理内存free加上可以回收的 slab、页缓存、可换出匿名页够不够本次申请的量。如果够就批准如果明显不够就返回 ENOMEM让 malloc 返回 NULL。这套判断是有意不精确的。因为它要的是拦住那种明显荒谬的申请比如一个程序试图申请 64TB 虚拟内存而不是精确跟踪每一笔账。它不维护每个进程累计承诺了多少虚拟内存也不管它们未来会不会全部写脏。因此多个进程各自的合理申请叠加起来总量很可能超过物理内存和 swap 之和——这就是过度分配这个名字的真正来由。这里有个常见的误解认为mode 0下 malloc 永远不会失败。实际上如果你一次性申请一个远超系统总内存的连续缓冲区内核依然会拒绝。真正容易被坑的是那种小步快跑式的叠加申请每次几百 MB系统都能通过启发式判断但积少成多后物理内存耗尽OOM Killer 启动。所以默认模式下安全不代表放纵你依然要关注总体的内存水位。2.2 模式 2 的 CommitLimit 计算与风险评估把/proc/sys/vm/overcommit_memory改成 2 之后内核就开始一丝不苟地记流水账。它统计所有进程承诺的虚拟内存总量保存在/proc/meminfo的Committed_AS字段同时根据物理内存和 swap 情况计算出硬上限即CommitLimit。计算公式很简单CommitLimit swap_total ram_total * overcommit_ratio / 100其中overcommit_ratio默认是 50可以通过/proc/sys/vm/overcommit_ratio调整。也就是说默认情况下内核允许的承诺总量是物理内存的一半加上所有 swap。如果你想更激进一点可以把 ratio 调高到 80 甚至 90但一定要留出内核自身预留的页和管理开销。实际操作里我在几台数据库节点上用过mode 2 ratio 80。表面上很稳妥但很快发现一个副作用fork()系统调用也会计入 Committed_AS因为子进程要复制父进程的页表结构。像 Postgres 这种频繁 fork 子进程处理连接的服务在内存水位高的时候会出现fork: Resource temporarily unavailable的报错而实际上物理内存根本没满。排查这类问题比调 OOM 还麻烦因为它把物理内存不足和承诺额度用完混在了一起。所以如果你要开mode 2一定先评估应用 fork 的频率和虚拟内存基数不然省下了 OOM 的头痛换来了 fork 失败的心烦。2.3 什么时候该改、什么时候别乱动根据我的观察mode 0对绝大多数业务是够用的你不用因为害怕 OOM 就急着改成 2。真正值得考虑改动的场景是这些机器上跑着大数据或科学计算任务它们会一次性申请巨大缓冲区甚至预留全部内存希望内核来者不拒这种场景更适合mode 1。数据库或金融交易类服务最怕的是整机不稳定地随机杀进程宁可提前拒绝内存申请也不容忍运行中被处决这种场景适合mode 2。容器化集群节点通常维持mode 0但通过 cgroup 对每个容器单独设上限把 OOM 的爆炸半径限制在单个容器内。无论选哪种模式我都建议你在调整后立刻观察/proc/meminfo的Committed_AS和CommitLimit而不是直接上线。这两个字段能帮你判断当前这台机器的承诺余量还有多少算是内核给你的风险仪表盘。3. OOM Killer 的选人逻辑badness 评分体系全拆解OOM Killer 最让运维抓狂的一点是它杀谁、不杀谁从表面上看有时毫无道理。你可能亲眼见过这样一个场景一个 MySQL 实例占了 60GB RSSOOM 后死的却是一个只有 2GB RSS 的后台任务。这不是随机也不是保护 MySQL 有什么天大的面子而是内核有一套自己的罪大恶极评分逻辑它测的从来不是 RSS 这一个维度。要读懂 OOM Killer 的行为得从它的触发路径讲起。当内存分配走到山穷水尽、常规回收也救不回来时__alloc_pages_may_oom会被调用内核会遍历所有可被杀死的进程算一下谁的oom_badness()不良度最高谁就优先被处决。3.1 触发与豁免谁根本不在候选人名单里内核也不是病急乱投医有些进程从一开始就不会进入 OOM Killer 的候选名单。首先是内核线程它们没有用户态地址空间杀不掉也不需要杀其次是被设置了OOM_DISABLE或者oom_score_adj为 -1000 的进程再次是所有正在退出中的进程它们即将自我了断内核没必要再补一刀。另外一个比较隐蔽的豁免条件是处于不可中断睡眠D 状态且正在执行为内核关键路径服务的进程OOM Killer 会尽量避免下手因为杀掉它们可能导致文件系统损坏或设备驱动状态错乱。这也解释了为什么有些进程被 OOM 追杀时总在名单边缘反复横跳而有些 D 状态的进程却一直活着。当你排查一个为什么被杀的总是我们服务的困惑时第一件事就是查看/proc/pid/oom_score。这个文件里是内核当前给它的评分数值越高死亡优先级越高。我曾在一次事故里发现一个只占 1GB RSS 的 Python 脚本oom_score高达 900而旁边一个 8GB 的 Java 进程却只有 300原因全在评分公式里。3.2 oom_badness 公式内存权重、root 折扣与人工干预Linux 5.x 内核中oom_badness()的核心逻辑大致是这样的先统计进程的匿名内存、swap 使用量和页表内存这些都标记为不可回收负担再除以系统的总内存得到一个基准分然后乘上 1000。核心思想是它优先看那些无法通过回收或换出而减轻的负担而不是看你分配的虚拟内存有多大。这套公式有个非常实用的推论如果你想知道某个进程被选中的概率不用去读内核源码直接看三个数就够进程的匿名 RSS、swap 占用、以及页表大小。它们越大分越高。文件页缓存不算因为文件页可以直接丢弃再读。这也是为什么那些大量读取磁盘文件、建立文件缓存的进程虽然 RSS 看着高但在 OOM 评分里反而没那么突出。然后是人情世故root 用户进程以及拥有 CAP_SYS_ADMIN 权限的进程得分会除以 4。这是内核有意为之的因为系统管理进程通常负责更核心的任务不该轻易陪葬。最后内核会叠加oom_score_adj的修正值该值范围是 -1000 到 1000正值是加分、负值是减分-1000 直接豁免。比如 systemd 里给某个服务加上OOMScoreAdjust-500它被选中杀掉的概率就会显著下降。从这里能看出来预测 OOM Killer 杀谁不是玄学。你只需要列出所有可疑进程的/proc/pid/oom_score排序结合它们的内存特征基本能判断下一次 OOM 时谁最危险。3.3 容器与 cgroup 场景OOM 的射程范围如何界定现代服务器上跑容器已经是常态OOM 的场景也因此被拆成了两层cgroup 内部的 OOM 和整机的 OOM。前者发生在 cgroup v2 的memory.max被打满时内核只在这个 cgroup 的进程列表里挑受害者而不会去动宿主机上其他 cgroup 的进程。这意味着你在容器里看到的Killed大概率来自 cgroup OOM而不是全局 OOM。排查时要先看内核日志里那行信息如果看到memory: cgroup字样说明问题范围被限制住了。同时cgroup v2 里有个参数memory.oom.group默认是 0设为 1 可以让内核杀掉该 cgroup 内所有进程而不是只杀一个。这个开关在混部场景里非常实用因为单体应用往往不希望被杀一个边缘线程后整个服务变成半死不活的状态干脆全杀然后由 orchestrator 重启。全局 OOM 则不同它的候选池是整个系统且优先杀评分高的。这时候即使你的服务在容器里跑如果容器本身没设memory.max它依然可能出现在全局 OOM 名单里。所以容器场景下最稳妥的做法是两层都设防容器配好内存上限关键服务调低oom_score_adj加上宿主机的 SWAP 缓冲才能把失控概率压到最低。4. 从 dmesg 到 /proc/meminfo一次真实 OOM 事件的全链路排查理论讲了一堆实际遇到 OOM 事故时的排查路径往往比想象中更有章法。我见过太多人一上来就翻业务日志结果扑空因为 OOM 是内核干的不是应用层自己抛出的。正确的第一动作是看内核日志线索基本都藏在 dmesg 里。一次典型的 OOM 事件dmesg 里会出现类似这样的信息[43432.171208] Out of memory: Killed process 31872 (java) total-vm:4185604kB, anon-rss:1820456kB, file-rss:0kB, shmem-rss:0kB [43432.172084] oom_reaper: reaped process 31872 (java), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB第一行告诉我们受害者是 PID 31872、进程名 java、虚拟内存 4GB、匿名 RSS 1.8GB第二行说明 oom_reaper 这个内核线程已经把它的匿名内存全部回收干净了。看到这两行基本可以确定这是一次全局或 cgroup 触发的 OOM而不是普通的 JVM 堆溢出。4.1 排查链路第一步日志里的案发时间与凶器拿到 dmesg 日志后先确定时间点再和业务告警对表。如果日志时间与业务侧连接数骤降、请求超时的时间吻合那基本坐实了 OOM 是响应用户故障的直接原因。接着要看日志里对这台机器的描述特别是进程被 Killed 前后的内存统计是否附带了各进程的内存快照。新版本内核的 OOM 日志会附有一个进程列表按 badness 排序打印出前几名候选人的内存占用。这个列表极其有价值能直接告诉我们当时到底谁最该被杀、以及为什么杀到了当前这个。很多线上的谜案看这个列表就能破比如真正吃内存的是另一个没设保护的老大但它oom_score_adj -500所以内核选择杀了老二。还要留心日志里是否出现Out of memory: Killed process之前还有page allocation failure的记录。如果出现这种记录说明是某次特定类型的高阶页分配失败比如内存碎片化严重导致无法分配连续页块这可能指向另一种问题内存碎片而不是真正的总量耗尽。两者的处理思路完全不同一个要调 OOM 策略另一个可能要开启内存规整或调整 THP透明大页。4.2 数据串读meminfo、Swap 与 Committed_AS 的正确打开方式日志只能告诉你谁死了要搞清楚为什么会死还得回到/proc/meminfo里读关键字段。MemFree和MemAvailable的差别很大后者是内核估算的实时可用内存把可回收的缓存也算进去了。所以判断内存是否真的紧张优先看MemAvailable而不是已经被缓存吃掉大半的MemFree。同时留意SwapTotal和SwapFree。如果 swap 明明还有剩余却发生了 OOM那就要怀疑是不是 root 临时禁用了 swap 回写或者当前内存压力是异步的回收速度跟不上分配速度。我在排查时遇到过一种情况swap 还剩 2GB但页回收延迟太高内存分配线程等不了那么久直接触发了 OOM。这种问题靠改 Overcommit 参数没用得调vm.swappiness和脏页回写阈值。然后回到 Overcommit 维度去看Committed_AS与CommitLimit的关系。如果内核日志显示的是内存耗尽,但Committed_AS离CommitLimit还很远说明这台机器大概率跑的是默认mode 0允许超额没有严格限制承诺量如果Committed_AS已经贴着CommitLimit那你应该检查/proc/sys/vm/overcommit_memory是不是被人改成了 2 却没有同步调整overcommit_ratio。4.3 关键区分真耗尽、误杀、还是被 cgroup 圈死很多时候判断错了方向会让你做一堆无用功。我的习惯是先问三个问题第一这次 OOM 是全局的还是 cgroup 容器内的第二被杀进程的oom_score_adj是多少是否原本可以救回第三可回收缓存和 swap 当时是不是都被榨干了回答第一个问题看 dmesg 的措辞。如果日志里只出现进程名字没有特定 cgroup 路径大部分是全局 OOM如果出现memory: cgroup开头的记录则是容器 cgroup 触发。容器场景的解法通常是调容器内存上限或优化容器内应用的缓存策略而不是动宿主机参数。回答第二个问题可以通过oom_score_adj的分布来判断是否有误杀。如果一个服务是应用主入口、却被杀而它本身oom_score_adj是 0那不能怪内核偏心只能怪你没给关键进程打保护标记。很多团队只重视容器配置却完全没给进程设置过OOMScoreAdjust结果容器内的进程池混在一起谁死全凭缘分。回答第三个问题直接对比 OOM 发生前的MemAvailable、SwapFree和Memavailable的记录。如果三者在事发前都趋近于零说明是真正的物理内存耗尽如果 swap 还有很多、缓存也充裕那事情就不是 OOM 那么简单了要往内存碎片或者某些触发路径的并发竞争方向排查。5. 防御不是祈祷Overcommit 与 OOM Killer 的落地调优清单讲实话OOM Killer 的核心哲学是整机优先个体随缘。在生产环境你不能指望它发善心留你的应用一命因为设计它的目的就是牺牲一个进程来救整台机器。所以真正该做的事是在它动手之前把相应的防御机制全部铺好。我在几套线上系统里经过反复试验最终沉淀下来的调优思路可以归纳为三板斧明确保护层级、设定硬性上限、制造可控的演练机制。下面按实际操作顺序展开。5.1 给关键进程穿上防弹衣oom_score_adj 的正确用法第一件事是给你的核心进程设置负数oom_score_adj。在 systemd 管理的服务里可以直接在单元文件里写[Service] OOMScoreAdjust-800这个值会映射到/proc/pid/oom_score_adj。设成 -800 意味着该进程的 badness 被大幅压低只有当系统里实在没有其他可杀进程时它才会被考虑。但注意它不是绝对豁免只有 -1000 才是内核直接跳过。我实际用下来有一个心得不要把所有服务都设置成 -1000否则它们会互相竞争谁最不该死最终内核只能退而求其次解除保护或者被迫杀掉一个你根本没想过要保护的进程。合理的做法是把最核心的数据库、注册中心、微服务网关设为 -800 或 -900把那些可快速重建的任务类服务保持默认 0把批处理脚本、报表任务设为正数让内核第一个想到它们。这个分级设计的思想和机场的 VIP 通道一样不是所有人都能进贵宾室但真正重要的那批必须能进。5.2 硬上限与软缓冲组合拳式防线如果整机的内存模型允许我建议在核心节点上把overcommit_memory设为 2同时花几天时间观察Committed_AS的变化曲线找到一个合理的安全余量再把overcommit_ratio调成一个平时不会触发、但能挡住失控分配的值。这里的技巧是不要把 ratio 调到 100因为内核本身还要预留一些页面用于关键事务处理。我一般取 80 到 90留出至少 10% 的余量。与此同时swap 不能省。很多容器环境为了追求性能喜欢把 swap 直接关掉这在内存充足时确实爽但在内存不足时等于砍掉了内核的最后一道缓冲。我的建议是保留一个体量较小的 swap比如内存的 10% 到 20%并把vm.swappiness降到 10 或更低。这样日常几乎不会触发换页只有内存高压时 swap 才会起到缓冲作用给 OOM Killer 多争取几秒也让你的监控有时间发出告警。在 cgroup 维度给每个容器配好memory.max并打开memory.oom.group。这样一来即使要杀也只会杀容器内的一组相关进程随后编排系统可以立刻重启而不是让整台机器处于不可预测状态。5.3 制造一场可控的 OOM用 stress 验证你的防御体系防御体系搭好之后不能干等出大事才验证。我强烈建议在预发环境或低峰期的节点上用 stress 工具人工模拟 OOM验证你设置的保护到底有没有生效。一条典型的压力命令是这样的stress --vm 2 --vm-bytes 8G --vm-hang 30 --timeout 120这台机器如果有 16GB 内存两个压力进程各申请 8GB 会在物理内存层面迅速制造紧张。这时候你打开另一个终端实时观察/proc/pid/oom_score和 dmesg看内核有没有选中那些被保护的服务以及是否出现了预期的 OOM 日志。我做过好几次这种演练发现最常暴露的问题有三个一是 systemd 的OOMScoreAdjust配置只在服务启动时生效热修改/proc/pid/oom_score_adj后重启服务会丢配置二是 cgroup v2 的memory.max配了但宿主机的全局 OOM 依然会把容器内进程当作候选者导致容器内看着没事、整机却杀了容器进程的诡异现象三是vm.overcommit_memory2时压力进程本身的 malloc 就可能直接失败根本走不到 OOM 那步导致你想验证 OOM Killer 反而验证不了这时得临时切回mode 0再做测试。演练结束后把日志、指标、配置全部归档作为下一次容量规划的输入。这套流程走顺了之后你会发现 OOM 从听天由命变成了一件完全可以预计和管控的事。我个人的体会是整机内存管理的核心不是让机器永远不出问题而是在出问题的瞬间让错误落在你能接受的范围内。