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。