
内存overcommit,就是系统对内存条能给的量打了张“白条”。图:PantheraLeo1359531/CC BY 4.0
Linux对你的程序撒了一个善意的谎。
你的程序申请一块内存,比方说要2GB。内核几乎不假思索就答应了,哪怕这台机器根本没那么多空闲内存。它赌的是:你申请归申请,多半不会真的一次用满。这套机制叫内存overcommit,超额承诺。系统对每个进程都说"内存管够",靠的就是大家不会同时把嘴张到最大。
大部分时候这个赌局赢得很漂亮。真让它输,往往是在半夜没人盯着的时候。而输了的代价,落在数据库头上尤其狠。Ubicloud最近写了一篇文章,讲他们为什么给PostgreSQL关掉了这个默认行为。我读完觉得,这事值得跟中文读者掰开说说——它背后是Linux内存管理里一个很多人踩过、却说不清的坑。
谎言的另一头,站着OOM killer
既然内核先答应了、把内存都超额承诺出去,那真到大家一起用、物理内存不够分的那一刻怎么办?这时候登场的,是内核里一个叫OOM killer(Out-Of-Memory killer,内存不足杀手)的角色。它的活儿很简单粗暴:挑一个进程,杀掉,把内存腾出来,让系统别整个崩掉。
问题就在"挑一个"这三个字上。它不是随便乱杀,而是给每个进程算一个"该死分"(badness score),从0到1000。算法我核实了一下:吃内存越多的进程分越高,一个用满了它可申请内存的进程,分就顶到1000;活得越久分越低;nice值大于0的进程分数直接翻倍;带特权的系统进程则被除以4手下留情。你还能通过/proc/[pid]/oom_score_adj手动给某个进程加减分,调到-1000就是免死金牌。
听起来挺讲道理。可对一台跑数据库的机器来说,谁最胖?当然是数据库自己。于是最该活下来的那个,恰恰是最容易被点名的那个。从数据库进程的视角看,这就是一次没有任何预警的偷袭——上一秒还在好好干活,下一秒被一个SIGKILL带走,连喊救命的机会都没有。
为什么PostgreSQL被杀,是"塌方"而不是"掉一个进程"
这里要讲清PostgreSQL的结构,才明白它为什么格外怕这一刀。
PostgreSQL不是一个大进程包打天下。它有一个叫postmaster的主管进程,每来一个连接,它就fork出一个后端进程去伺候。所有这些后端进程之间,共享一大块内存——里面装着shared buffers、WAL buffers、锁表这些全局状态。这是它性能的根基,也是它的软肋。
Ubicloud点破的要害是:这块共享内存在操作系统层面没有任何事务保护。一个后端进程正写到一半、页只写了半张,就被OOM killer一刀砍了,这块共享内存就可能留下一个损坏的、半吊子的状态。postmaster是个悲观主义者——只要发现有后端进程是被非正常干掉的,它就默认最坏情况已经发生:数据可能已经乱了。它的反应是把剩下所有后端进程也一并终止,掐断每一条连接,然后进入崩溃恢复。
看明白了吗。OOM killer本来只想杀一个进程腾点内存,结果因为PostgreSQL这套共享内存模型,一个进程的死,变成了整个数据库的连接全断、服务中断、走恢复流程。一次小小的内存紧张,被放大成一场停机。这就是为什么在数据库人眼里,OOM killer的"random"不是随机那么简单,是灾难。
把"偷袭"换成"可预期的报错"
Ubicloud的解法,是从根上不让这个赌局开起来。办法就是那个很多人听过、却不太敢碰的内核参数:vm.overcommit_memory。
它有三档,我照内核文档核实过。0是默认值,启发式overcommit,明显离谱的申请会被拒,其余照样超额承诺。1是无条件overcommit,来者不拒、假装内存永远够,科学计算那种场景才用。2是严格模式,也就是Ubicloud选的这个——系统不再超额承诺,所有进程已承诺的内存总量(Committed_AS)不许越过一条上限线(CommitLimit)。任何一次申请,只要会把总量顶过这条线,当场就被拒绝,返回一个ENOMEM错误。
这一下,性质就变了。内存不够时,不再是内核先答应、事后让OOM killer来偷袭;而是在申请的那一刻就老老实实告诉你:"没了。"对程序来说,这是一个可以捕获、可以处理的普通错误。对PostgreSQL来说更是天壤之别——某个连接的内存申请失败,它自己报个错、回滚就是了,不会连累别人,更不会触发postmaster那套连坐式的全体崩溃恢复。把一次不可控的暴毙,变成了一次可控的、局部的报错。

数据库要的不是省内存,是确定性。图:Swilsonmc/CC BY-SA 3.0
天下没有白拿的稳,代价要算清
严格模式不是打开就完事,那条CommitLimit的线画在哪,是门手艺。
这条线由vm.overcommit_ratio(百分比)或者vm.overcommit_kbytes(绝对值)来定。公式是CommitLimit等于物理内存乘以这个比例,再加上swap。ratio的默认值是50——意思是默认只让你承诺一半的物理内存加swap。这个默认值明显不是给数据库准备的,太保守,会让你早早撞上ENOMEM。
Ubicloud给的配方是用overcommit_kbytes直接定死:总内存的80%,再加2GB。留出的那20%给内核自己的数据结构用;那个雷打不动的2GB,是留给exporter、备份工具这类"陪跑"进程的——它们常常会预留一大片虚拟地址空间,实际却用不了多少,得给它们留够额度。
还有个前提得说透:严格模式吃"独占"。它最适合那种基本只跑PostgreSQL、外加几个已知陪跑进程的机器。要是塞在一台什么杂活都跑的共享机器上,某个不相干的负载把承诺额度吃光了,PostgreSQL就算眼看着系统还有物理内存,也会平白无故收到ENOMEM。这时候严格模式反而成了新的坑。
我怎么看
这事最值得琢磨的,不是那个具体参数,而是一个更普遍的道理:默认值是给"平均情况"调的,不一定适合你的场景。overcommit默认开着,是因为对绝大多数桌面和普通服务来说,超额承诺换来的内存利用率是划算的,偶尔被OOM killer杀一下也无伤大雅。但数据库不吃这一套。它要的不是省内存,是确定性——宁可早点、明明白白地报"内存不够",也不要在某个没人值守的凌晨被无声地带走。
很多线上事故,追到底就是这种"善意的默认"没被理解、也没被质疑。你得先知道内核在背地里替你做了什么假设,才谈得上防坑。把一次不可控的暴毙,改造成一次可预期的报错,值不值那点调参的麻烦?对一个要稳的数据库,我觉得太值了。
资料来源:Ubicloud博客《PostgreSQL and the OOM killer: Why we use strict memory overcommit》、Linux内核文档overcommit-accounting、man7 proc_pid_oom_score_adj。参数以你所用内核版本的官方文档为准,上线前务必在自己的环境实测。
你线上被OOM killer偷袭过吗?当时是怎么定位、怎么救回来的?说说你的经历。