凌晨三点,手机响了。
PostgreSQL 挂了。不是慢查询,不是死锁,不是磁盘满。是 Linux 内核的 OOM killer 把 postmaster 当成了”最该杀的那个进程”,一刀下去,整库连接全部断开。
但你登录上去一看,free -h 显示还有几个 GB 的 free 内存。
这就不是内存不够的问题,是内核判错了死刑。
Ubicloud 作为一家托管 PostgreSQL 服务商,选择了一条反直觉的路——vm.overcommit_memory=2,strict overcommit。宁可让查询因为 ENOMEM 失败,也绝不让 OOM killer 随机挑一个 PostgreSQL 进程杀掉。
这篇文章聊聊这个选择背后的逻辑、代价,以及他们查出来的一个只有一个字符的内核 bug。
Linux 内存管理的三种人格
理解 strict overcommit,得先理解 Linux 在默认情况下是怎么”撒谎”的。
Linux 内核有三种 overcommit 模式,通过 vm.overcommit_memory 控制:
Mode 0(Heuristic,默认):内核根据一套启发式规则判断当前的内存请求是否合理。只要看起来还”撑得住”,malloc() 就返回成功。至于物理内存到底够不够——那是后来的事。大部分时候这没问题,因为程序申请的内存往往比实际用到的多。但启发式规则总有误判的时候。
Mode 1(Always):永远说 yes。无论申请多大,内核都批准。物理内存耗尽时,OOM killer 上场,杀一个进程腾出空间。这是最危险的模式,对数据库来说尤其如此。
Mode 2(Strict):内核追踪所有进程已提交的虚拟内存总量(Committed_AS),并设置一个硬上限(CommitLimit)。超过上限的申请直接返回 ENOMEM。程序自己决定怎么处理。
默认的 Mode 0 听起来很合理——大部分时候它确实工作正常。问题出在”大部分时候”的例外,恰好会落在数据库头上。
OOM killer 为什么专挑数据库下手
OOM killer 的决策逻辑——oom_badness() 函数——会计算每个进程的 oom_score。它主要看两点:进程占了多少内存,以及它是不是 root 进程。得分越高的进程越先被杀。
数据库进程正好是高分选手。
PostgreSQL 的 shared_buffers 映射到每个后端的地址空间中,每个连接看起来都像一个内存大户。同时,数据库进程的 RSS(实际占用物理内存)通常也确实很大。在内存紧张的瞬间,PostgreSQL 的后端进程——甚至 postmaster 本身——在 OOM killer 眼里就是最大的”目标”。
更要命的是,OOM killer 不知道谁是”重要的”。
它不区分 postmaster 和一个处理临时查询的后端。杀了 postmaster,整个 PostgreSQL 实例崩溃,所有连接断开,正在执行的事务回滚。在托管服务场景下,一台机器上跑了十几个客户的数据实例,OOM killer 的一次”随机执法”可能引发连锁灾难。
PostgreSQL 社区从 2003 年就开始在邮件列表里讨论这个问题。官方文档明确建议设置 vm.overcommit_memory=2。这不是新方案,是经过二十年验证的防御策略。
strict overcommit 怎么做
在 strict overcommit 下,PostgreSQL 的一个后端进程请求内存失败时,内核返回 ENOMEM。PostgreSQL 处理这个错误的方式很体面——向客户端报错、取消当前事务、继续运行。Postmaster 不受影响。其他连接正常工作。
这就是核心逻辑:把晚期、毁灭性的 OOM kill 转化为早期、可控的分配失败。
Ubicloud 计算 CommitLimit 的公式直截了当:
overcommit_kbytes = 总物理内存 × 0.8 + 2 GB
0.8 系数预留了 20% 给内核数据结构——页表、slab 缓存、网络缓冲区——这些东西不体现在用户态 Committed_AS 里,但确实占用物理内存。
额外的 2 GB 是给 sidecar 进程的。Ubicloud 每个 PostgreSQL 节点上还跑着 prometheus、node_exporter、postgres_exporter、wal-g 这些 Go 程序。Go 的运行时启动时就 mmap 大块虚拟地址空间,实际用的 RSS 却很少。它们的 committed memory 贡献远大于真实内存占用。Ubicloud 统计了集群数据,96% 的节点 sidecar 提交量在 1 GB 以内,2 GB 的固定预留覆盖了超过 99% 的情况。
实现代码不到 30 行 Ruby,放在 /etc/sysctl.d/99-overcommit.conf 里,sysctl --system 生效。全部代码可以在 Ubicloud 的 GitHub 仓库里看到。
代价是什么
strict overcommit 不是没有代价。
最大的代价是浪费。20% 的物理内存被”预留”给了内核,不能被用户态程序显式使用。虽然内核会把空闲物理内存用作 page cache 加速文件 I/O——这对 PostgreSQL 的读性能有直接好处——但如果你的工作负载几乎全是写入,这部分内存确实相当于被锁住了。
第二个代价是意外拒绝。CommitLimit 设得太低,正常的查询可能在内存申请时收到 ENOMEM,即使物理内存还很充裕。这需要应用层有重试和降级逻辑。不是所有应用都准备好了处理 malloc() 失败——很多 C 程序压根不检查 malloc() 的返回值。
第三个代价是 fork 失败。fork() 系统调用会短暂地”加倍”进程的地址空间计数,即使父子进程通过 COW(copy-on-write)共享物理页面。在 strict overcommit 下,如果 Committed_AS 已经接近 CommitLimit,fork() 可能直接返回 ENOMEM。这意味着 postmaster 无法派生新的后端进程来接受连接——这本身就是另一种形式的服务中断。
一个字符的内核 bug
Ubicloud 在启用 strict overcommit 几周后遇到了一个诡异的问题:一些 PostgreSQL 实例明明有充足的物理内存,却持续报 ENOMEM。
检查 /proc/meminfo,他们发现 Committed_AS 显示 651 GB——在一台 8 GB 的机器上。健康的同类服务器只有 2.7 GB。
648 GB 的”幽灵内存”。
排查过程很硬核。他们先怀疑 hugepage 映射被重复计数了——每个 PostgreSQL 后端的 VSZ 都包含一个 2 GB 的 shared_buffers 映射,虽然这个共享内存段物理上只存在一份。检查 /proc/<pid>/smaps 的 VmFlags,没有 ac(VM_ACCOUNT)标志。hugepage 被正确排除在 committed memory 核算之外。这条路不通。
然后他们遍历了所有进程的 accountable VMA,加总只有 2.43 GB。vm_committed_as 计数器确实在泄漏——内存被计入了分配,却从未被归还。
全集群分析发现,内核版本是决定性变量:
| 指标 | 内核 6.5.0 | 内核 6.8.0 |
|---|---|---|
| 中位数比率 | 0.55 | 0.27 |
| 平均比率 | 24.97 | 0.32 |
| 最大比率 | 3,405 | 1.86 |
| 比率 > 1.0 的服务器占比 | 23% | < 1% |
运行 6.5 内核的服务器出现 committed memory 膨胀的概率是 6.8 内核的 52 倍,而且膨胀速度与 uptime 正相关——每周约 4.7% 的复合增长。
问题最终追溯到 Linux 6.5 的一个 commit:408579c。这个提交改变了 do_vmi_align_munmap() 的返回值约定——之前 0 表示成功,之后始终返回 0 表示成功。在 mm/mremap.c 的 move_vma() 函数中,错误处理的条件从 < 0 被错误地改成了 !:
// 修复前(broken): 条件反转,每次成功 mremap 都多计一次内存
if (!do_vmi_munmap(&vmi, mm, old_addr, old_len, uf_unmap, false)) {
if (vm_flags & VM_ACCOUNT && !(flags & MREMAP_DONTUNMAP))
vm_acct_memory(old_len >> PAGE_SHIFT);
}
// 修复后(correct): 只在 unmap 失败时才补偿计数
if (do_vmi_munmap(&vmi, mm, old_addr, old_len, uf_unmap, false) < 0) {
if (vm_flags & VM_ACCOUNT && !(flags & MREMAP_DONTUNMAP))
vm_acct_memory(old_len >> PAGE_SHIFT);
}
一个 ! 的差异,让每次成功的 mremap 操作都把旧区域的大小重新加回 Committed_AS。计数器单调递增,永不平账。
Linus Torvalds 本人分析了问题并提交了修复。他在 commit message 里写:“这并没有改变任何实际的 VM 行为,除了当 VMA 上设置了 VM_ACCOUNT 时的内存核算。这让错误的返回值检查变得相当隐蔽,因为一切都继续正常工作。”
“一切都继续正常工作”——除了 Committed_AS 悄悄爆炸。在默认的 heuristic overcommit 下,这个值只是 /proc/meminfo 里的一个数字,没人会盯着看。只有在 strict overcommit 下,它才变成决策依据,bug 才会暴露。
社区怎么看
HN 讨论区里,Ubicloud 的联合创始人 Ozgun 自己先出来收了一脚油门:“我为这个博客标题道歉,它太强了。对于 Ubicloud 这个托管 Postgres 提供商来说,我们用 strict overcommit。但还有很多场景,启用它会产生意想不到的副作用。”
评论区的争议集中在几个点上:
“共享主机上用这招就是灾难。“一个在 Go 应用和 PostgreSQL 同机部署的工程师反馈:开了 overcommit_memory=2 后,Go 运行时的大块虚拟内存分配吃掉了 commit 预算,PostgreSQL 反而先撞上 ENOMEM。他们的最终方案是靠经验值反复调参——没有公式能算出来。
“更现代的做法是用 oom_score_adj。“有评论指出,把 postmaster 的 oom_score_adj 设为 -1000(排除在 OOM 计算之外),同时让子进程保持默认值,能让 OOM killer 只杀单个连接而不杀主进程。这笔账更精细,但前提是 OOM killer 真的能被触发——内存在被耗尽前可能先触发各种其他问题。
“Windows 和 macOS 默认就不做 overcommit。“这条获了不少赞同。Linux 的默认内存管理策略让桌面用户苦不堪言——不是 OOM killer 不及时触发导致系统卡死,就是杀了不该杀的进程。但 Linux 的默认值经历了二十多年的惯性,改不动。
Crunchy Data 的 Joe Conway 在一篇 2021 年的博文里补充了另一层方案:结合 Kubernetes cgroup 的 memory limit,把 OOM 的爆炸半径从”整台机器”缩小到”一个容器”。但这也意味着需要为每个 Pod 精确配置 requests 和 limits。
这笔账怎么算
strict overcommit 到底值不值得用,取决于你的约束条件。
专用数据库节点,sidecar 进程已知且数量固定——这是 Ubicloud 的场景。CommitLimit 可以精确计算,sidecar 的 committed memory 波动在可控范围内。在这类环境里,strict overcommit 把不可预测的 OOM 屠杀变成了可预期的 ENOMEM 错误。应用的查询要么成功,要么失败——不会出现”返回了一半结果然后连接断开”的中间状态。
共享主机、混部场景、Go/Java 应用和数据库跑在同一台机器上——CommitLimit 几乎没办法静态设定。大内存应用——尤其是 Go 和 JVM——的 committed memory 和实际 RSS 之间的差距可能是数倍甚至数十倍。给它们留的预算太小,数据库先被 ENOMEM 卡住;留得太大,strict overcommit 形同虚设。
桌面 Linux——不建议。太多 GUI 应用和浏览器 tab 依赖 overcommit 存活,而且 fork() 失败在桌面上比在服务器上更容易触发,表现也更差。
笔者的判断是:在条件允许的情况下,strict overcommit 是目前被广泛验证的有效方案之一。这个开关需要你在监控过一段时间的 Committed_AS 和实际 RSS 之后才去扳动——先理解自己的内存画像,再做决策。
受限于仅基于公开信息的分析,本文无法覆盖所有生产环境的边界情况。如果你的场景和 Ubicloud 不完全一致——很可能不一致——建议先在 staging 环境跑足负载测试,观察 ENOMEM 出现频率和触发模式,再决定是否推向生产。
参考链接: