malloc 成功 ≠ 你有内存:一次把 OOM 从头测到尾

malloc 成功 ≠ 你有内存:一次把 OOM 从头测到尾

线上告警:容器重启,exit code 137

137 = 128 + 9,意思是这个进程收到了 SIGKILL。没有异常,没有栈,没有 core dump------它是被枪毙的,不是自己死的。

这篇把从 malloc 到那颗子弹之间的每一步都实测一遍。环境:Linux 6.18,7.8 GB 物理内存,0 交换分区,cgroup 限额 5.84 GB。

1. 我申请了 12GB,机器只有 7.8GB

ini 复制代码
  连续 malloc(1GB) 成功 12 次 -> 共 12 GB 虚拟内存
  物理内存只有 7.8 GB, 进程 VSZ 已达 12.0 GB
  当前 RSS = 9.9 MB  <- 一个字节都没真正占用

malloc 返回的不是内存,是一张欠条。内核只在页表里记了「这段地址你可以用」,一页物理内存都没给。这就是 overcommit。

2. 但单次申请 8GB 就失败了

这里有个被普遍误解的点。同一台机器:

scss 复制代码
  malloc(1 GB   ) -> 成功 ✅
  malloc(8 GB   ) -> 失败 ❌ (ENOMEM)
  malloc(64 GB  ) -> 失败 ❌ (ENOMEM)
  malloc(4 TB   ) -> 失败 ❌ (ENOMEM)

12 个 1GB 可以,1 个 8GB 不行。

因为默认的 vm.overcommit_memory = 0启发式 模式:它只拦截「单次明显离谱」的申请(超过 RAM + Swap 的),但完全不管这些申请加起来是多少。

三种模式:

行为
0 默认 启发式:拦单次离谱的,不管总量
1 永远同意,任何大小都批
2 严格:总量不得超过 CommitLimit,超了当场 ENOMEM

只有模式 2 会真正算总账。CommitLimit 的公式是 Swap + RAM × overcommit_ratio%,我这台机器实测:

ini 复制代码
MemTotal   = 8,225,104 kB
Swap       = 0
ratio      = 50
CommitLimit= 4,112,552 kB   = 8,225,104 × 50% ✅

模式 2 的好处是申请失败会以 ENOMEM 的形式返回给你的代码,你能捕获、能降级、能打日志。代价是内存利用率大幅下降------所以几乎没人开。

3. 内存是「写」出来的,不是「申请」出来的

物理页真正落地的时刻,是你第一次往那个地址的时候(demand paging)。我逐页写满 512MB:

ini 复制代码
  首次写入 512MB: 2.094 s   (每页 15979 ns)
  再写一遍同样的: 0.004 s   (每页    33 ns)  -> 快 487 倍
  次缺页(minor fault) 增加: 131073 次  (512MB / 4KB = 131072 页)

缺页中断次数 131,073,理论值 131,072,一页不多一页不少。

每页第一次写要走一遍内核:分配物理页 → 清零 → 填页表。这里是 16μs/页(虚拟化环境偏慢,裸机通常 1~3μs),但第二次只要 33ns,快了 487 倍。

这解释了一个常见现象:服务刚启动的前几分钟总是慢一截------它在替你把欠条一张张兑现。

4. 最反直觉的后果:fork 失败

这是 overcommit 最坑人的地方,我也测了:

ini 复制代码
  起始: VSZ=0.01 GB  RSS=9.0 MB
  [小 VSZ] fork() 成功 ✅

  malloc(1GB) x 12 之后: VSZ=12.01 GB  RSS=9.1 MB
  [大 VSZ] fork() 失败 ❌  [Errno 12] Cannot allocate memory

进程只用了 9MB 物理内存,fork 却被拒绝了。

因为 fork 要复制页表,启发式模式会按虚拟内存总量来评估这次复制------12GB > 7.8GB,直接拒。哪怕 COW 意味着实际上一页都不会复制。

这就是 Redis 官方文档为什么明确要求你设 vm.overcommit_memory = 1:Redis 的 BGSAVE 靠 fork 出子进程来持久化,一个占了 8GB 的 Redis 在默认配置下,fork 会失败,快照直接存不下来。同样的坑也在 fork 型的 worker 模型里到处都是。

