这两个 sysctl 到底在干什么

/proc/meminfo CommitLimit 和三个 sysctl 在终端里的 mockup

vm.overcommit_memoryvm.overcommit_ratio 是 Linux 内核暴露的两个旋钮,用于虚拟内存 commit 记账 —— 决定你的进程能否被授予一段「可能没有真实内存页来支撑」的虚拟地址范围。

commit 记账架在页分配器之上。每个 mmap()、把区域改成 writable 的 mprotect()、以及 mremap() 都会调整每-mm 的 mm->committed_vm 计数器。当调用涉及 commit 内存时,内核拿这个计数跟系统级的限额比。Mode 0 走启发式;mode 1 一律放行;mode 2overcommit_ratioovercommit_kbytes 定义一个严格的限额来强制。

这层是有意跟 cgroup 内存限额分开的。一个 cgroup v2 进程,memory.max=512M,照样能成功 mmap(PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, 4GB) 通过 commit 记账 —— cgroup 会在页真的被 fault 进的时候 OOM-kill。两套体系答的是不同问题:

决策依据 失败模式
Overcommit 记账 对一份 heuristic 或固定预算的虚拟预约 -ENOMEM from mmap() / malloc()(基本安全)
cgroup 内存 controller 每 cgroup 的真实使用 vs memory.high/memory.max OOM-kill(具侵入性,可能杀错进程)
页分配器慢路径 通过 direct reclaim + PSI 拿真物理页 等待,或节点级 OOM-kill

如果跑容器化工作负载,overcommit_memory 是第一道决定,cgroup 是第二道。它们正交,通常应该独立分析。

三档模式

三档模式卡片——启发式/总是/严格——带拒绝语义

Mode 0 — 启发式(默认)

启发式 overcommit。明显超过地址空间的 overcommit 会被拒绝,典型的系统跑这个。它会拦截真正过分的分配,同时放行合理的 overcommit 以减少 swap 使用。这是默认值。

启发式写在 mm/util.c:__vm_enough_memory() 里。一组 magic check 意思是「明显傻到不行的就拒」:

C
// mm/util.c, 简化版
if (sysctl_overcommit_memory == OVERCOMMIT_NEVER)
    vm_committed_as_b = ...;
else if (sysctl_overcommit_memory == OVERCOMMIT_ALWAYS)
    return 0;                 // 一律放行
else {
    // 启发式 — 一堆 magic 常量
    free = global_zone_page_state(NR_FREE_PAGES);
    free += global_zone_page_state(NR_FILE_PAGES);
    /* ... swap space, ... */
    if (free > pages)
        goto ok;
    if (cap_sys_admin)
        goto ok;             // root 在这有大概 3% 的 slack,git-blame 里写着
    /* 下面还有一堆启发式 — 完整列表看源码 */
}

关键是谁被拒,谁被放行:

  • 拒绝:非 root 单次 mmap 大于物理 RAM 的约 3–8%。具体说,如果 vm.overcommit_memory=0,请求会导致 vm_committed_as + bytes > 由 RAM 算出来的某个大数,非 root 调用者拿到 -ENOMEM。root 可以多搞约 3%。这个机制就是阻止 malloc(64 * 1024 * 1024 * 1024) 成功。
  • 放行:(swap + 一部分 RAM) 这个粗略边界内的分配都通过。典型是科学计算,要分配大型稀疏区域。
  • MAP_NORESERVE:显式绕过检查。在你想要申请巨大区域、又不想真预约时有用(比如 MPI 共享堆)。

Mode 0 成为默认是平衡的考量。它拦截会引发抖晃的那种野外 overcommit,其它放行。每次调用成本:几个 atomic op 加一个数学检查。便宜。

Mode 1 — 总是 overcommit

Bash
echo 1 > /proc/sys/vm/overcommit_memory

C
// mode 1 分支
return 0;          // 一律放行

内核根本不查 committed_vm 也不看任何限额。每个 mmap() 成功。页在 fault 之前不存在,fault 时如果物理 RAM 不够支撑,触发 OOM-killer。

内核文档引用的经典场景:

经典例子是用稀疏数组的代码,依赖虚拟内存几乎全是 zero page。

