操作系统内功 · 第 51 篇|overcommit 三种策略:内存申请的承诺与谎言

overcommit 三种策略与 OOM 链条(手绘信息图)

虚拟内存的承诺制:malloc 不等于物理内存

第 20 篇讲过 OOM Killer 是内存耗尽时的兜底裁决者;这一篇往前追一步,讲内存分配的前门规则——overcommit。要理解它,先接受一个反直觉的事实:malloc 返回成功,不代表任何物理内存已经到手。malloc 与 mmap 分配的只是虚拟地址区间,内核在账本上做了一笔「承诺登记」,物理页要等到程序第一次真正写入那些地址、触发缺页中断时才逐一分配。这套设计叫惰性分配,它不是偷懒,而是几个关键机制的基础:fork 之所以能在毫秒级复制一个大进程,靠的是父子共享页加写时复制,物理内存根本没动;程序预留 10GB 缓冲区只用 2GB,另外 8GB 不占任何物理页;堆的预留与提交分离,glibc 的 malloc 才能自由伸缩。

但惰性分配埋下了一个时间差:分配时看起来大家都有内存,使用时才知道总账够不够。这个时间差就是 overcommit 的全部矛盾——承诺总量可以超过真实物理资源,靠「不是每个承诺都会兑现」这个统计事实运转。绝大多数时候它是优雅的:全系统的物理内存得到充分利用,没有谁真的需要那么多的承诺。少数时候它变成定时炸弹:所有承诺在同一天兑现,内核发现自己兑现不了,只能启动 OOM Killer 强制违约。

于是问题变成策略问题:内核应该在分配时就严格审核承诺,还是放宽审核把风险留到使用时?Linux 把这个选择交给了管理员,就是 vm.overcommit_memory 的三个值。三种模式没有绝对优劣,它们是在「分配失败提前暴露」与「运行中违约事后兜底」之间滑动的刻度,选哪一档取决于你的应用能不能优雅处理分配失败、以及 OOM 的业务代价有多高。

承诺制的账本值得再看一眼来源。glibc 的 malloc 对小分配走堆(在堆段内切分),大分配直接走独立映射区间;线程栈、代码段、共享库映射各有各的承诺;加上 fork 的共享页按潜在私有化记账——全系统承诺总额汇总的是所有这些「理论峰值」的总和,天然显著大于实际物理占用。理解这笔账的结构,才能理解为什么「剩余额度」比「物理内存余量」更值得监控:物理余量描述现状,承诺余量预示未来,OOM 与分配失败都发生在未来。

三种模式与 CommitLimit 算术

模式 0 是启发式审核,也是绝大多数发行版的默认。内核对每次申请做粗略估算:单次申请明显超过物理内存加交换空间总量的离谱请求会被拒绝,中等规模的申请则几乎照单全收。它防的是「申请 1TB」这种明显荒谬的请求,防不住「一百个进程各申请 20GB」这种合理请求的合理化叠加。启发式的好处是不打扰正常程序,坏处是防线形同虚设——绝大多数 OOM 事故恰恰发生在模式 0 之下。

模式 1 是来者不拒:除明显非法的请求外一律放行,fork 与 malloc 永远成功。它的适用场景很具体:应用自己有严格的内存上限管理,或者存在瞬时承诺远超实际使用的工作模式。Redis 官方明确建议把机器设成模式 1 并伴随启动告警,原因后面细讲。选模式 1 的哲学是把内存治理的责任完全交给应用层,内核不再审核,OOM Killer 是唯一的边界。

模式 2 是严格审核:全系统的承诺总量不得超过 CommitLimit,算式为物理内存乘以 overcommit_ratio 百分比,再加上交换空间大小,ratio 默认 50。也就是说一台 128GB 内存、16GB 交换空间的机器,默认承诺上限是 64 加 16 共 80GB。超额申请直接失败,malloc 返回空指针,fork 报资源不足——「内存不足」从运行中崩溃提前到了分配时失败,应用有机会优雅处理。观测口在 /proc/meminfo:CommitLimit 是上限,Committed_AS 是当前全系统承诺总额,两者之差就是剩余额度,容量巡检应把这两个数纳入例行采集。