5. 欠条兑不出来的时候

我让一个子进程把自己标记为「优先枪毙」(oom_score_adj=1000),然后真的往里写数据:

ini 复制代码
  cgroup 限额 = 5.84 GB
  [子进程 412] oom_score_adj=1000 -> oom_score=1333
  [子进程] 已实际写入 1.0 GB
  [子进程] 已实际写入 2.0 GB
  ...
  [子进程] 已实际写入 5.0 GB

  ** 子进程被信号 9 (SIGKILL) 杀死 **
  OOM 计数: 0 -> 16038
  cgroup 峰值: 5.84 GB   (正好顶到限额)

SIGKILL 不可捕获、不可忽略、不可屏蔽。 你没有任何机会写一行「即将退出」的日志、flush 缓冲区、或者优雅关闭连接。

这就是 K8s 里 OOMKilled + exit code 137 的全过程。注意峰值精确停在 5.84 GB------杀它的是 cgroup 限额,不是机器内存。机器当时还剩 2GB 空闲。所以「服务器内存明明够用」和「容器被 OOM」可以同时成立。

6. 谁会被选中

内核给每个进程算一个 oom_score基础分正比于它占用的物理内存 (RSS + 页表 + swap),再加上 oom_score_adj

ini 复制代码
oom_score_adj 取值 -1000 ~ +1000
  +1000 = 请先杀我
  -1000 = 永不杀(内核豁免)

关键结论:OOM killer 杀的是「最胖的」,不是「泄漏的」。

所以典型的线上事故长这样------某个批处理任务偷偷吃内存,最后倒下的却是你那个内存占用最大、但完全无辜的主服务进程。真凶还在跑。

实践清单

1. exit code 137 先看 cgroup,不要先看机器内存。

kubectl describe pod 里的 OOMKilled + memory.failcnt 才是证据,free -h 显示「内存充足」完全不能排除 OOM。

2. 关键进程加保护: echo -1000 > /proc/<pid>/oom_score_adj(K8s 里通过 QoS class 体现------Guaranteed 的 Pod 拿到更低的 adj 值,更不容易被选中)。

3. 用 fork 的服务(Redis 等)设 vm.overcommit_memory = 1 这不是"调优",是让它能正常工作的前提。

4. 监控看 RSS 和 Committed_AS,别看 VSZ。 VSZ 包含大量永远不会兑现的欠条,Go 运行时预留的虚拟地址空间能让 VSZ 看起来吓人一跳,但毫无意义。

5. 容器里给 JVM/Go 设显式上限。 -XX:MaxRAMPercentage / GOMEMLIMIT,让运行时在触碰 cgroup 红线之前自己先 GC。等内核动手就太晚了------它不跟你商量。

最后

malloc 返回非空,只意味着内核答应给你内存。

真正的结算发生在你写入的那一刻,而结算失败的处理方式,是一颗不打招呼的 SIGKILL。

相关推荐
闲云野鹤在人间1 小时前
Docker入门|第3章 镜像详解
linux·网络·docker·容器·centos·云计算·php
团子股股东峥哥1 小时前
day38-RHEL-管理存储堆栈
linux·运维·服务器
JoyT1 小时前
Spring AI 2.0 进阶入门:Badcase、Eval 与大模型应用效果优化
后端
旺仔不是程序员1 小时前
复合索引最左前缀原则:PostgreSQL 的 WHERE 为什么必须命中第一列
数据库·后端·sql
旺仔不是程序员1 小时前
字段类型不一致:PostgreSQL 报错与索引失效的第一元凶
数据库·后端·sql
JaguarJack2 小时前
PHP Trait 为何可能成为语言的关键特性
后端·php·服务端
n8n2 小时前
多 Agent 协作实战:Supervisor 模式构建专家团队,突破单一 Agent 能力边界
后端
BingoGo2 小时前
PHP Trait 为何可能成为语言的关键特性
后端·php
名字还没想好☜2 小时前
Python 的 __call__ 实战:让实例像函数一样被调用,做带状态计数器、缓存器与可配置策略
开发语言·后端·python·缓存·编程语言