JVM 加 -Xmx=512G,整个堆一上来就 commit 但实际按需 fault。在一台 128G 主机上,512G 配置的 Java 进程 mode 0 下会被拒(或者生产环境重跑时拒);mode 1 下没问题,OOM-killer 在适当的时刻出手。同样的模式适用于 Redis(fork + copy-on-write)、自启动就分配超大桶的进程内缓存,以及任何用稀疏地址的科学计算代码。

Mode 2 — 严格记账

Bash
echo 2 > /proc/sys/vm/overcommit_memory

现在有硬上限了。内核算:

Bash
CommitLimit = (total_RAM − total_huge_TLB) × overcommit_ratio / 100 + total_swap

每个 mmap() / brk()-with-extension 把 vm_committed_as 加一笔。如果 vm_committed_as + new_request > CommitLimit,调用返回 -ENOMEM(在用户空间:malloc() 返 NULL,mmap()MAP_FAILED)。

内核文档里有句著名的 gotcha:

Mode 2 下 MAP_NORESERVE 标志被忽略。

所以显式要求 no reservation 也救不了你。Mode 2 是严格的

这个模式下有两个百分比旋钮:

  • vm.overcommit_ratio — 百分比(默认 50)。overcommit_ratio=50 时,CommitLimit = (RAM − huge_TLB) × 0.5 + swap
  • vm.overcommit_kbytes — 绝对字节数。非零就覆盖 ratio。当你想锁死绝对数、不受 RAM 大小影响时(比如容器主机的统一预约)有用。

一个不显然的点,在 serverfault 讨论里出现:overcommit_ratio > 100 实际上就是 overcommitratio=200 意味着 CommitLimit = 2× RAM + swap。文档的「Don’t overcommit」有误导 — 内核尊重的是限额本身,不是限额是否低于物理 RAM。

为什么 overcommit 必须存在

没有 overcommit,每次 mmap() 都得立刻预约物理页。这听上去合理,直到你意识到典型应用实际在做什么:

