内存还剩一大半却报 fork 失败:vm.overcommit_memory 内存超额分配与 Redis 持久化踩坑实战

内存明明还剩一大半,Redis 却报 fork 失败:从 vm.overcommit_memory 说起

这大概是运维中最容易被误判的一类故障:服务器 8G 内存,Redis 只用了 1G,系统 free 显示还有 6G 可用,可一到做 RDB 快照的时间点,日志里就蹦出一句 Background saving terminated by signal 9,或者业务侧报 fork: Cannot allocate memory。你去看监控,内存曲线平平稳稳,CPU 也不高,完全看不出问题在哪。

更迷惑的是,等你重启一下 Redis,一切又正常了,于是你把它当成偶发抖动放过去,直到某天数据真的丢了一次才回头查。这类问题的根子,绝大多数不在 Redis,也不在内存「够不够」,而在 Linux 的内存超额分配(overcommit)策略。这篇文章把这件事从头讲清楚:内核为什么要允许超额分配、fork 到底在什么情况下会失败、以及怎么用最小代价把它理顺。

先分清两件事:内存「够用」和内存「能分配」不是一回事

很多人对「内存够不够」的判断,依赖的是 free -m 里那一行 Mem: available。这个指标本身没错,它反映的是「在不触发 swap 的前提下,还能拿来给新进程用的大致内存量」。但它回答的是「现在启动一个新进程,物理内存够不够」,而不是「内核愿不愿意批准一次大额的内存预留申请」。

这是两个完全不同的层面:

  • 物理层面:内存条上有多少空闲页,能不能真的落盘到内存上。这个是 free 在回答。
  • 账目层面:内核在进程调用 malloc、fork、mmap 时,是否批准这次「内存额度」的申请。这个由 overcommit 策略决定,free 完全不体现。

打个比方:你的银行账户里确实有钱(物理内存),但你要开一张大额支票时,银行有它自己的一套风控规则(overcommit),这套规则可能直接拒付,理由是「我不确定你未来会不会把这笔钱全提走」。在 Linux 里,这套风控规则就是 vm.overcommit_memory。

vm.overcommit_memory 的三个取值,以及它们分别在管什么

