某生产集群的内存占用飙升到 2TB 后,最终被 OOM killer 无情终止。令人困惑的是,当时数据库的 work_mem 参数明明只设置了 2MB。这个看似矛盾的现象,其实揭示了 PostgreSQL 内存管理中一个容易被忽视的核心陷阱:即使配备了高性能硬件,一条在高峰期执行的查询,仍有可能拖垮整个集群。解决这类问题,往往需要深入理解数据库的底层机制,而非简单地依赖搜索引擎。
需要说明的是,下文展示的内存日志并非直接来自那次生产事故,而是在一台独立的服务器上复现问题时记录的(我们提前终止了查询,避免了 OOM 的发生)。但生产集群就没那么幸运了。本文将详细拆解这次问题的排查过程、根本原因,并提供切实可行的规避方案,希望能帮助开发者们避开同样的坑。
关键排查工具: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 内存释放的设计逻辑
核心结论是:PostgreSQL 并没有忽略 work_mem 的限制,只是这个参数无法控制所有内存场景。work_mem 仅限制单个哈希或排序操作的内存分配额度,但内存的释放时机,并非在该操作执行的过程中,而是在整个操作完成之后。
这是 PostgreSQL 内存上下文(Memory Context)系统的设计初衷。与传统的 malloc/free 方式相比,内存上下文的优势在于可以一次性释放整个上下文中的所有内存,无需跟踪每一个独立内存块的分配与释放,效率更高,也更可靠。PostgreSQL 源码中的 README 明确写道:
“与直接使用 malloc/free 相比,内存上下文的主要优势在于,你可以轻松释放整个内存上下文的内容,而不需要释放其中的每个内存块。”
具体到本次问题:
ExecutorState 是查询开始执行时创建的内存上下文,执行器所需的所有资源都存储在这个上下文里,它只有在查询完全结束后才会被销毁。
HashTableContext 作为 ExecutorState 的子上下文,用于存储哈希表的所有数据(桶、条目等),它的生命周期与哈希连接的整个过程一致,同样是在操作结束后一次性释放。
罪魁祸首:524059 个内存块的累积效应
回到日志中发现的 524059 个内存块。其根源在于一条设计不合理的查询语句:该查询调用了一个 PL/pgSQL 函数,函数内部执行了 copy 操作,随后将结果与其他表进行关联。从语法上看,这条查询完全合法,但它犯了一个典型的 SQL 结构设计错误——将函数当作普通表用于关联操作,这是一种常见的代码拆分误区。
从 PostgreSQL 的视角来看,这是一个单一的、庞大的操作:它只创建了一个 ExecutorState 上下文,并且这个上下文从查询开始到结束都一直存在。每一个内存块(总共 524059 个)占用的内存可能都不超过 work_mem 的限制,但所有这些内存块都累积在同一个 ExecutorState 上下文中,在整个操作完成前不会被释放。最终,这个操作因为内存耗尽而无法完成,OOM killer 被迫介入,导致生产集群宕机。
补充一点:在向 PostgreSQL 社区咨询后了解到,目前似乎没有开发者能完全掌握 PostgreSQL 所有内存行为的细节。源码中关于内存上下文的 README 是最具权威性的参考文档,如果想深入研究,建议仔细阅读。
规避方案:4 个可落地的优化措施
首先要明确的是,PostgreSQL 无法为单个后端进程设置一个硬性的内存使用上限,没有这样的配置参数,数据库会根据需求分配内存。但是,我们可以通过以下 4 种方式来规避此类内存溢出问题:
- 优化统计信息:如果查询规划器低估了行数,就可能做出错误的“溢出到磁盘”的决策。需要定期执行
ANALYZE 命令,并检查 pg_stats 和 pg_statistic 视图。如果存在列值相关的情况(这本身可能意味着建模违反了高范式),可以使用 CREATE STATISTICS 来捕获相关性;使用 ALTER TABLE ... ALTER COLUMN ... SET STATISTICS 提高列的统计目标,但这通常仅适用于列值相互独立的场景。
- 优化查询语句:一条会占用 2TB 内存的查询本质上就是不合理的,但在实际中,我们可能会继承这样的问题查询。这时就需要重构查询逻辑,避免将函数当作普通表进行关联,从而减少单个操作内的内存累积。
- 设置查询超时:虽然我们无法限制内存占用,但可以通过
statement_timeout 参数来终止运行时间过长的查询,防止内存持续累积最终导致宕机。
- 利用
pg_log_backend_memory_contexts 进行监控:掌握这个函数的用法后,可以在发现内存异常时及时调用它。在 OOM killer 触发之前,定位到内存占用的根源,提前介入排查。
核心启示:硬件无法弥补查询设计的缺陷
这次事故给我们最核心的启示是:高性能硬件无法弥补查询设计的缺陷。编写那个问题函数的开发者,大概率没有预料到它会导致如此严重的内存溢出,这也是许多开发者的共性问题——对 PostgreSQL 内存管理的设计逻辑缺乏深入了解。
问题的根源并非 PostgreSQL 的 bug,而是其内存上下文“一次性释放”的设计逻辑,与不合理的查询语句相结合后,导致内存持续累积直至耗尽。理解这一底层机制,虽然不能直接修复有问题的查询,但能帮助开发者向应用团队解释“为什么一条看似简单的查询会在高峰期搞垮生产集群”,从而推动查询语句的重构与优化。更多关于数据库性能调优的讨论,也欢迎到 云栈社区 与同行们交流。
原文链接:https://mydbanotebook.org/posts/work_mem-its-a-trap/
作者:Lætitia Avrot
引用链接
[1] PostgreSQL 官方文档: https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM
[2][3] PostgreSQL 源码的 README: https://github.com/postgres/postgres/blob/master/src/backend/utils/mmgr/README