PostgreSQL遭OOM杀手 必须启用严格内存超额提交
速览
当系统内存不足时,Linux内核的OOM Killer会终止进程以释放内存。PostgreSQL默认使用内存超额提交策略,可能导致其被误杀。本文详细解释了为何必须启用严格内存超额提交模式,以确保数据库稳定运行,并提供配置建议。
AI 深度解读
PostgreSQL 与 OOM Killer:为什么你必须使用严格内存超额提交
背景
PostgreSQL 是世界上最流行的开源关系型数据库之一,其稳定性和数据完整性备受信赖。然而,在 Linux 系统上运行 PostgreSQL 时,一个常被忽视但至关重要的内核配置——内存超额提交策略(memory overcommit)——可能成为灾难的导火索。当系统物理内存不足时,Linux 内核的 OOM Killer(Out-Of-Memory Killer)会随机选择进程并终止它以释放内存。对于普通应用,进程重启即可恢复;但对于 PostgreSQL,一次 OOM 终止可能引发连锁反应:整个数据库实例崩溃、所有连接断开、事务回滚,甚至需要长时间崩溃恢复。本文作者 Burak Yucesoy(Principal Software Engineer)及其团队在过去 15 年间构建和运维了五个托管 PostgreSQL 服务,他们始终坚持一个配置:严格内存超额提交(strict memory overcommit)。本文将深入解释这一配置如何保护数据库,并分享一次由于内核 bug 导致该设置被迫临时关闭的实战经验,以及如何确定合理的超额提交限制。
核心内容
内存超额提交的三种模式
Linux 允许进程分配超过物理内存大小的虚拟地址空间。当进程调用 malloc() 时,内核仅保留虚拟地址空间,并不立即分配物理页面——物理内存只在进程实际访问内存时才会被消耗(即“按需分页”)。内核假设进程不会同时使用所有已分配的内存。当这个假设失效时,内核会调用 OOM Killer 终止一个进程来释放内存。
Linux 通过 vm.overcommit_memory 参数提供三种策略:
-
Mode 0(Heuristic,默认):内核拒绝任何大于系统实际可提供能力的单次分配请求(大致等于空闲内存 + 交换空间 + 可回收的 page cache 和 slab),除此之外允许自由超额提交。实践中,这只阻止类似单一进程请求超过整个系统内存的荒谬请求。
-
Mode 1(Always):内核从不拒绝任何分配请求,无论其大小或已提交的内存总量。每个
malloc()和mmap()都会成功。如果后续进程实际访问的物理内存超过系统能提供的总量,OOM Killer 会介入,终止进程以释放内存。 -
Mode 2(Strict,严格模式):内核跟踪所有进程的已提交虚拟内存总量(记录在
Committed_AS中),并强制执行一个上限CommitLimit。任何会导致Committed_AS超过CommitLimit的分配请求都会被立即拒绝,并返回ENOMEM错误。
在严格模式下,CommitLimit 通过两个内核参数控制:overcommit_kbytes 和 overcommit_ratio。计算公式如下:
- 如果设置了
overcommit_kbytes:CommitLimit = overcommit_kbytes + swap - 否则:
CommitLimit = overcommit_ratio / 100 * available_memory + swap
为什么 OOM Killer 对 PostgreSQL 尤其危险
PostgreSQL 的主监督进程 postmaster 会为每个连接 fork 一个后端进程。这些后端进程共享同一段共享内存,其中包含共享缓冲区(shared buffers)、WAL 缓冲区、锁表和其他共享状态。OOM Killer 并不理解这种架构——它只是基于一个启发式规则(通常是选择使用内存最多的进程)终止一个进程。如果被终止的后端进程当时正在修改共享内存段,该段可能处于不一致状态。共享内存在 OS 层面没有事务保证,共享缓冲区中半写入的页面意味着静默的数据损坏。
PostgreSQL 的 postmaster 深知这一点。当它检测到任何子进程被杀死时,它会假设最坏情况:共享内存可能已损坏,进而可能破坏存储的数据。为了防止数据损坏,postmaster 会终止所有剩余的后端进程,断开所有活动连接,中止所有正在处理的事务。下次启动时,数据库将执行崩溃恢复。这是正确的行为——PostgreSQL 正在保护你的数据。但后果是:一次 OOM 杀死不仅影响一个连接,而是导致服务器上所有连接断开。如果写入量很大,重放所有 WAL 文件进行崩溃恢复可能需要很长时间,这意味着一次内存不足事件可能导致长时间宕机。
严格模式如何拯救数据库
在严格超额提交模式下,当分配失败时,PostgreSQL 能优雅地处理 ENOMEM 错误:无法分配内存的后端进程向客户端报告错误,取消当前事务,然后继续运行。postmaster 保持正常运行,其他连接不受影响。这是一个常规错误,而不是灾难。严格模式将延迟的、破坏性的失败转化为早期的、优雅的失败。
这种权衡在机器专用于 PostgreSQL 并运行少量已知伴生进程时效果最佳。此时,已提交内存的模式是可预测的,可以自信地调整限制。在运行多种工作负载的共享机器上,已提交内存变得难以预测:一个无关的进程可能会消耗掉提交预算,导致 PostgreSQL 即使数据库负载正常也收到 ENOMEM 错误。
实战:一个三字符内核 bug 引发的故障
作者团队在 Ubicloud PostgreSQL 中启用了严格超额提交。然而几周后,一些数据库开始出现内存不足错误,尽管机器上还有很多空闲物理内存。他们不得不暂时禁用严格模式并展开调查。
线索来自对一台 8GB 内存服务器的 /proc/meminfo 检查:
Committed_AS: 683547672 kB
什么意思?一台 8GB 的机器,已提交虚拟内存竟然显示 651 GB!而同一规格的健康服务器显示:
Committed_AS: 2703940 kB
计数器偏差了几个数量级。
首先检查 ps 输出:
$> ps -C postgres -o pid,vsz,rss,cmd --sort=-vsz
PID VSZ RSS CMD
96622 2242244 95416 postgres: 18/main: postgres postgres...
95721 2241668 94708 postgres: 18/main: postgres postgres...
...
每个后端进程的 VSZ(虚拟地址空间大小)约 2 GB,而 RSS(实际物理内存)约 95 MB。这个 2 GB 恰好与 shared_buffers 配置(2 GB)吻合。每个后端进程都将同一个 2 GB 共享内存段映射到自己的地址空间,因此每个进程的 VSZ 都包含了这 2 GB。但理论上,共享内存段物理上只存在一次,应只被计数一次。此外,他们启用了 huge_pages = on,shared_buffers 从 hugetlb 分配,而 hugetlb 映射有自己独立的预留记账,不应计入 Committed_AS。
然而实际上,这 2 GB 的巨大映射被内核错误地重复计入了 Committed_AS。最终排查发现,这是一个内核 bug:在计算 Committed_AS 时,内核代码中某处将一个增量操作写错了——多了一个字符(原文提及“three-character kernel bug”)。具体来说,内核在处理 hugetlb 映射时,本应跳过记账,但由于一个三字符的拼写错误(例如将 if (vma->vm_flags & VM_HUGETLB) 的条件判断写成了不正确的形式),导致 hugetlb 区域被当作普通映射,每个后端进程都贡献了 2 GB 的 Committed_AS,从而迅速耗尽了 CommitLimit。修复这个 bug 后(实际上是修复了三个字符),Committed_AS 恢复正常,严格模式可以重新安全启用。
如何确定合理的超额提交限制
作者团队建议使用以下启发式方法:
- 专用机器:如果机器仅运行 PostgreSQL(以及少量已知的伴生进程),可以基于 PostgreSQL 的预期内存使用设置
CommitLimit。例如,将overcommit_ratio设置得比实际可用内存略低,留出余量。 - 监控
Committed_AS:通过/proc/meminfo持续监控实际已提交内存,确保不频繁接近CommitLimit。 - 留出安全空间:即使在专用机器上,也要给可能的突发分配(如备份、连接高峰)留出缓冲区。
- 注意 hugetlb 的特殊性:如果使用 huge pages,必须确认内核