这个参数在 /proc/sys/vm/overcommit_memory(持久化配置写在 /etc/sysctl.conf 或 /etc/sysctl.d/*.conf),只有三个取值,但语义差别极大:

0:启发式超额分配(默认值)

这是绝大多数发行版的默认值。内核用一种启发式算法判断这次申请「看起来合不合理」,它会拒绝那些明显荒谬的请求(比如申请比物理内存加 swap 总和还大的内存),但对一般规模的申请比较宽容,会批准。这个模式的问题是它的判断标准不透明、随内核版本变化,你很难精确预测它什么时候会拒绝。

1:总是允许超额分配

内核无条件批准所有内存申请,不管你要多少。这个模式最大的好处是——Redis 官方文档明确要求的正是这个值。原因是 Redis 做持久化时不是「分配一份新内存」,而是 fork() 出一个子进程,子进程在 fork 那一刻,理论上是父进程地址空间的完整副本。

在 1 模式下,内核不会真的去检查「复制这份地址空间还需要多少物理内存」,而是直接放行,真正的物理内存分配推迟到写入时才发生(这就是 Copy-On-Write,写时复制)。所以在 1 模式下,即使机器内存看起来不够,fork 也能成功。

2:严格不允许超额,按 CommitLimit 卡死

这是最严格的模式。内核维护一个硬性的「可承诺内存上限」 CommitLimit,所有进程申请的内存总和一旦超过它,就直接拒绝。这个值由 overcommit_ratio(默认 50,即物理内存的 50%)加上 swap 大小算出,也可以在 sysctl 里单独指定 vm.overcommit_kbytes。

在 2 模式下,你会看到那句经典的 fork: Cannot allocate memory,哪怕 free 显示内存一堆空闲——因为卡住你的是账目上限,不是物理内存。

为什么 Redis 做 RDB 会 fork 失败:真正的机制

Redis 的 RDB 持久化、AOF 重写,都依赖 fork()。理解 fork 失败的机制,关键要理解 fork 在 Linux 上做了什么:

  1. 父进程调用 fork(),内核为子进程创建一套新的页表。
  2. 此时内核并不复制实际的数据页(那是低效的),而是让父子的虚拟地址都指向同一批物理页,并把这些页标记为只读。
  3. 任何一方要修改某个页时,触发页错误,内核才真正复制那一页出来——这就是写时复制。
  4. 但页表本身(把虚拟地址翻译成物理地址的那张表)是必须真实存在的,页表占用的内存是按虚拟地址空间大小算的,跟实际用了多少物理页无关。

所以关键在于:fork 的开销,取决于父进程的虚拟地址空间有多大,而不是它实际用了多少物理内存。

一个 8G 内存的 Redis,如果配置了 maxmemory 4gb,那么即便当前只存了 1G 数据,它的虚拟地址空间也可能是一个很大的数。fork 时内核要按这个空间规模去建页表、做账目承诺,overcommit 策略如果偏严格,就会在这里拒绝。

这就解释了那个反直觉的现象:内存明明还剩很多,fork 却失败。因为它算的是另一本账。

怎么亲眼看到这本账?看 /proc/meminfo:

CommitLimit:    4177404 kB
Committed_AS:   3896540 kB

CommitLimit 是内核允许的承诺总量上限,Committed_AS 是所有进程已经承诺的内存总和。当 Committed_AS 逼近 CommitLimit 时,新的内存申请就会被拒。这个数字在 free 里看不到,必须看 /proc/meminfo,这是排查这类问题的第一现场。

怎么改:把 overcommit_memory 设为 1

对绝大多数跑 Redis、跑数据库、跑 PHP-FPM(本身也 fork)的服务器,标准做法就是把 overcommit_memory 设为 1:

# 临时生效(重启失效,用于验证)
sysctl -w vm.overcommit_memory=1

# 永久生效:写入配置文件
echo 'vm.overcommit_memory = 1' > /etc/sysctl.d/99-overcommit.conf
sysctl -p /etc/sysctl.d/99-overcommit.conf

# 验证
cat /proc/sys/vm/overcommit_memory

为什么用 /etc/sysctl.d/99-overcommit.conf 而不是直接改 /etc/sysctl.conf?因为现在的主流发行版(Debian 12、Ubuntu 22.04+、Rocky 9)都按目录里文件名的字典序加载,99- 前缀保证它最后加载,不会被别的文件覆盖。而且把自定义配置和系统自带的 sysctl.conf 分开,将来系统升级时不会冲突,回滚也干净。

改成 1 之后的风险要说清楚

设成 1 不是没有代价。它意味着内核不再拦内存申请,如果某个进程失控疯狂申请内存,内核会一直批准,直到物理内存和 swap 真的耗尽,那时候触发的是 OOM Killer 而不是申请失败。换句话说,你把「优雅地分配失败」换成了「粗暴地杀进程」。

所以设成 1 的前提是:你知道自己的进程规模是有界的。对个人站长的常见场景(Redis、MySQL、Nginx+PHP-FPM),这个前提通常是成立的。但如果你在同一台机器上跑着来源不明的程序,那就得权衡。

比「改成 1」更重要的三件事

把参数改对只是止血,真正让 fork 稳定的是下面三件事。任何一个没做,你还是会在某个时刻遇到 fork 失败。

一、给 Redis 的 fork 预留内存,而不是只看用了多少

Redis 的 fork 期间,理论上最坏情况下 COW 会把所有页都复制一遍,所以一个稳妥的经验法则是:预留出大约 maxmemory 1.2 到 1.5 倍的空闲内存,专门给 fork 子进程用。别把 maxmemory 配到接近物理内存的值,那样 COW 一发生就必然 OOM。

可以用 redis-cli info memory 里的 mem_fragmentation_ratio 和 used_memory_rss 来观察实际驻留内存。RSS 远大于 used_memory 时,说明碎片或 COW 已经在吃内存了。

二、确认没有开透明大页(THP)

透明大页(Transparent Huge Pages)是 Redis 的另一个经典杀手。THP 会把 4K 的页合并成 2M 的大页,而 COW 的粒度是「页」——本来复制 4K,现在要复制 2M,写时复制的内存放大效应会非常严重,还带来延迟抖动。

检查:

cat /sys/kernel/mm/transparent_hugepage/enabled

如果输出里 [always] 被中括号括住,说明 THP 是开着的,需要关掉。关闭方式前面有一篇专门讲 THP 的文章写过,这里只说结论:通过内核启动参数或 rc.local 在启动早期关闭,且必须早于数据库启动。

三、确认 swap 存在且合理

很多人在云服务器上为了性能干脆禁用了 swap。但在 Linux 的内存管理里,swap 不只是「虚拟内存」——它是 CommitLimit 计算的一部分,是内核在内存压力下的一块缓冲。完全禁用 swap 会让 COW 发生时没有任何回旋余地,直接撞上 OOM。

对 8G 内存的机器,配 2-4G swap(哪怕放在 SSD 上)是合理的折中。它平时不被使用,只在真的紧张时兜底。vm.swappiness 可以调低(比如 10)来减少平时换出,但不要设成 0。

排查这一类问题的固定动作清单

下次再遇到 fork 失败或 Cannot allocate memory,按这个顺序走,不要凭感觉:

  1. 看日志原文。Redis 的 Background saving terminated by signal 9 是 OOM Killer 干的(signal 9 是 SIGKILL),Cannot allocate memory 是分配被拒。两种原因不同,处理方式也不同。
  2. 看 dmesg -T | grep -i "out of memory"。如果 OOM Killer 出手了,这里会有记录,明确写出被杀的进程和当时的 oom_score。
  3. 读 /proc/meminfo 的 CommitLimit 与 Committed_AS。这是判断是不是 overcommit 账目问题的核心证据,别只看 free。
  4. 确认 vm.overcommit_memory 的当前值,以及它在 sysctl.d 里有没有被别处覆盖。
  5. 检查 THP 状态。
  6. 核对 Redis 的 maxmemory 与机器物理内存的比例,留出 COW 余量。

一个容易被忽略的细节:sysctl 的加载顺序

我见过好几次「参数明明改了,重启后又变回去」的情况。原因是把自己的配置写进了低优先级的文件,或者写进了 /etc/sysctl.conf 而某个高优先级的 /etc/sysctl.d/ 文件里又有同名项把它覆盖了。

排查方法很简单,按加载顺序把相关配置全列出来:

grep -rn "overcommit" /etc/sysctl.conf /etc/sysctl.d/

看哪个文件排在最后。规则是:/etc/sysctl.conf 最先加载,然后 /etc/sysctl.d/ 目录里的文件按文件名排序依次加载,后面的覆盖前面的。99- 开头的排在最末,所以是最稳的选择。另外 systemd 系统上,有些参数还可能被 systemd 自己的 sysctl 调用链影响,用 systemd-analyze cat-config systemd/sysctl.conf 能看到完整的合并结果。

小结

「内存还剩很多却分配失败」这件事的本质,是物理内存和内存承诺账本是两回事。overcommit 策略管的是账本,free 显示的是物理。Redis fork 失败、PHP-FPM 报 Cannot allocate memory,多半是账本卡住了,而不是内存真的没了。

处理顺序:先把 vm.overcommit_memory 设为 1 并持久化到 sysctl.d/99- 文件,再给 fork 留够余量(预留 maxmemory 的 1.2-1.5 倍),关掉 THP,保留合理的 swap。然后去 /proc/meminfo 学会读 CommitLimit 和 Committed_AS 这两个指标——它们比任何第三方监控面板都更直接地回答「内核现在愿不愿意再批额度」这个问题。

把这套理顺之后,你会发现这类「莫名其妙」的 fork 失败基本就绝迹了。剩下的偶发问题,才值得再去怀疑别的东西。

Last modification:September 30th, 2026 at 01:28 pm

Leave a Comment