C
// 典型 jvm 启动器
void *heap = mmap(NULL, 512UL << 30 /* 512 GiB */,
                  PROT_READ | PROT_WRITE,
                  MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// → 按需 fault,后备页延迟分配

如果内核得用 512 GB 物理 RAM 给这个申请撑底,你永远跑不起来 512 GB 配置的 JVM,除非机器恰好有 512 GB。权衡就是:放行虚拟内存超出物理内存,但提供机制(overcommit 拒绝 + OOM-kill)处理「虚拟内存超过物理现实」的情况。内核选择:能拒绝就早点拒绝,逃不掉就后面再杀进程。

读旋钮,看预算

Bash
# 这两个 sysctl:
sysctl vm.overcommit_memory vm.overcommit_ratio vm.overcommit_kbytes
# vm.overcommit_memory = 0
# vm.overcommit_ratio  = 50
# vm.overcommit_kbytes = 0

# 内核当下实际看到的:
grep -i commit /proc/meminfo
# CommitLimit:    8466180 kB
# Committed_AS:   4235844 kB

CommitLimit 是用 overcommit_ratio 和当前 RAM/swap 算出来的(/proc/meminfo 显示实时数,即使你不查 sysctl 也能看见)。Committed_AS 是所有进程 mm->committed_vm 的实时累加。如果 Committed_AS > CommitLimit 你就在踩坑了 — 在 mode 0/1,这无所谓,检查早通过了;在 mode 2,那个 mmap 调用早就 fail 了。

想看谁在吃 commit:

Bash
# 每进程 mm->committed_vm 估算
for pid in /proc/[0-9]*; do
    if [[ -r "$pid/smaps" ]]; then
        committed=$(awk '/^Pss:|Rss:/{sum+=$2}END{print sum*1024}' "$pid/smaps")
        name=$(cat "$pid/comm" 2>/dev/null)
        printf "%-6s %-20s %s bytes\n" "$(basename $pid)" "$name" "$committed"
    fi
done | sort -k3 -n -r | head -20

注意是估算 —— Pss 是按比例的 set size,不是 commit。现代内核没有导出每 task 的 committed_vm;想精确就从 cgroup sum,或者用 smem 看运行时画面。

fork-on-write 的 Redis 那个老故事

Redis 的 BGSAVE/BGREWRITEAOF 调用 fork()。Linux 用 copy-on-write:子进程跟父进程共享页,直到任一方写。mode 0 下,如果 Redis 父进程 -Xmx=64G,内核视角看子进程全量 commit 加在父进程上,在内存吃紧的机器上会被拒。

这是那个经典 Redis 调参建议:

Bash
echo 1 > /proc/sys/vm/overcommit_memory

Mode 1 让 fork 成功;内存压力下 OOM-killer 接手。配合 vm.overcommit_ratio=100(或 Redis 文档历史建议的)系统就宽容了。这在 Redis 和数据库调优文档里被反复引用。Mode 1 不是多租户主机的正解 —— 只适合 fork 重的单一目的守护进程。

Java/JVM 的情况

现代 JVM 加 -Xmx 通常一启动就把整个堆 commit。128G 主机上,-Xmx=64G 的 JVM 没问题;-Xmx=192G,mode 0 在 128G 主机上会拒,就算 mode 1 也救不了你,如果内核在启动 JVM 时因为物理页一时不够 OOM-kill 它。

JVM 主机的实战做法是三选一:

  • 跑 mode 1 + 保证所有 JVM 的 -Xmx 之和不超过物理 RAM。
  • 跑 mode 2 + overcommit_ratio=100(或更高),让 commit 记账足够宽容。
  • 跑 mode 0,把 JVM 调成 commit 更少(-XX:+UseCompressedOops-XX:MaxRAM=N),让 commit 的虚拟内存能 fit。

现代发行版默认 mode 0,即使 JVM 主机也用,因为 MAP_NORESERVE 被尊重。要可预测启动,JVM 打包社区这几年来推 mode-2-with-high-ratio。

容器主机上的 mode 2

Kubernetes(以及任何多租户编排器)通常在主机上设 sysctl vm.overcommit_memory=1,再叠加每 pod 的 cgroup memory.max。这个组合:

  • 让每个 pod 的 mmap 都过,即使声明的 RSS 超物理 RAM
  • 通过 cgroup 卡每 pod 的真 RSS
  • 先在 cgroup 层 OOM-kill;cgroup 顶不住再升级到主机层

mode 2 加合理 ratio 是早期容器主机配方,也仍 work。实际差别:mode 2 让你精确预算 commit,mode 1 让内核在 fault 时挑受害者。

跟 cgroup v2 memory controller 的相互作用

分层防护——overcommit → 页分配器 → cgroup;按工作负载的生产组合

如果你读 memory cgroup v2 那篇,自然的问题就是:这两套系统哪里重叠?

  • 记账上不重叠。Overcommit 是对 commit 的虚拟预约检查;cgroup 是对真实使用的物理检查。
  • 跑容器的主机上 mode 2 + overcommit_ratio=50:每个容器的 mmap 都得 fit 到物理 RAM 的一半加 swap 之内。这限制了容器能干什么,但也一种「早点拒绝虚拟内存增长」的方式。
  • mode 1 + cgroup:commit 记账宽容;真实内存使用在 fault 时被 cgroup 约束。
  • __vm_enough_memory() 是 mode 0 唯一查的;cgroup 通过 memcg_charge() 关心物理使用。

生产里常见的组合:

Bash
host:    vm.overcommit_memory=1(或 2 + 高 ratio)
pod env: memory.high = 期望 RSS × 1.5
         memory.max = 一条线,「超过这个就杀」

分层防护:mode 2 + 低 ratio 早点拒绝,mode 1 在 fault 时让 cgroup 兜底。

mode 0 的启发式到底是什么,看代码

mm/util.c 里的 __vm_enough_memory() 文件短,有一堆 magic 数。简化一下:

C
int __vm_enough_memory(struct mm_struct *mm, long pages, int cap_sys_admin)
{
    long allowed;
    unsigned long commit_charge;   // 我们这次要 charge
    unsigned long free, total;
    int overcommit;

    commit_charge = pages;

    /*
     * overcommit 限额按页算;从字节转。
     */
    if (mm)
        commit_charge += mm->committed_vm;

    overcommit = sysctl_overcommit_memory;

    if (overcommit == OVERCOMMIT_ALWAYS)
        return 0;     // mode 1: 一律放过

    if (overcommit == OVERCOMMIT_NEVER)
        goto out;     // mode 2: 落到限额检查

    /* 下面是 Mode 0(启发式) */

    free = global_zone_page_state(NR_FREE_PAGES);
    free += global_zone_page_state(NR_FILE_PAGES);
    /* swap 和 SReclaimable 在源码里也算了 */

    total = global_zone_page_state(NR_ACTIVE_ANON) +
            global_zone_page_state(NR_INACTIVE_ANON) +
            /* ...active+inactive file, 等*/

    /*
     * 如果请求 fit 进 free + 可复用页,允许。
     * 否则,如果 cap_sys_admin,允许多 3% 的 slack。
     * 具体常量看 git-blame 历史。
     */
    if (free > pages)
        return 0;

    /* 「过分大」的检查:拒绝 81×commit_total? */
    if (cap_sys_admin)
        allowed = total / 32 + 1;
    else
        allowed = total / 16 + 1;

    if (commit_charge > allowed) {
        /* 很少 fatal — 大 mmap() 的 malloc NULL 路径就在这 */
        return -ENOMEM;
    }

    return 0;
}

源码读起来:这个启发式本质是「请求会消耗一块明显的物理内存」的检查。64G 主机上 4G mmap 过;非 root 64G 主机上 64G mmap 拒;128G 主机上同样 64G mmap 自由页够的话非 root 也过。常量在不同内核版本间略有变化,但结构稳定。

那些 gotcha

改 mode 立刻生效

Bash
echo 2 > /proc/sys/vm/overcommit_memory
# 下一次 mmap 即生效,不用重启

但有个微妙的点:有个进程的 committed_vm > CommitLimit,改完之后并不会被杀掉 — 它持有的预约仍然有效。改动只拒绝新的 commit。要丢现有的 commit,得进程退出或者显式 munmap()。新限额往后走才生效。

栈增长是隐式 mremap

文档原文:

C 语言栈增长做隐式 mremap。如果你要绝对保证、又跑在边缘,你必须用 mmap 把栈放到你认为最大尺寸那么大。

线程栈在访问时自动增长,内核调 expand_stack()expand_downwards()mmap(),这个 mmap 参与 commit 记账。Mode 2 下线程的栈爆了,内核可能因为这个自动 grow 返回 -ENOMEM,然后 SIGSEGV 这个线程。 这是「我们设 overcommit=2 程序就崩」最常见的报告。

缓解:把栈 ulimit(ulimit -s)显式设好,启动代码里 pre-touch 敏感栈变量。或者保持 mode 1。

/proc/meminfo CommitLimit 是动态的

CommitLimit 在公式输入变化时(RAM 热插拔、swap 变化、sysctl 更新)、或者 mode 0 每次调用时都会重算。你不能依赖快照 — 你读到的时候它已经稍微 stale 了。当作有噪声的仪表盘,不是精确账单数。

overcommit_kbytes 凌驾 ratio

同时设 overcommit_kbytes=0overcommit_ratio=N,ratio 被忽略。要回到 ratio-based,把 overcommit_kbytes 设回 0。

Mode 2 下 MAP_NORESERVE 被悄悄忽略

内核没给路径特殊处理意味着你「不要预约」的请求也什么都得不到,预约总是做的。这让 syscall 级工具意外 — 它们指望 mode 2 在显式放弃预约时退让一步。Documentation/mm/overcommit-accounting.rst 里有写。

fork 后 COW — 隐藏增长

Redis 跑 mode 1 + BGREWRITEAOF,然后开始脏页,实际物理使用在 fork 之后才增长。如果你的内核按 commit 拒绝,这个 refault 会 OOM-kill 子进程,即使 commit 看上去还有 headroom。Mode 1 + 仅物理监控(cgroup memory events)才能正确处理这种情况。

实时读限额

对 SRE 有用的一行命令:

Bash
# 现在多大 / commit 多大 / cap 多大:
awk '/^Commit/ {print}' /proc/meminfo
sysctl vm.overcommit_memory vm.overcommit_ratio vm.overcommit_kbytes

# 每进程 top contributors(commit 风格):
smem -t -k -s commit
# 或者手写一遍:
for p in /proc/[0-9]*; do
    name=$(cat $p/comm 2>/dev/null)
    c=$(grep VmRSS $p/status 2>/dev/null | awk '{print $2}')
    [[ -n "$c" ]] && echo "${c} ${name}"
done | sort -n | tail -20 | awk '{printf "%.1f MB\t%s\n", $1/1024, $2}'

# 快速测:我实际用了多少 headroom?
python3 -c "
ratio = 0.5; swap_kb=0
meminfo = dict(line.split(':',1) for line in open('/proc/meminfo') if ':' in line)
ram = int(meminfo['MemTotal'].split()[0])
total = int(meminfo['CommitLimit'].split()[0])
comm = int(meminfo['Committed_AS'].split()[0])
print(f'CommitLimit:    {total//1024} MiB')
print(f'Committed_AS:   {comm//1024} MiB ({100*comm/total:.1f}% of limit)')
print(f'Headroom:       {(total-comm)//1024} MiB')
"

按工作负载选设置

决策树:

  1. 单租户主机 + fork 重型守护(Redis、基于 fork 的 PostgreSQL 派生): vm.overcommit_memory=1。Ratio 别理。
  2. 多租户容器主机: vm.overcommit_memory=1 + cgroup v2 limits。Ratio 基本是噪声。
  3. 单租户 + 巨大 JVM 或稀疏数组的科学计算: vm.overcommit_memory=1。OOM-killer 在 fault 时是你的朋友。
  4. 硬实时 / 嵌入式,不能容忍随机 OOM kill: vm.overcommit_memory=2 + overcommit_ratio=100(或针对你的 RAM+swap 调 kbytes)。会偶尔拒大 mmap,这是保证的代价。
  5. 默认 Linux 桌面: vm.overcommit_memory=0。默认启发式。别动。

概念开关:mode 0/1 给内核权限到最后一刻杀进程;mode 2 永远不杀(而是早点失败上报)。只有当你宁愿看到分配失败、也不要 OOM kill 时,才选 mode 2。

psi=0 以及其它 VM 调参的交互

如果你已经了解 psi=0(那个关掉 PSI 记账换性能的命令行参数) —— 那是另一套体系。PSI 跟踪 stall,overcommit 跟踪预约。设 psi=0 不动 vm.overcommit_memory,反之亦然。唯一的关联情形:系统跑了 psi=0 时,OOM-kill 决定从 memory.pressure 看不见,你得读 /proc/meminfo CommitLimit / Committed_AS 加 OOM-killer 的日志(dmesg 或 systemd journal)。

内核文档没明说的

几件事生产里出现但文档没提:

  • vm.swappiness 和 overcommit 正交。swappiness 控制内核换出文件缓存页的激进程度;overcommit 是虚拟预约记账。因相同原因可调(内存管理),但两者不联动。
  • vm.zone_reclaim_mode 不影响 overcommit。它控制 NUMA 本地回收激进程度。
  • vm.watermark_scale_factor 也不影响。那个是关于 kswapd 激进程度。
  • overcommit_memory 是少数几个 vm.* sysctl 里运行时改安全、不影响现有进程的。只有未来的 mmap 在意。

实际 gotcha 列表,放在一起

  • Mode 2 忽略 MAP_NORESERVE。看文档再问「不要预约」。
  • Mode 2 默认 overcommit_ratio=50 会拒绝大于 2 × RAM + swap 的 Java -Xmx。把 ratio 调高,或用 mode 1。
  • Mode 1 让任何 mmap 成功。OOM-kill 在 fault 时发生。别怪 OOM-killer。
  • vm.overcommit_kbytes 凌驾 overcommit_ratio。把 kbytes 设回 0 撤销。
  • Redis 需要 mode 1,fork+COW 才不会在 BGREWRITEAOF 路径上 OOM-kill。
  • 容器主机几乎总是要 mode 1 + cgroup limits,而不是 mode 2。
  • 栈增长自动 mmap 在 mode 2 下可能失败,造成 SIGSEGV。在 deep mode 下显式限制 ulimit -s
  • Commit 记账是一堆启发式和 magic 常量。别指望 mode 0 表现得像「多一个 check 的 mode 1」;mode 0 的常量跨内核不可预测。
  • 文档里 mode 0 的「明显 overcommit」指非 root 单 mmap 大于约 6% RAM。如果你的工作负载合法需要每 mmap > 6%,且你信 OOM-killer,把 vm.overcommit_memory=1

全貌:虚拟内存记账是内核和用户空间的契约。Mode 0、1、2 是三份对「拒绝」语义不同的契约。选一份跟你的 OOM-kill 容忍 vs 分配失败容忍匹配的。

最后修改: 2026年7月21日

作者

评论

发表评论

您的邮箱地址不会被公开。