work_mem: 这是一个陷阱!

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 会将该后端的完整内存上下文树转储到日志中。每个分配、每个上下文、包括大小和块数量。

sql 复制代码
select pg_log_backend_memory_contexts(299392);

然后查看你的日志。

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

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

我们看到的内容

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

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

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

该后端的总计:

arduino 复制代码
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"在高峰时段导致了生产故障。

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

相关推荐
程序员爱钓鱼33 分钟前
为什么学习 Rust?Rust 能做什么?
后端·rust
啵啵鱼爱吃小猫咪35 分钟前
Franka机械臂安装实时内核
数据库·postgresql
掘金者阿豪1 小时前
Docker命令太多记不住?用Portainer把容器管理变成可视化操作
后端
用户69371750013842 小时前
AI Agent 里的 Loop 到底是什么?
android·前端·后端
Rain的Java大神之路2 小时前
高并发下的抢优惠券如何设计
后端
钟智强2 小时前
AWS CloudGoat 实战:一个 SSRF 如何撬动整个云账户
后端
Ai拆代码的曹操2 小时前
@Transactional 写在 private 方法上,事务根本没生效
后端
云技纵横2 小时前
@Async 默认执行器又炸了:SimpleAsyncTaskExecutor 为什么不是线程池?
后端·面试
程序员Agions2 小时前
Fypro 新手快速上手与实战指南
后端
舒心的云朵2 小时前
用三张图片详解Asp.Net 全生命周期
后端·asp.net