PostgreSQL 与 OOM Killer:为何必须使用严格的内存超分配策略
摘要
这篇博文解释了为什么 PostgreSQL 数据库容易受到 OOM killer 的影响,以及使用严格的内存超分配如何能够防止灾难性宕机。文章还讨论了内核中的一个 bug 以及设置合适超分配限额的启发式方法。
暂无内容
查看缓存全文
缓存时间: 2026/07/03 14:14
# PostgreSQL 与 OOM 杀手:为何我们采用严格内存过量使用
来源:https://www.ubicloud.com/blog/postgresql-and-the-oom-killer-why-we-use-strict-memory-overcommit
[](https://www.ubicloud.com/)
2026年4月27日 · 10分钟阅读
Burak Yucesoy Burak Yucesoy 首席软件工程师
过去15年里,我们的团队成员构建并运营了五个托管的 PostgreSQL 服务。在所有服务中,有一个配置始终不变:严格内存过量使用。在这篇博文中,我们将解释严格内存过量使用如何保护你的数据库免遭灾难性的 OOM(内存不足)杀手。我们还将分享一个仅三个字符的内核错误如何迫使我们暂时禁用此设置。最后,我们将解释确定合适内存过量使用限值的启发式方法。希望这能帮助你为你的工作负载找到合适的设置。
### 为什么 PostgreSQL 无法容忍 OOM 杀手
Linux 允许进程分配比物理可用内存更多的虚拟内存。当进程分配内存(例如使用 `malloc()`)时,内核会为其保留虚拟地址空间。然而,内核不会立即用物理内存来支持该空间。物理页面仅在进程实际访问内存时才被消耗。内核依赖于这样一个假设:并非所有分配的内存都会同时被积极使用。通常,这个假设成立。当不成立时,内核会调用 OOM 杀手来通过终止进程释放内存。
对于大多数进程来说,处理 OOM 杀手很简单:进程重启、重新连接,然后从之前的位置继续。PostgreSQL 则不同。PostgreSQL 的 postmaster(其主监督进程)为每个连接派生一个后端进程。这些后端共享内存段,其中包含共享缓冲区、WAL 缓冲区、锁表和其他共享状态。OOM 杀手不理解这种架构。它只是基于一种启发式方法(通常是使用内存最多的进程)选择一个进程并将其终止。如果该后端正在修改共享内存段,该段可能留下不一致的状态。操作系统级别的共享内存没有事务保证。共享缓冲区中的半写入页面意味着静默数据损坏。
PostgreSQL 的 postmaster 知道这一点。当它检测到任何子进程被杀死时,它会假定最坏的情况:共享内存可能已损坏。当共享内存损坏时,存在损坏存储数据的风险。为了防止这种情况,postmaster 会终止所有剩余的后端。每个活动连接都被断开。每个正在执行的事务都被中止。在下次启动时,数据库会经历崩溃恢复。这是正确的行为——PostgreSQL 正在保护你的数据。但这意味着单个 OOM 杀死不仅影响一个连接,它会断开服务器上的所有连接。此外,如果写入量很大,重放所有 WAL 文件进行崩溃恢复可能需要很长时间。这意味着一次内存不足的情况可能导致长时间的中断。
### 严格过量使用:尽早失败,而非灾难性失败
可以配置内核在进程请求内存时的行为。Linux 通过 `vm.overcommit_memory` 提供了三种过量使用策略:
- **模式 0(启发式)**:默认值。内核拒绝任何大于系统实际可提供(大致是空闲内存 + 交换空间 + 可回收的页面缓存和 slab)的单个分配,但此外允许自由过量使用。实际上,这只会阻止荒谬的请求,比如单个进程请求超过整个系统内存的内存。
- **模式 1(总是)**:内核从不拒绝分配请求,无论它有多大,也无论已经承诺了多少内存。每个 `malloc()` 和 `mmap()` 都会成功。如果进程后来实际访问的物理内存超过了系统所能提供的,OOM 杀手就会介入,通过终止进程来释放内存。
- **模式 2(严格)**:内核跟踪所有进程的已提交虚拟内存总量(`Committed_AS`),并强制执行一个上限,称为 `CommitLimit`。任何会导致 `Committed_AS` 超过 `CommitLimit` 的分配都会立即被拒绝,并返回 `ENOMEM` 错误。
在严格过量使用模式下,内核有两个旋钮来设置 `CommitLimit`:`overcommit_kbytes` 和 `overcommit_ratio`。`CommitLimit` 的计算方式为:
```
CommitLimit = overcommit_kbytes + swap
```
或者,如果未设置 `overcommit_kbytes`:
```
CommitLimit = overcommit_ratio / 100 * available_memory + swap
```
当分配失败并返回 `ENOMEM` 错误码时,PostgreSQL 会优雅地处理。无法分配内存的后端会向客户端报告错误,取消事务,然后继续。postmaster 保持运行。其他连接不受影响。这是一个常规错误,而非灾难。
这种取舍在于:严格过量使用将延迟的、破坏性的失败转化为早期的、优雅的失败。当机器专门用于 PostgreSQL 和一小部分已知的 sidecar 进程时,这种取舍效果最好。在这种情况下,已提交内存的概况是可预测的,可以放心地调整限制。而在运行多样化工作负载的共享机器上,已提交内存更难预测。一个不相关的进程可能会用完提交预算。这可能导致 PostgreSQL 收到 `ENOMEM` 错误,即使数据库负载正常。
### 一个内核错误与 648 GB 的幻影内存
我们一直偏爱 PostgreSQL 的严格过量使用。我们之前构建的托管 PostgreSQL 服务以及 Ubicloud PostgreSQL 中都使用了它。然而,这次启用后我们很快遇到了麻烦。开启严格内存过量使用几周后,一些数据库开始出现故障。即使机器上有大量空闲物理内存,它们也会显示内存不足错误。我们禁用了严格内存过量使用并开始调查。
#### 发现
第一条线索来自对一台 8 GB 内存服务器的 `/proc/meminfo` 的例行检查:
```
$> cat /proc/meminfo | grep "Committed_AS"
Committed_AS: 683547672 kB
```
**在一台 8 GB 的机器上,已提交内存为 651 GB!** 相比之下,相同大小的健康服务器显示:
```
$> cat /proc/meminfo | grep "Committed_AS"
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...
96414 2241436 94892 postgres: 18/main: postgres postgres...
96619 2241076 93308 postgres: 18/main: postgres postgres...
96417 2240900 94300 postgres: 18/main: postgres postgres...
95728 2240736 93864 postgres: 18/main: postgres postgres...
96620 2240736 92852 postgres: 18/main: postgres postgres...
95727 2240428 93640 postgres: 18/main: postgres postgres...
96623 2239840 93164 postgres: 18/main: postgres postgres...
```
VSZ 是进程映射的虚拟地址空间总量,RSS 是它实际使用的物理内存。在上面的输出中,每个后端显示约 2 GB 的 VSZ(覆盖其整个映射地址空间),但 RSS 小得多(约 95 MB),反映其积极使用的内存。在这台 8 GB 的虚拟机上,我们配置了 2 GB 的 `shared_buffers`,如果你认为约 2 GB 的 VSZ 与 `shared_buffers` 大小可疑地接近,你是对的。每个后端的大部分 VSZ 实际上是持有 `shared_buffers` 的共享内存段。每个后端将相同的 2 GB 区域映射到自己的地址空间,因此它出现在每个后端的 VSZ 中。当有多个后端时,VSZ 数字会迅速增加。
也就是说,所有这些都不应该夸大 `Committed_AS`。共享内存段出现在每个后端的地址空间中,但物理上只存在一次,因此应该只计数一次。此外,我们以 `huge_pages = on` 运行 PostgreSQL,因此 `shared_buffers` 从 hugetlb 分配。Hugetlb 映射有自己的独立预留记账,并且不应计入 `Committed_AS`。尽管如此,2 GB 的 hugetlb 区域是每个后端中最大的映射,并且 hugetlb 记账是内核中的一个特殊情况。这使得它成为最自然的入手点,所以我们最初的假设是内核以某种方式错误地计算了这些映射。例如,每个进程收费一次而不是忽略它们。为了验证,我们通过 `/proc/<pid>/smaps` 检查了 hugetlb 映射的 VMA(虚拟内存区域)标志。每个 VMA 有一组标志,`ac` 标志(`VM_ACCOUNT`)表示该区域计入已提交内存:
```
$> sudo cat /proc/321784/smaps | grep -A 25 "hugepage"
7fce75000000-7fcef0c00000 rw-s 00000000 00:10 10723551 /anon_hugepage (deleted)
Size: 2027520 kB
Shared_Hugetlb: 393216 kB
Private_Hugetlb: 0 kB
...
...
VmFlags: rd wr sh mr mw me ms de ht sd
```
没有 `ac` 标志。大页面被正确排除在已提交内存记账之外。假设被排除。
然后我们对机器上所有进程的可记账内存(带有 `ac` 标志的 VMA)求和:
```
$> sudo awk '/^Size/{size=$2} /VmFlags:/ && / ac/{sum+=size} END{printf "%.2f GB\n", sum/1048576}' /proc/[0-9]*/smaps
2.43 GB
```
可记账内存 2.43 GB 对比报告的 651 GB;**648 GB 的幻影已提交内存。** `vm_committed_as` 计数器在泄漏。我们怀疑内存在分配时收费,但从未重新计入。这让我们考虑在内核已提交内存计算中可能存在一个潜在的错误。
#### 全集群分析
当时,我们的集群中使用了两种不同的内核。我们检查了所有 PostgreSQL 服务器,并根据内核版本和运行时间比较了 `Committed_AS` 与 `MemTotal` 的比率:
| 指标 | 内核 6.5.0 | 内核 6.8.0 |
|------|------------|------------|
| 中位数比率 | 0.55 | 0.27 |
| 平均比率 | 24.97 | 0.32 |
| 最大比率 | 3,405 | 1.86 |
| 比率大于1.0的服务器 | 23% | < 1% |
左右拖动表格查看其余内容
我们还进行了统计分析,发现运行 6.5 内核的服务器出现已提交内存膨胀的可能性**高出 52 倍**。在 6.5 服务器上,运行时间与膨胀正相关。泄漏大约以每周 4.7% 的复合增长率增长,与运行时间成正比。在 6.8 服务器上,没有相关性。这一分析大大增强了我们的假设,即这是一个内核错误。
#### 一个字符的错误
为了获得确凿证据,我们让 LLM 检查了 6.5.0 到 6.8.0 之间的每个提交,寻找已提交内存计算中的可能修复。它很快找到了以下内容。
该错误是在 Linux 6.5 中由提交 408579c(https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=408579cd627a15bd703fe3eeb8485fd02726e9d3)引入的。这个提交改变了 `do_vmi_align_munmap()` 的返回约定:
- **之前**:0 = 成功,1 = 成功且锁降级,负数 = 错误
- **之后**:总是 0 表示成功,负数表示错误
该提交更新了 mm 子系统中的调用者。然而,在 `mm/mremap.c` 中的 `move_vma()` 内部,错误检查被错误地转换了:
之前(正确):错误处理程序在返回负数时运行(出错时)
```c
if (do_vmi_munmap(&vmi, mm, old_addr, old_len, uf_unmap, false) < 0) {
/* OOM: 无法分割 vma,只需调整账户 */
if (vm_flags & VM_ACCOUNT && !(flags & MREMAP_DONTUNMAP))
vm_acct_memory(old_len >> PAGE_SHIFT);
}
```
之后(损坏):错误处理程序在返回 0 时运行(成功时)
```c
if (!do_vmi_munmap(&vmi, mm, old_addr, old_len, uf_unmap, false)) {
/* OOM: 无法分割 vma,只需调整账户 */
if (vm_flags & VM_ACCOUNT && !(flags & MREMAP_DONTUNMAP))
vm_acct_memory(old_len >> PAGE_SHIFT);
}
```
从 `< 0` 到 `!` 的变化反转了条件。要理解这为何重要,请考虑 `move_vma()` 的作用。它首先为旧区域递减 `Committed_AS`(作为移动的一部分),然后调用 `do_vmi_munmap()` 来实际取消映射。如果取消映射失败,内核需要将计数器递增回去以保持记账正确。毕竟,取消映射失败了,旧区域仍然存在。必须恢复其收费。由于条件反转,这个重新递增操作在每次成功的 mremap 上运行,而不是仅在失败时运行。计数器随着每次内存重映射操作单调增长。
该错误在 此处(https://lore.kernel.org/all/[email protected]/)报告,并在 此处(https://lore.kernel.org/all/[email protected]/)进行了二分。Linus 本人分析了根本原因,并通过一行修改修复了它(https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=3cec50490969afd4a76ccee441f747d869ccff77),将条件改回 `< 0`:
```
- if (!do_vmi_munmap(&vmi, mm, old_addr, old_len, uf_unmap, false)) {
+ if (do_vmi_munmap(&vmi, mm, old_addr, old_len, uf_unmap, false) < 0) {
```
正如 Linus Torvalds 在修复中所写:
> 这并没有改变任何实际的 VM 行为,**除了**当 VMA 上设置了 `VM_ACCOUNT` 时的内存记账。这使得错误的返回值测试相当微妙,因为其他一切都继续工作。或者更确切地说——它继续工作,但“已提交内存”记账变得混乱(`/proc/meminfo` 中的 `Committed_AS` 值),并且根据设置,这在很长时间后会导致问题,因为 VM 依赖虚假统计信息进行启发式决策。
这是一种伪装在显而易见的地方的错误。在启发式过量使用(默认)下,`Committed_AS` 纯粹是信息性的。内核不将其用于门控分配。该错误仅在非默认的严格过量使用模式下才导致失败,因此它没有被注意到。失败也是间接的。记账在几周内默默漂移,直到 `Committed_AS` 最终超过 `CommitLimit`,分配开始失败。
### 设置提交限制
内核错误解决后,我们可以逐步重新启用严格内存过量使用。这是一个好时机来解释我们决定提交限制的启发式方法,以防你也想为你的工作负载启用它。我们使用公式:
```
overcommit_kbytes = total_memory_kb × 0.8 + 2 × 1048576
**
简单来说:**总物理内存的 80% 加上 2 GB**。
#### 为什么是 80%?
20% 的预留用于内核数据结构使用的内存,这些数据结构在用户空间中不可见。这包括页表、slab 缓存、网络缓冲区和内核自身的分配。需要注意的是,20% 并没有浪费。内核仍然将其用于页面缓存(即,内核使用空闲物理内存来缓存文件 I/O)。这是最大的消费者,并且直接有益于 PostgreSQL 的读取性能。页面缓存不计入 `Committed_AS`,因为它是可回收的。当任何进程实际需要内存时,内核可以随时驱逐缓存的页面。
#### 为什么是 +2 GB?
我们集群中的每个 PostgreSQL 服务器都运行几个 sidecar 进程。例如 prometheus、node_exporter、postgres_exporter 和 wal-g。这些是 Go 程序,Go 的运行时通过 `mmap` 提前保留大块虚拟内存区域,但只在需要时才实际访问页面。它们贡献的已提交内存远大于实际常驻内存。我们调查了集群中这些 sidecar 进程的已提交内存:
| Sidecar 已提交内存 | 服务器占比 |
|-------------------|-----------|
| 0.0 – 0.5 GB | ~64% |
| 0.5 – 1.0 GB | ~32% |
| 1.0 – 1.5 GB | ~1% |
| 1.5 – 2.0 GB | ~1% |
| 2.0 – 2.5 GB | ~1% |
| 2.5 – 3.0 GB | ~1% |
| 3.0 – 3.5 GB | ~1% |
左右拖动表格查看其余内容
96% 的服务器低于 1 GB。我们发现了 vCPU 数量与 sidecar 已提交内存之间的弱正相关性(r = 0.22)。这可能是因为 Go 的运行时随可用 CPU 数量扩展,但还不足以证明按比例伸缩的合理性。固定的 2 GB 覆盖了 >99% 的服务器。这是故意慷慨的。如果这个偏移量太小,sidecar 可能会悄悄消耗掉
相似文章
使用 Postgres 作为作业队列的潜在后果
文章分析了使用 PostgreSQL 作为作业队列的可扩展性限制,特别强调了高并发下 MultiXact SLRU 争用导致的性能瓶颈。文章解释了为什么这种架构在开发环境中表现良好,但在生产环境中却会失败,并建议考虑替代方案。
PREEMPT_NONE 已死;你的 Postgres 可能并不在乎
Linux 7.0 移除了 PREEMPT_NONE 内核抢占模式,在特定配置下导致 PostgreSQL 基准测试性能下降,但大多数用户不会注意到这一变化。
初创公司的PostgreSQL生存指南
一份面向在生产环境中运行PostgreSQL的初创公司的全面指南,涵盖模式设计、查询优化、索引、迁移、连接管理,以及查询计划与分区等高级主题。
深入解析Postgres内部:数据库集群、数据库与表
一篇探讨PostgreSQL内部机制的技术文章,涵盖数据库集群、数据库、表、系统目录和对象标识符(OID)的逻辑与物理结构。
PostgreSQL 18.4 和 17.10 版本修复了 11 个 CVE
PostgreSQL 已发布针对版本 18.4、17.10、16.14、15.18 和 14.23 的安全更新,修复了 11 个 CVE 和超过 60 个错误。值得注意的修复包括 CVE-2026-6473(整数回绕,CVSS 8.8)和 CVE-2026-6475(符号链接覆盖,CVSS 8.8)。