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

一次真实的 PostgreSQL OOM 事故

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

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

关键工具 pg_log_backend_memory_contexts 救场

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

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

执行语句如下:

csharp 复制代码
select pg_log_backend_memory_contexts(299392);

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

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

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

css 复制代码
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 内存。而该后端进程的内存总占用为:

arduino 复制代码
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 内存管

相关推荐
桑榆肖物2 小时前
有字幕,没配音?用浏览器自带语音能力,让网页视频直接“开口说话”
数据库·edge·音视频·tts
熬夜的咕噜猫2 小时前
MySQL主从复制与读写分离
网络·数据库·mysql
道清茗2 小时前
【MySQL知识点问答题】 备份技术、Invisible Indexes 和直方图的应用
数据库·mysql
芒果披萨2 小时前
sql存储过程
java·开发语言·数据库
jnrjian2 小时前
RAC 去除node的建议 dbca 和手动方法
数据库·oracle
TlYf NTLE3 小时前
redis分页查询
数据库·redis·缓存
翻斗包菜3 小时前
MySQL 全量、增量备份与恢复实战指南(含 mysqldump + binlog + XtraBackup)
数据库·oracle
|华|3 小时前
MySQL主从复制与读写分离
数据库·mysql
_下雨天.3 小时前
MySQL 全量、增量备份与恢复
数据库·mysql