work_mem: 这是一个陷阱!

2 阅读7分钟

work_mem: 这是一个陷阱!

摘要: 本文探讨了一个 PostgreSQL 内存问题,尽管 work_mem 仅设置为 2 MB,但某个查询却消耗了 2 TB 的 RAM。根本原因在于 PostgreSQL 的内存上下文系统,该系统只在查询执行结束时释放内存,而不是在执行过程中释放,导致所有 work_mem 分配都在单个上下文内累积,直到执行完成。

原文链接


我的朋友 Henrietta Dombrovskaya 在 Telegram 上联系了我。她的生产集群刚刚被 OOM killer 杀死,消耗了 2 TB 的 RAM。work_mem 设置为 2 MB。

有些不对劲。

Hetty 和我一样喜欢玩弄巨型硬件。2 TB 的 RAM 在她的世界里并不罕见。但在高峰时段因单个查询导致整个集群崩溃,与凌晨 3 点的故障是完全不同的问题。当 OOM killer 在最糟糕的时刻来袭时,你需要快速找到答案。

一个重要的细节:下面展示的内存日志并非来自生产事故。Hetty 在另一台服务器上重现了这一行为来调查。那一次她在 OOM killer 发威之前停止了查询。生产集群就没那么幸运了。

我想直接指出:这类问题靠一个好的网络关系比靠好的搜索引擎解决得更快。Hetty 是一位出色的 Postgres 专家。我们一起研究了这个问题的成因。我把它写下来是因为你也会遇到,而且 Postgres 内存管理的行为确实令人惊讶。

拯救危机的工具

在我们深入探讨"为什么"之前,让我向你介绍一个在这次对话之前我并不知道存在的函数:pg_log_backend_memory_contexts

传入一个 PID。Postgres 会将该后端的完整内存上下文树转储到日志中。每个分配、每个上下文、包括大小和块数量。

select pg_log_backend_memory_contexts(299392);

然后查看你的日志。

我有点尴尬之前不知道这个函数。二十年使用 Postgres,我每周都能学到新东西。这就是我喜欢这个数据库的原因。

(该函数在 Postgres 14 中引入。如果你想了解更多,请参阅 Luka 的博客文章。)

我们看到的内容

以下是日志输出中的相关行:

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

ExecutorState 约 235 MB,HashTableContext 约 340 MB。work_mem 为 2 MB。

该后端的总计:

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

在重现服务器上,单个后端约 557 MB。生产集群有 2 TB 的 RAM,OOM killer 不得不介入。

ExecutorState 中的 524,059 个块立即引起了我们的注意。

但是,每个 DBA 都知道 work_mem

任何经验丰富的 DBA 都知道 work_mem 不是"每个查询的内存"。正如 Postgres 文档 所述,work_mem 是每个哈希或排序操作可以使用的内存量。一个查询可以有很多这样的操作。加上并行 worker,数量会快速增加。

所以 2 MB 乘以很多哈希、很多排序、很多 worker,已经可以让你陷入麻烦。但 2 TB?那是一个完全不同的数量级。即使有惊人的并行 worker 数量和操作数量,数学也不应该得出这个结果。

一定有其他原因。

为什么 PostgreSQL 在这里忽略了 work_mem?

简短回答:它没有忽略。它只是不能控制一切。

work_mem 限制了每个哈希或排序操作分配的内存。但真正的罪魁祸首是:内存只在整个操作结束时释放,而不是在执行过程中释放。

这是设计如此。Postgres 内存上下文系统的设计理念是,一次性释放整个上下文比跟踪单个分配更快、更可靠。来源于源代码 README

内存上下文相对于直接使用 malloc/free 的主要优势在于,可以轻松释放整个内存上下文的内容,而无需请求释放其中的每个块。

ExecutorState 是查询开始时创建的内存上下文。执行器需要的所有内容都放在其中。它在查询完成时销毁,而不是之前。HashTableContextExecutorState 的子级。它保存哈希表数据:桶、条目,所有这些。哈希连接的整个生命周期都存活。在结束时一次性释放。

524,059 个块是罪魁祸首

回到那些块。查询是一个 select,调用了一个 plpgsql 函数,该函数内部执行了一个 copy 操作,然后与另一个表连接结果。完全有效的 Postgres。但用 Hetty 的话来说:"能做某事并不意味着你应该做。"开发者已经学会了很好地使用函数。但随后他们做了一个非常面向对象的事情:他们在连接中把那个函数当作另一个表来使用。一个典型的重构错误。Hetty 实际上将在 Nordic PGDay 2026 上讨论这种 SQL 结构化陷阱。

从 Postgres 的角度看,这是一个巨大的操作。一个从开始到结束的单一 ExecutorState 上下文。

这 524,059 个块中的每一个都使用了最多 work_mem 大小的内存。而且因为它们都生活在同一个 ExecutorState 上下文中,直到整个操作完成之前,它们都不会被释放。操作从未完成。OOM killer 确保了这一点。

这就是解释 2 TB 的组合:不仅仅是哈希和排序的数量,而是每个 work_mem 块都累积在一个上下文内,而这个上下文在完成之前不会释放任何东西。

诚实的脚注:当我们询问社区时,答案大致是:没有人完全理解 Postgres 内存行为的方方面面。源代码中的内存上下文 README 是最接近权威文档的。如果你想要深入了解,值得一读。

你能防止这种情况吗?

你无法对每个后端的 Postgres 内存设置硬上限。没有这样的设置。Postgres 会获取它需要的内存。

你能做的是:

  • 修复你的统计数据。如果计划器低估了行数,它会在何时溢出到磁盘方面做出糟糕的决定。运行 ANALYZE,检查 pg_statspg_statistic。如果你有具有相关值的列(这违反了更高范式,本身是一种建模问题),使用 CREATE STATISTICS 来捕获相关性。使用 ALTER TABLE ... ALTER COLUMN ... SET STATISTICS 增加每列目标只在列值相互独立时有所帮助。
  • 修复查询。消耗 2 TB RAM 的查询是一个糟糕的查询。就是这样。但有时你继承的是糟糕的查询。在这种情况下……
  • 使用查询超时。你无法限制内存,但可以使用 statement_timeout 终止运行时间过长的查询。
  • 使用 pg_log_backend_memory_contexts 监控。既然你知道它的存在,就使用它。当某些东西看起来不对时,调用该函数。你会在 OOM 杀死你的服务器之前知道发生了什么。

没有任何硬件可以弥补错误的查询

这是 Hetty 在我们对话结束时说的,她说得有道理。公平地说,写那个函数的人可能没有预料到这种行为。大多数开发者都不会。

根本原因是一个写得糟糕的查询消耗了远多于预期的内存。Postgres 在执行结束时才释放内存的底层行为是设计如此,而不是 bug。

理解 为什么 Postgres 这样做并不能修复糟糕的查询。但它确实可以帮助你向应用团队解释,为什么他们的"简单 select"在高峰时段导致了生产故障。

有时这就足以让查询被重写。