很多人对 PostgreSQL 的 work_mem 其实一直有个误解。你是不是也曾经被这几个问题困扰过:
work_mem 是不是一申请就占满?
- 排序、Hash Join、Hash Agg 用掉的内存,是不是要等到事务结束才释放?
- 如果 SQL 很慢,是不是这些内存会一直挂着不放?
- spill 到磁盘,是不是说明 PostgreSQL 内存管理不行?
如果你也曾点头,那么这篇文章想讲清楚一件事:PostgreSQL 里和 work_mem 相关的内存,绝大多数情况下并不会等到事务结束才释放。很多内存其实会在 SQL 执行过程中就被提前回收。 而且,排序、Hash Join、Hash Aggregate 这三类算子的释放方式还不完全一样。这也是为什么很多人明明配了很大的 work_mem,却仍然搞不清楚 PostgreSQL 的真实内存峰值到底从哪来。
先说结论: work_mem 不是“申请了就一直占着不放”
一句话先说透:work_mem 不是预分配内存,而是排序、哈希等执行操作的一个软预算。 也就是说:
- 不是一上来就把这块内存全申请出来
- 不需要就不会真的分配那么多
- 超了也不一定报错,很多时候会转而 spill 到磁盘
- 更关键的是,这些内存并不会一直撑到事务结束
更准确的规律其实是:
- 有些内存会在执行过程中就提前释放
- 有些会在执行节点结束时释放
- 最晚通常在 SQL 语句结束时,由 executor 统一回收
- 一般不会等到事务结束,更不会等到连接断开
如果你以前理解成“work_mem 申请后会一直保留到事务提交”,那这个认识要更新了。
为什么很多人会误解 work_mem
因为从参数名字看,work_mem 很容易让人误以为:“这是 PostgreSQL 给一个 SQL 分配的一块工作内存。”但实际上,它更接近于:SQL 执行过程中,某些需要排序或哈希工作区的操作可使用的内存预算上限。 注意这里有两个关键词:某些执行操作、预算上限。这意味着什么?意味着一个 SQL 里如果有多个 Sort、多个 Hash Join、多个 Hash Agg,它们可能分别使用自己的预算。如果再叠加并行执行,实际内存消耗还会进一步放大。所以你看到 work_mem = 64MB,并不等于“这个 SQL 最多只用 64MB”。这也是 PostgreSQL 内存调优里最容易踩坑的地方之一。另外,Hash 类操作通常还会乘上 hash_mem_multiplier,所以它们的可用预算往往比 Sort 更大。
Sort: 排序不是最后才释放, 但也不是所有内存都会立刻释放
先看排序。排序主要由 tuplesort.c 管理。它会创建几层 memory context,用来保存排序过程中的元数据、tuple 数据、merge 状态等。但真正重要的不是“创建了几个 context”,而是:排序过程中确实存在不少提前释放动作,但不是所有排序相关内存都会立刻释放。
比如:
- 一批内存中的 tuples 排完并写入临时文件后,会 reset 这批 tuple 对应的工作内存
- 进入 merge 阶段时,会释放不再需要的数组和缓存
- merge 过程中,某个输入 tape 用尽后,会立即关闭,释放读缓冲
- 如果是 bounded sort,某些注定进不了最终结果的 tuple,会当场释放
也就是说,排序并不是“先攒一堆,最后统一释放”。它更像是:一边处理,一边把已经没用的那部分工作内存尽快回收。 但这里有个容易忽略的细节:Sort 的释放并不总是“立刻彻底释放”。tuplesort 内部有 maincontext 和 sortcontext 之分。执行过程中经常被 reset 的,主要是 sortcontext 以及它下面的 tuple 工作内存;而 maincontext 里保存的 Tuplesortstate 本体、排序键元数据等,可以跨多个 batch 复用。
这在 Incremental Sort 里尤其明显。增量排序会反复调用 tuplesort_reset() 来清理当前 batch 的排序工作区,但 maincontext 仍然保留,以便下一批继续复用。真正彻底释放,要等到节点结束时调用 tuplesort_end()。所以对于 Sort 来说,更准确的说法应该是:大量排序工作内存会在执行过程中提前回收,但部分排序元数据和状态可能会保留到节点结束。
Hash Join: 不是逐条释放, 而是按 batch 成批回收
再看 Hash Join。Hash Join 的思路和 Sort 不太一样,它不是那种“经常细粒度释放”的风格,而更偏向于:按 batch 批量处理,按 batch 批量释放。 它内部大致会有几类内存:整个 join 生命周期都要用的、当前 batch 才需要的、spill 到磁盘时使用的缓冲。
Hash Join 最重要的释放动作是:每处理完一个 batch,就 reset 这个 batch 的内存 context。 这意味着:
- 这一批 tuple 的内存会被一次性回收
- 不需要逐条
pfree
- 回收成本更低,也更符合哈希构建阶段的使用模式
如果内存继续上涨怎么办?PostgreSQL 的策略不是“硬扛”,而是:增加 batch 数、把部分数据写到磁盘、降低当前内存压力。这就是为什么 Hash Join 在内存不够时,往往表现为“分批写盘”,而不是简单失控。另外,Hash Join 在某些路径下也会做更早的回收,比如:batch 切换时 reset 整个 batchCxt、skew bucket 占用过高时提前清理、batch 数增大时,把不属于当前 batch 的 tuple 迁移到磁盘并释放对应内存块。换句话说,Hash Join 的 内存管理 核心不是“精细到每条 tuple 都单独释放”,而是:用 batch、spill 和批量 reset,把整体内存控制在一个可收敛的范围内。
Hash Agg: 回收很积极, 但不要简单理解成“每个 group 完成就立刻释放”
如果说 Sort 是“边做边释放”,Hash Join 是“按 batch 释放”,那么 Hash Agg 可以说是:释放粒度很多、回收动作很积极的一类。 但这里有一个很重要的纠偏点:不能简单说成 Hash Aggregate 每完成一个 group,就会把这个 group 的聚合状态立刻释放。 更准确地说,聚合相关清理有几种不同粒度:
- 每处理一条输入 tuple,会 reset
tmpcontext,释放这条 tuple 的临时表达式计算内存
- 对于 sorted/plain aggregation,在 group 边界会对相应 context 做
ReScanExprContext(),释放已完成 group 的状态
- 对于 HashAgg,更关键的是在 spill batch 切换时重置哈希工作集,而不是“每输出一个 group 就立即释放整组状态”
- 节点结束时,
ExecEndAgg() 才会做最终清理
如果内存超限,Hash Agg 会进入 spill 模式:不再继续无限扩展内存哈希表,新 group 的 tuples 直接写入磁盘,后续再按 batch 读回处理。在处理每个 spilled batch 前,还会重置相关 hash 工作内存,让下一批重新获得预算空间。所以更准确的说法应该是:Hash Agg 的内存回收非常积极,但它的主要释放粒度是 per-tuple、spill batch 切换和节点结束,而不是简单概括成“每个 group 完成就立刻释放”。
真正应该怎么理解 work_mem
看到这里,其实就能把 work_mem 的概念纠正过来了。work_mem 不是:一个连接固定拥有的内存、一个 SQL 只能使用一次的内存、一块申请后直到事务结束才释放的内存。而更应该理解为:执行器里某些排序/哈希类操作可用的软预算,实际内存会随着执行过程动态分配、动态回收,超限后再通过 spill 转移到磁盘。 所以,调优 work_mem 这件事,重点从来都不只是“值设多少”。更重要的是理解:SQL 里到底有多少个会消耗 work_mem 的排序或哈希操作、是 Sort 多还是 Hash 多、有没有并行、会不会 spill、内存峰值到底是节点叠加造成的还是单个节点失控造成的。
为什么这个问题在生产上特别重要
因为现实里很多 PostgreSQL 的内存问题,并不是“不会释放”,而是“你低估了会同时申请预算的节点数量”。举个非常常见的误区:有人看到磁盘 spill,第一反应就是:“work_mem 太小了,调大。”但问题是,如果一条复杂 SQL 本来就有很多排序和哈希操作,你把 work_mem 从 64MB 调到 256MB,单个操作确实更不容易 spill 了,但整体内存峰值可能反而暴涨。结果就是:spill 变少了,但数据库整体内存压力更高了,并发一上来,系统反而更危险。所以 spill 不是绝对的坏事。很多时候,适度 spill 其实是 PostgreSQL 在“内存安全”和“执行效率”之间做的合理平衡。
小结
最后把这件事收成一句话:PostgreSQL 中 work_mem 相关内存,并不是等到事务结束才释放,而是大量内存在执行过程中就会按不同粒度被提前回收。 但也不要把这件事理解得过于绝对:
- Sort 会在 run dump、merge、tuple 淘汰、tape 用尽等阶段及时回收,但部分排序元数据和状态可能保留到节点结束,Incremental Sort 就是典型例子
- Hash Join 主要通过 batch reset、分批写盘、chunk 驱逐来控制内存
- Hash Agg 会在 tuple 级、spill batch 级和节点结束等多个层次释放和重建工作集,但不能简单理解成“每个 group 完成就立即释放整组状态”
所以,理解 PostgreSQL work_mem 的关键,不是只记住参数值,而是理解它背后的执行器行为。只有这样,你才能真正看懂:为什么有的 SQL 明明很慢,但内存并没有一直涨;为什么有的 SQL 看起来不复杂,却能吃掉很多内存;为什么 spill 不一定是坏事;为什么 work_mem 不能简单粗暴地无限调大。
如果你之前也一直把 work_mem 理解成“事务级保留内存”,那现在可以把这个旧印象彻底改掉了。你可以在 云栈社区 与更多开发者一起探讨 PostgreSQL 底层调优的实战心得。
参考
本文基于 PostgreSQL 源码阅读与 DeepWiki 辅助分析整理。参考链接: https://deepwiki.com/search/postgresql-workmem-sql_281d8b2c-c9af-4594-b743-6ff484c6bbab