调 ratio 是模式 2 落地的核心功课。ratio 太低,承诺额度不足,进程起不来或频繁分配失败;太高,保护形同虚设,退化回模式 0 的境遇。合理的 ratio 必须从压测里来:把目标负载全量拉起,观察 Committed_AS 的峰值,再留出余量反推。交换空间在算式里的角色同样重要——它既是承诺额度的组成部分,也是模式 2 下的实际缓冲:承诺超出物理内存的部分要靠换出兑现,没有 swap 的机器开模式 2 会把额度卡得非常紧。

模式选择的代价模型再放一个框架:分配失败的代价由应用承担(能不能优雅处理返回空指针),运行中 OOM 的代价由整机承担(受害者未必是责任人)。严格模式把代价前移给应用——应用必须写好失败处理,换来整机确定性;宽松模式把风险后置给内核裁决——应用省事,换来偶发的无差别击杀。团队的工程成熟度决定选择:有严格的内存治理与失败处理,严格模式是资产;历史包袱重、应用裸奔,强行上严格模式只会收获一批启动失败的工单。

从 malloc 成功到 OOM 的完整链条

把违约的完整链条走一遍。程序 malloc 20GB 成功,此时只是账本上多了一笔承诺;程序开始逐页写入,每次首次触碰一个页触发一次缺页,内核分配一个物理页并建立映射——物理内存以实际使用的节奏被逐步吃掉。当全系统空闲页耗尽,回收机制启动:内核先回收页缓存、换出匿名页(第 37 篇的回收路径),这些都不伤及进程;当回收也填不上缺口,内核触发 OOM Killer,按打分挑出一个进程当场击杀,把「兑现不了的承诺」强制违约掉。从分配成功到程序死亡,中间可能隔着几小时甚至几天——这就是承诺制延迟爆雷的形态。

启发式误判有一个经典剧本。某服务用 mmap 申请 20GB 做环形缓冲区,实际常年只用 2GB,在模式 0 下相安无事。某天一次大查询把 20GB 全部写满,叠加旁边正在扩容的另一个服务,整机物理内存见底,回收无效,OOM Killer 拿着打分表挑中了那个无辜的数据库——它承诺多(大 buffer pool)、份量大,打分自然高。复盘时所有人都很困惑:每个进程的申请都「合理」,加在一起却违约了。启发式管住离谱申请,管不住合理申请的叠加,这是模式 0 的结构性盲区。

fork 在这个链条里是个放大器。粗略记账下,fork 的承诺按父子地址空间之和计算——虽然写时复制让实际物理开销远小于此,但账面承诺翻倍。一个 30GB 的大进程 fork 一瞬间,账面上多了 60GB 的承诺,模式 2 下很可能直接失败;这就是大内存服务在严格模式下 fork 失败的原因,也是 Redis 这类靠 fork 做快照的服务对模式选择格外敏感的根源。真实物理开销取决于 fork 后子进程写入多少页,所以同样的 fork 在低写入期成功、高峰期失败,也让问题带上时运色彩。

再补一个容器场景的复合剧本。容器设了内存限额,进程以为自己活在一台 8GB 的机器上;宿主机 256GB,宽松模式放行一切承诺。容器内进程申请 7GB 成功、写入触顶限额,cgroup 的内存击杀机制在容器内执行——这条链路与系统级 OOM 是两套并行的司法系统,账本互不参考。运维要建立两张仪表盘:宿主机层面的承诺与物理水位、容器层面的限额使用率,任何一张见底都是不同的处置流程。这一篇讲的是第一张仪表盘,别让它的存在掩盖第二张。

交换空间在兑现链条里的角色也要单独看清。物理内存见底之后,内核的第一反应不是击杀,而是把匿名页换出到 swap 腾地方——swap 是承诺的缓冲垫,也是性能悬崖的起点:一旦系统进入大规模换入换出,交互延迟从微秒级劣化到毫秒级,业务侧的观感就是「服务还活着但慢得像死机」。监控要把换页速率与主要缺页当作核心指标,它们是「承诺正在被强制兑现」的实时信号,比内存剩余水位更早报警。

数据库与运行时的建议

