一台跑 PostgreSQL 的机器内存被打满,OOM killer 挑中一个 backend 干掉。很多人以为这只影响一条连接——用户重连一下就好。实际上整个实例会跟着倒下:所有连接被掐断、未提交事务回滚、下次启动走一遍 crash recovery。WAL 写得多的话,恢复要跑很久。一次内存打满能换来一段不短的停机。
Ubicloud 的工程团队把他们踩过的坑写成了一篇很实在的复盘:为什么他们坚持给 PostgreSQL 开严格 overcommit,以及他们是怎么被内核一个字符的 bug 反咬一口的。我读完觉得这是今年最值得数据库运维细读的一篇,下面把关键部分拆开。
postmaster 架构决定了它受不了 OOM kill
PostgreSQL 的主进程叫 postmaster,每来一个连接就 fork 一个 backend。这些 backend 共享同一段内存:shared buffers、WAL buffers、锁表、各种共享状态。OOM killer 不理解这套架构,它只按启发式(通常是占内存最多的进程)挑一个杀。如果被杀的 backend 正在写共享内存段,这段内存就可能处在不一致状态——OS 层面共享内存没有事务保证,shared buffers 里一个写了一半的页就是静默的数据损坏。
postmaster 的应对是:一旦发现任何子进程被杀,就假定最坏情况,主动把所有剩余 backend 全部终止。这个行为是对的,PostgreSQL 在保护你的数据,但代价是一次 OOM kill 不是一条连接的事,而是整个服务器所有连接一起掉。
Linux 的三种 overcommit 策略
vm.overcommit_memory 控制内核面对内存申请时的态度:
- 0(启发式,默认):只拒绝单个离谱的大申请(超过可用物理内存 + swap + 可回收的 page cache/slab),其余随便超卖。等于只挡"一次要 800GB"这种。
- 1(永远允许):任何 malloc/mmap 都成功。真要用到物理页时不够了,才轮到 OOM killer 上场。
- 2(严格):内核用
Committed_AS记录全系统已提交的虚拟内存,并强制一个上限CommitLimit。任何会把 Committed_AS 顶过这个上限的申请,当场返回ENOMEM。
CommitLimit 的算法很简单:
CommitLimit = overcommit_kbytes + swap
# 如果没有设置 overcommit_kbytes:
CommitLimit = overcommit_ratio / 100 × 可用内存 + swap
关键差别在失败形态。严格模式下申请失败就是一个 ENOMEM:backend 把错误报给客户端、取消当前事务、继续活着,postmaster 不掉,其他连接不受影响。这是把"晚来的、破坏性的失败"换成"早来的、优雅的失败"——代价是得接受偶尔的业务报错。
这个取舍有个前提:机器基本专用给 PostgreSQL 加少量已知的 sidecar。共享机器上别的进程会吃掉 commit 预算,数据库负载正常也可能吃 ENOMEM。
648GB 幽灵内存
他们开了严格模式,几周后开始收到内存报错,而机器上物理内存明明还剩很多。查 /proc/meminfo,一台 8GB 的机器上:
$ cat /proc/meminfo | grep Committed_AS
Committed_AS: 683547672 kB # 651 GB
同规格健康机器的对照值:
Committed_AS: 2703940 kB # 2.6 GB
先怀疑是自己的配置算错了,用 ps 看进程:
$ ps -C postgres -o pid,vsz,rss,cmd --sort=-vsz
PID VSZ RSS CMD
96622 2242244 95416 postgres: 18/main: postgres postgres...
每个 backend 的 VSZ 约 2GB,正好等于 shared_buffers 的大小:每个 backend 都把同一段共享内存映射进自己的地址空间,所以 VSZ 会重复统计;但这不该影响 Committed_AS,共享段物理上只有一份,而且他们开了 huge_pages = on,hugetlb 映射有独立的预留记账,本来就不该进 Committed_AS。
验证方法是看 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
VmFlags: rd wr sh mr mw me ms de ht sd # 没有 ac
假设被排除。再手工把所有带 ac 的 VMA 加总:
$ sudo awk '/^Size/{s=$2} /VmFlags:/ && / ac/{sum+=s} END{printf "%.2f GB\n", sum/1048576}' /proc/[0-9]*/smaps
2.43 GB
2.43GB 的实际记账 vs 651GB 的上报值,648GB 是凭空多出来的。 计数器在泄漏:申请时加了账,释放时没有减回来。
机队横向对比,把范围锁死
把整个 PostgreSQL 机队按内核版本和上线时长拉出来对比 Committed_AS / MemTotal 的比值:
| 指标 | 内核 6.5.0 | 内核 6.8.0 |
|---|---|---|
| 比值中位数 | 0.55 | 0.27 |
| 比值均值 | 24.97 | 0.32 |
| 比值最大值 | 3405 | 1.86 |
| 比值 > 1.0 的机器 | 23% | < 1% |
统计上,跑 6.5 内核的机器出现计价膨胀的概率是 52 倍。而且 6.5 上膨胀与 uptime 正相关,大约每周复合增长 4.7%;6.8 上没有这种相关性。
一个字符的内核 bug
定位到具体提交后,事情就清楚了。Linux 6.5 的 commit 408579c 改了 do_vmi_align_munmap() 的返回值约定:原来 0 = 成功、1 = 成功但锁降级、负数 = 错误,改成一律 0 = 成功、负数 = 错误,并同步更新了调用方。但 mm/mremap.c 里 move_vma() 的判断改错了:
// 正确:< 0 才进错误分支
if (do_vmi_munmap(&vmi, mm, old_addr, old_len, uf_unmap, false) < 0) {
/* OOM: unable to split vma, just get accounts right */
if (vm_flags & VM_ACCOUNT && !(flags & MREMAP_DONTUNMAP))
vm_acct_memory(old_len >> PAGE_SHIFT);
}
// 实际合入:! 反转了条件
if (!do_vmi_munmap(&vmi, mm, old_addr, old_len, uf_unmap, false)) {
...
}
move_vma() 的逻辑是:先为旧区域减少 Committed_AS,再调 do_vmi_munmap() 真正解除映射;如果 unmap 失败(旧区域还在),就必须把账加回来。条件反转之后,这段"补账"代码变成每次 mremap 成功都执行,计数器只增不减。
Linus 亲自分析了根因,用一行改动修回来(把 ! 改回 < 0),并留下这段话:
This didn't change any actual VM behavior except for memory accounting when 'VM_ACCOUNT' was set... the "Committed memory" accounting goes all wonky (Committed_AS value in /proc/meminfo), and depending on settings that then causes problems much much later as the VM relies on bogus statistics for its heuristics.
这类 bug 之所以藏得住:默认启发式模式下 Committed_AS 纯粹是信息展示,内核不拿它当分配门禁,所以数字烂掉也没人管;只有严格模式才会因为它拒绝分配。而且失效是间接的——计数器安静地漂几周,直到越过 CommitLimit 才开始报错。
CommitLimit 怎么定
修复就位后再回过头开严格模式,他们的经验公式是:
overcommit_kbytes = total_memory_kb × 0.8 + 2 × 1048576
也就是物理内存的 80% 再加 2GB。
为什么留 20%:这部分给内核自己的数据结构(页表、slab、网络缓冲)兜底。20% 不是浪费,内核仍然拿空闲物理内存做 page cache,这是 PostgreSQL 读性能最大的帮手,而 page cache 是可回收的,本来就不计进 Committed_AS。
为什么固定加 2GB:每台机器上还有一堆 sidecar——prometheus、node_exporter、postgres_exporter、wal-g,清一色 Go 程序。Go runtime 启动就 mmap 预留大片虚拟内存,按需 fault in,它们提交的内存远大于实际 RSS。机队统计显示 96% 的机器 sidecar 提交量在 1GB 以内(和 vCPU 数只有弱相关,r = 0.22),固定 2GB 能覆盖 99% 以上,宁可给宽一点:这个额度给少了,sidecar 会悄悄吃掉剩余 commit 预算,最后吃 ENOMEM 的是 PostgreSQL 而不是 sidecar。
实现里有个细节值得抄:他们用了 vm.overcommit_kbytes 而不是 overcommit_ratio,因为公式里那固定 2GB 没法用百分比表达(4GB 机器上它是 50%,64GB 上只有 3%)。另外如果是 hugepage 部署,还要先扣掉预留部分:
def configure_memory_overcommit(strict: false)
if strict
total_mem_kb = File.read("/proc/meminfo").match(/MemTotal:\s+(\d+)/)[1].to_i
# 25% 内存留给 hugepage,不计入 commit limit,只剩 75% 可用
non_hugepage_mem_kb = total_mem_kb * 0.75
overcommit_kbytes = (non_hugepage_mem_kb * 0.8 + 2 * 1048576).round
safe_write_to_file("/etc/sysctl.d/99-overcommit.conf",
"vm.overcommit_memory=2\nvm.overcommit_kbytes=#{overcommit_kbytes}\n")
else
`sudo rm -f /etc/sysctl.d/99-overcommit.conf`
end
`sudo sysctl --system`
end
我的做法
给数据库开严格 overcommit 之前,先做三件事,不然就是给自己造故障:
- 量化基线,别凭感觉设。采集一段时间
Committed_AS和CommitLimit(node_exporter的node_memory_Committed_AS_bytes就有),峰值留 30% 以上余量再开。 - 先修内核。看到
Committed_AS远超实际值,用上面那条awk /proc/[0-9]*/smaps求和自查,比值超过 1.0 基本就是这个 mremap 记账 bug(内核 6.5 引入),升级即解。 - 盯住 sidecar。把 Go 写的 sidecar 的提交量单独监控起来,它们抢的是数据库的 commit 预算。真出问题时,报错的会是 PostgreSQL,而不是真正吃内存的那个。
严格 overcommit 从来不是性能优化,它是把"半夜整个实例挂掉 + 长时间 crash recovery"换成"个别请求报 ENOMEM"。这个交易值不值,取决于你的业务能不能接受一条连接失败。对绝大多数线上库来说,能。
浙公网安备 33010602011771号