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。

相关推荐
狼爷4 分钟前
从零用 Java 构建 AI Agent 框架:JavaManus 设计与实现深度解析
后端·langchain·aigc
郑州光合科技余经理6 分钟前
海外版外卖加盟:总站与分站配送规则怎么分开管
java·开发语言·前端·后端·uni-app·php·ai编程
专业程序开发源22 分钟前
springboot简历管理系统81389-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·spring·php·课程设计
专业程序开发源36 分钟前
springboot社区养老系统44071-计算机课程设计、毕业设计
vue.js·spring boot·后端·python·django·php·课程设计
小呆呆66643 分钟前
副业搞起来,小说,漫画,漫剧的成本优化思路
前端·后端·面试
羔羊++1 小时前
18_实验十七_其他命令与自定义启动变量
linux
周杰伦fans1 小时前
8GB显存下模型量化实战指南
人工智能·后端·c#
小蒜学长2 小时前
基于SpringBoot的佳新超市管理系统设计与实现系统(代码+数据库+LW)
java·数据库·spring boot·后端·佳新超市管理系统
IT_陈寒2 小时前
Vite静态资源导入这个坑我帮你们踩过了
前端·人工智能·后端
打工仔折腾 AI3 小时前
把AI Agent托管在家用电脑:UU远程终端与端口映射实测记录
人工智能·后端·python·langchain·ai agent 实战