2MB 的 PostgreSQL work_mem,如何吃掉 2TB 内存?

0 阅读7分钟

一次真实的 PostgreSQL OOM 事故

某生产集群在占用 2TB 内存后,被 OOM killer 终止,而此时 work_mem 参数仅设置为 2MB。这一现象看似矛盾,却暴露了 PostgreSQL 内存管理中容易被忽视的核心问题——即便配备高性能硬件,峰值时段的单条查询仍可能导致整个集群宕机,而这类问题的解决,往往依赖于对数据库底层机制的深度理解,而非单纯的搜索引擎检索。

需特别说明:下文展示的内存日志并非来自生产事故现场,而是在独立服务器上复现问题时记录的结果(复现过程中提前终止了查询,避免了 OOM killer 触发),但生产集群未能如此幸运。本文将拆解该问题的排查过程、底层原因,并提供可落地的规避方案,助力开发者避开同类陷阱。

关键工具 pg_log_backend_memory_contexts 救场

在深入分析问题原因前,先介绍排查 PostgreSQL 内存问题的关键函数——pg_log_backend_memory_contexts,该函数在此次问题排查中起到了决定性作用,此前不少资深开发者也未曾了解其用法。

该函数的使用方式极为简单:传入进程 PID,PostgreSQL 会将该后端进程的完整内存上下文树(包含所有内存分配、上下文信息、内存大小及块数量)输出到日志中。

执行语句如下:

select pg_log_backend_memory_contexts(299392);

执行后查看日志,即可获取完整的内存分配详情,该函数 PostgreSQL 14+版本可用。

日志排查:异常内存占用的关键线索

从复现环境的日志中,可提取到以下关键信息(仅展示核心片段):

level: 4; ExecutorState: 234954896 total in 40 blocks; 14989952 free (524059 chunks); 219964944 used

level: 5; HashTableContext: 339738680 total in 47 blocks; 4716320 free (84 chunks); 335022360 used

可见,在work_mem=2MB的配置下,ExecutorState 上下文占用约 235MB 内存,HashTableContext 上下文占用约 340MB 内存。而该后端进程的内存总占用为:

Grand total: 586713672 bytes in 409 blocks; 20823696 free (527202 chunks); 565889976 used

复现环境中单个后端进程已占用约 557MB 内存,而生产集群拥有 2TB 内存却仍被 OOM killer 终止,足以说明问题的严重性。其中,ExecutorState 上下文包含的 524059 个内存块(chunks),成为了排查的核心突破口。

误区澄清:work_mem 并非“单查询内存上限”

资深 PostgreSQL DBA 都清楚,work_mem并非“单条查询的最大内存限制”。根据PostgreSQL 官方文档work_mem的定义是“单个哈希或排序操作可使用的内存量”——单条查询可能包含多个哈希、排序操作,若启用并行工作进程,内存占用会快速倍增。

理论上,2MB 的 work_mem 乘以多个操作和并行进程,确实可能导致内存溢出,但要达到 2TB 的量级,显然存在其他未被忽视的因素。这意味着,除了操作数量和并行进程,还有其他机制在影响内存占用。

底层原因:PostgreSQL 内存释放的设计逻辑

核心结论:PostgreSQL 并未忽略work_mem的限制,只是该参数无法控制所有内存场景——work_mem仅限制单个哈希或排序操作的内存分配,但内存的释放时机并非在操作执行过程中,而是在整个操作完成后。

这是 PostgreSQL 内存上下文系统的设计初衷:与传统的 malloc/free 方式相比,内存上下文的优势在于可一次性释放整个上下文的所有内存,无需跟踪单个内存块的分配与释放,效率更高且更可靠。PostgreSQL 源码的 README中明确说明:

“内存上下文相较于直接使用 malloc/free 的主要优势在于,可轻松释放整个内存上下文的内容,无需逐一释放其中的每个内存块。”

具体到本次问题:ExecutorState是查询执行开始时创建的内存上下文,执行器所需的所有资源均存储在此上下文内,仅在查询完全结束后才会被销毁;HashTableContext作为ExecutorState的子上下文,用于存储哈希表的所有数据(桶、条目等),其生命周期与哈希连接的整个过程一致,同样在操作结束后一次性释放。

罪魁祸首:524059 个内存块的累积效应

回到日志中发现的 524059 个内存块,其根源在于一条不合理的查询语句:该查询调用了一个 PL/pgSQL 函数,函数内部执行了copy操作,随后将结果与其他表进行关联。从语法上看,该查询完全符合 PostgreSQL 规范,但存在典型的 SQL 结构设计误区——将函数当作普通表用于关联操作,这是一种常见的代码拆分错误。

从 PostgreSQL 的视角来看,这是一个单一的大型操作:仅创建一个 ExecutorState 上下文,且该上下文从查询开始到结束始终存在。每一个内存块(共 524059 个)都会占用不超过work_mem的内存,而所有这些内存块都存储在同一个 ExecutorState 上下文中,在整个操作完成前不会被释放。最终,该操作因内存耗尽无法完成,OOM killer 被迫介入,导致生产集群宕机。

补充说明:向 PostgreSQL 社区咨询后得知,目前暂无开发者能完全掌握 PostgreSQL 内存行为的所有细节,源码中的内存上下文README是最具权威性的参考文档,若需深入研究,建议仔细阅读。

规避方案:4 个可落地的优化措施

需要明确的是,PostgreSQL 无法为单个后端进程设置硬性内存上限,不存在相关配置参数,数据库会根据需求分配内存。但可通过以下 4 种方式规避此类内存溢出问题:

  1. 优化统计信息:若查询规划器低估了行计数,会做出错误的磁盘溢出决策。需定期执行ANALYZE命令,检查pg_statspg_statistic视图;若存在列值相关的情况(这种情况本身违反高范式,属于建模问题),可使用CREATE STATISTICS捕获相关性;使用ALTER TABLE ... ALTER COLUMN ... SET STATISTICS提高列统计目标,仅适用于列值相互独立的场景。
  2. 优化查询语句:占用 2TB 内存的查询本质上是不合理的,但实际场景中可能会继承此类问题查询。此时需重构查询逻辑,避免将函数当作普通表用于关联,减少单一操作的内存占用。
  3. 设置查询超时:无法限制内存占用,但可通过statement_timeout参数终止运行时间过长的查询,避免内存持续累积导致宕机。
  4. 利用pg_log_backend_memory_contexts监控:掌握该函数的用法后,可在内存出现异常时及时调用,在 OOM killer 触发前定位内存占用异常的根源,提前排查问题。

核心启示:硬件无法弥补查询设计缺陷

本次事故的核心启示的是:高性能硬件无法弥补查询设计的缺陷。编写该问题函数的开发者,大概率未预料到其会导致如此严重的内存溢出,而这也是多数开发者的共性问题——对 PostgreSQL 内存管