PostgreSQL 社区的立场是选模式 2:内存不足宁可让分配失败、让应用报错重试,也不要运行中被 OOM 击杀——数据库被强杀的恢复代价(重放日志、重建连接池、缓存全冷)远高于一次分配失败。配套动作是按负载峰值压测 Committed_AS,反推 overcommit_ratio 与 swap 大小,把剩余额度做成监控指标与告警。MySQL 的建议方向类似:明确资源上限、避免依赖启发式兜底。

Redis 是光谱的另一端:官方建议模式 1。它的内存有 maxmemory 硬上限管理,淘汰策略自己兜底,不需要内核审核;而它的持久化依赖 fork 做快照,fork 瞬间的账面承诺翻倍在模式 2 下容易失败,在模式 1 下畅通无阻。这是一个「应用自治换内核放行」的典型契约:选了模式 1,就要接受全机其他进程也失去审核保护,所以 Redis 主机上通常不再混部署不受控的负载。

JVM 的故事在中间。堆参数预留的是一个巨大的虚拟区间,commit 在启动时完成大半,但物理内存随对象分配逐步落地。模式 2 下 Xmx 设得过大,启动或运行中的分配会直接失败,倒逼容量规划做实——很多人反而视之为优点。容器时代又添一层:cgroup 内存限额与 overcommit 是两个独立维度(第 8 篇的容器底座),容器内可能 overcommit 审核通过、却在 cgroup 限额处触雷,排查时两条线都要查。

故障复盘模板也值得留下。每次 OOM 事故固定回答四个问题:击杀发生时全系统承诺总额距上限还剩多少;被杀进程的承诺贡献占比多少(识别承诺大户);承诺大户的实际使用率几何(承诺远大于使用的进程是宽松模式下的重点观察对象);以及哪个进程的增量行为触发了这次兑现潮。四问答完,防止复发的动作自然浮现:降承诺、加额度、或迁移到严格模式。把复盘模板化,内存治理就从救火变成迭代。

实操清单与常见误区

清单如下。第一步先看现状:cat 出 overcommit_memory 的当前值,读 /proc/meminfo 的 CommitLimit 与 Committed_AS,记录剩余额度做基线。第二步按应用契约选模式:数据库类选 2 并压测 ratio,Redis 类选 1 并保证应用自治,通用混合负载维持 0 但配好 OOM 告警。第三步把 swap 纳入算式统筹,别只调 ratio 不管交换空间。第四步给 OOM 上监控:内核日志的击杀事件要进告警链路,这是模式 0 环境的最低保障。

常见误区。误区一:认为 malloc 失败才会内存不足——绝大多数 OOM 发生在分配成功之后的写入期。误区二:模式 2 设了就算完——ratio 不压测等于没设,额度告警不做等于裸奔。误区三:swap 关掉省事——没有 swap 的模式 2 额度极紧,模式 0 下也失去一层违约缓冲。误区四:容器里只看 cgroup 限额——overcommit 与限额是两条独立的雷区。误区五:把 Committed_AS 当物理占用——它是账面承诺,与实际使用是两个数,监控上要分开两条线。

再补一组快速自检问答,供接手新机器时使用:overcommit 当前是什么模式,谁设的、为什么;承诺上限与当前承诺总额的差值是多少,历史曲线里最低到过哪里;swap 的大小与近期换入换出速率如何;内核日志里最近一次 OOM 击杀记录在什么时候、杀了谁。四问能在十分钟内建立一台机器的内存人格——保守、激进还是裸奔。接手不明主机的第一课,永远从这四问开始。

小结

overcommit 是虚拟内存承诺制的策略开关:模式 0 用启发式宽松放行,模式 1 完全放行交给应用自治,模式 2 用 CommitLimit 算术把违约提前到分配时。三种模式对应三种风险分配方式,没有标准答案,只有与应用契约的匹配度。

从 malloc 成功到 OOM 击杀的链条提醒我们:内存不足的暴露点在使用时而非分配时,fork 的账面翻倍是时运型故障的来源。数据库倾向模式 2 的提前失败,Redis 依赖模式 1 的畅通 fork,JVM 在中间被迫做实容量规划——选择背后全是恢复代价的经济学。

运维的功夫在三处:读得懂 CommitLimit 与 Committed_AS 的账、压得出 ratio 的合理值、盯得住 OOM 事件的告警。这三件事做完,内存耗尽就从玄学变成了算术。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?