PostgreSQL 内存调优实战(第 12 篇):work_mem 只调大 64 倍,峰值为什么远不止 64 倍

排序落盘后,团队把 work_mem 从 4MB 全局调到 256MB。单条报表快了,BI 高峰数据库却被 OOM Killer 终止。直接答案是:work_mem 约束的是一次 Sort、Hash 等操作的基础预算,不是一条查询、更不是整个实例的内存上限;多个操作、并行参与者与并发查询会叠加,Hash 还会乘 hash_mem_multiplier

work_mem 是许多 Sort、Hash 等操作各自的基础预算。多个节点、Hash 倍数、并行参与者和并发连接会继续相乘;允许受控落盘通常比全局追求零临时文件更安全。

先用压力模型代替推荐值

text 复制代码
单个查询的执行内存压力
≈ Σ(同时活跃 Sort 的各自预算)
 + Σ(同时活跃 Hash 的 work_mem × hash_mem_multiplier)
再按实际并行参与者及其执行节点放大

实例峰值
≈ 上述查询内存
 × 同时执行的重查询数
 + shared_buffers
 + backend/连接基础内存
 + maintenance/autovacuum/扩展等其他内存

这不是上界公式:节点生命周期未必重叠,executor 还有不完全受 work_mem 约束的结构,实际结构也可能提前 spill,leader 与 worker 分工随计划变化。它的用途是识别风险因子,不能拿来承诺"最多占多少"。它足以否定 max_connections × work_mem 或"每条查询最多 256MB"这两种简单口径。

PostgreSQL 文档明确指出,一个复杂查询可能有多个 Sort/Hash 操作,并发会话可能同时使用各自预算。Hash 操作的上限还会乘 hash_mem_multiplier

源码证明:Sort 与 Hash 拿的是不同操作预算

PostgreSQL 18.6 中,两条关键源码路径把配置映射成 executor 状态:

  • src/backend/utils/sort/tuplesort.ctuplesort_begin_common()一次排序 创建独立的 TupleSort mainTupleSort sort MemoryContext,并把该调用收到的 workMem 换算后写入 allowedMem。排序数据超过预算后,tuplesort 转为临时 runs 和外部归并;这对应 EXPLAIN 的 Sort MethodMemoryDisk 与 temp blocks。
  • src/backend/executor/nodeHash.cget_hash_memory_limit() 直接计算 work_mem × hash_mem_multiplier × 1024。这对应 Hash 节点的 Memory UsageBatchesDisk Usage;增大预算可能减少 batch,但预算是每个相关 Hash 操作的,不是整条计划共享一个固定块。
text 复制代码
SET LOCAL work_mem
  ├─ Sort → tuplesort_begin_common(workMem) → allowedMem → 内存排序或临时 runs
  └─ Hash → get_hash_memory_limit() → work_mem × hash_mem_multiplier → 内存表或 batches

EXPLAIN (ANALYZE, BUFFERS)
  └─ Sort Memory/Disk、Hash Memory/Batches/Disk、temp read/write

SQL 是 PostgreSQL executor 的正式入口,本文要证明的是服务端算子预算与实例内存放大,不存在能比 SQL + EXPLAIN 更直接的 Java 入口,因此不添加与结论无关的 JDBC 包装代码。

一个查询同时触发 HashAggregate 与 Sort

在资源受控的测试库运行,数据规模按机器调整:

sql 复制代码
DROP TABLE IF EXISTS mem_demo;

CREATE TABLE mem_demo AS
SELECT
    g AS id,
    md5(g::text) AS k,
    g % 100000 AS group_id,
    random() AS v
FROM generate_series(1, 3000000) AS g;

ANALYZE mem_demo;

低内存实验使用事务级设置,避免污染连接后续请求:

sql 复制代码
BEGIN;
SET LOCAL work_mem = '4MB';
SET LOCAL max_parallel_workers_per_gather = 0;

EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT group_id, count(*) AS cnt, avg(v)
FROM mem_demo
GROUP BY group_id
ORDER BY cnt DESC, group_id;
ROLLBACK;

记录执行计划中的:

  • Sort Method、Memory、Disk;
  • HashAggregate 的 Batches、Memory Usage、Disk Usage;
  • temp read/write;
  • 总执行时间与 shared buffers。

再提高当前事务预算:

sql 复制代码
BEGIN;
SET LOCAL work_mem = '256MB';
SET LOCAL max_parallel_workers_per_gather = 0;

EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT group_id, count(*) AS cnt, avg(v)
FROM mem_demo
GROUP BY group_id
ORDER BY cnt DESC, group_id;
ROLLBACK;

高预算可能减少 Hash batches 和 Sort 落盘,单查询更快;但它只证明这条查询在单并发、禁用并行时受益,不能支持全局修改。数据是否已缓存、实际 group 数和 planner 选择都会影响结果。

同一条计划为什么能消费多份预算

一个执行计划可能同时或交叠存在:

  • 多个 Sort,例如窗口函数、Merge Join 与最终 ORDER BY;
  • Hash Join 的 build hash table;
  • HashAggregate;
  • Memoize、Materialize、去重等其他状态;
  • 子查询与 CTE 内各自的操作;
  • 并行 worker 中重复执行的节点。

work_mem 不是预先为每个连接一次性保留的固定块;这解释了空闲连接不等于都占满 work_mem。但高并发计划同时达到预算时,总量会急剧上升。

Hash 为什么还要乘一次

sql 复制代码
SHOW work_mem;
SHOW hash_mem_multiplier;

Hash-based 操作可使用 work_mem × hash_mem_multiplier。如果 work_mem = 256MB 且 multiplier 为 2,一个 Hash 节点的基准上限思路已接近 512MB;同一查询再有第二个 Hash 与 Sort,就不是"256MB 查询"。

增大 multiplier 能减少 batch,也会放大峰值。它适合在确定 Hash spill 是主要瓶颈且总量可控时局部验证,不应与 work_mem 同时盲目上调。

并行查询如何继续放大

sql 复制代码
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT group_id, count(*)
FROM mem_demo
GROUP BY group_id;

查看 Workers PlannedWorkers Launched。并行查询使用的资源可能远多于非并行计划,因为每个 worker 是独立进程,某些操作在多个参与者中各自分配内存;leader 也可能参与执行。

Workers Planned = 4 不保证实际启动 4 个,实际资源竞争会影响 launched 数。容量压测要以 Workers Launched、真实并发和进程 RSS 为证据,不能只把配置上限代入公式后声称精确。

官方文档给出的容量含义更直接:并行查询中的 work_mem 等资源限制通常逐 worker 生效,4 个 worker 加 leader 的资源消耗可能接近非并行查询的 5 倍,但并非每个节点都会在每个参与者中同时达到预算。并行维护命令的 maintenance_work_mem 又是整条命令共享,不能把查询规则机械套到 CREATE INDEXVACUUM

把单查询收益升级为并发证据

单会话 EXPLAIN 只能证明一次执行。要评估全局修改,需在有明确内存限制的隔离实例中增加并发,先从较小预算开始。示例 work_mem-case.sql

sql 复制代码
BEGIN;
SET LOCAL work_mem = '32MB';
SET LOCAL max_parallel_workers_per_gather = 0;

SELECT count(*)
FROM (
    SELECT group_id, count(*) AS cnt, avg(v)
    FROM mem_demo
    GROUP BY group_id
    ORDER BY cnt DESC, group_id
) AS report_result;

ROLLBACK;
bash 复制代码
pgbench -n -c 4 -j 4 -T 60 -f work_mem-case.sql target_database

每一级并发同时采集数据库/容器 RSS、swap/OOM、临时文件增量、吞吐和 p95/p99;若内存逼近隔离环境上限或开始 swap,立即停止,不继续把 -c 放大。该命令仅用于有资源上限的隔离压测,禁止直接指向生产库。

为什么"临时文件为零"不是优化目标

sql 复制代码
SELECT
    datname,
    temp_files,
    pg_size_pretty(temp_bytes) AS temp_bytes
FROM pg_stat_database
ORDER BY temp_bytes DESC;

该视图是数据库级累计统计,不能直接归因到某条 SQL,且统计可能有刷新延迟或重置。可结合 log_temp_filespg_stat_statements 和 EXPLAIN 定位具体工作负载。

临时文件是受控退化路径。一个每天运行一次的报表 spill 10GB,可能比 100 个并发报表各自多拿数百 MB 更安全。真正目标是业务吞吐、尾延迟、内存水位与临时盘容量共同满足,而不是把 temp_bytes 清零。

还要区分"慢但可完成"和"内存把实例杀掉":前者影响单任务,后者会让所有连接中断并触发恢复。

用 MemoryContext 证据补足进程 RSS

操作系统 RSS 能证明进程当前占用,但不能告诉你是哪一个 PostgreSQL 执行上下文在增长。PostgreSQL 18 可请求目标 backend 把 MemoryContext 树写入服务端日志:

sql 复制代码
SELECT pg_log_backend_memory_contexts(<target_pid>);

<target_pid> 应来自已经确认的测试会话或问题查询 PID,不能照抄占位符。该函数会向目标 backend 发信号,默认需要较高权限;输出进入服务端日志而不是 SQL 结果,因此还要控制日志访问和敏感信息范围。

建议在隔离压测中按时间线采样:

text 复制代码
T0:查询启动前记录实例/容器内存与 PID
T1:Hash/Sort 进入高水位时请求 MemoryContext 日志
T2:查询结束后再次记录 RSS、temp I/O 与上下文释放

MemoryContext 日志可以定位 executor、tuple sort、hash 等上下文的分配方向,但它是一个时点快照;不能直接相加为整条查询历史峰值,也不能替代操作系统和容器总量监控。

先修计划,再给内存

Hash/Sort 过大有时是基数估算错误造成的。推荐顺序:

  1. 找第一处 estimated/actual rows 分叉,修统计、过滤和 Join 顺序问题。
  2. 减少无用返回列和中间行,尽早过滤。
  3. 验证索引是否能提供过滤或排序,避免本不必要的全量 Sort。
  4. 对确定受益的角色、事务或作业局部提高 work_mem
  5. 同时限制 BI 并发与并行度,再做混合负载压测。

若第一处 estimated/actual rows 已明显分叉,先按第 9 篇:SQL 和索引没变,计划为什么突然慢一百倍修正统计与计划;给错误计划更多内存,只会让错误路径跑得更昂贵。

局部示例:

sql 复制代码
BEGIN;
SET LOCAL work_mem = '64MB';
SET LOCAL hash_mem_multiplier = 1.5;
-- 已验证且受并发控制的报表查询
COMMIT;

若经连接池执行,必须保证事务边界可靠;使用 session 级 SET 后忘记 RESET,可能把高预算泄漏给后续用户。

不能漏算的其他内存池

实例容量还包括:

  • shared_buffers
  • 每个 backend 的基础状态、缓存和查询上下文;
  • maintenance_work_mem 对 CREATE INDEX、VACUUM 等维护操作;
  • autovacuum worker 的维护内存边界;
  • WAL、锁表、连接认证、扩展与语言运行时;
  • 操作系统页缓存及其他进程。

因此"物理内存减 shared_buffers,剩余全分给 work_mem"没有为峰值抖动、内核和维护任务留下安全余量。容器环境还要以 cgroup limit 而非宿主机总内存为边界。

生产灰度与停止条件

变更前记录:查询 p50/p95/p99、规划和执行时间、temp bytes、进程/容器 RSS、swap、OOM 事件、并发数、workers、业务吞吐。

灰度单位优先是单个报表角色或作业队列,而非全实例。先限制并发为 1---2,逐级增加;每级覆盖完整报表周期。验收要求报表 SLA 改善,同时数据库内存高水位、OLTP 延迟和临时盘都在预算内。

出现以下任一信号立即停止扩大:

  • 可用内存逼近预设安全线或开始持续 swap;
  • postmaster/backend 被 OOM killer 处理;
  • OLTP p99 或错误率超过阈值;
  • 临时盘仍增长而内存也显著升高;
  • 并发稍增就出现非线性抖动。

恢复原 work_mem 可以限制后续分配,但不能撤销当前已运行查询占用,也不能消除 OOM 后恢复影响。必要时先停止新报表进入,再等待或按业务可重试性取消明确的重查询。

证据边界

证据 能证明 不能证明
高 work_mem 单查询更快 该查询减少 spill 后受益 全局高并发安全
temp bytes 很高 数据库产生大量临时 I/O 唯一 SQL 和根因
Sort Memory 为 200MB 该 Sort 节点使用量 整条查询总内存
workers 为 4 计划/执行使用并行参与者 每个 worker 都吃满同样内存
OOM 后调回配置 后续预算恢复 当前查询和实例影响已回滚

面试表达主线

work_mem 是 Sort/Hash 等操作级预算,一条查询可有多个操作,Hash 再乘 hash_mem_multiplier,并行 worker 与并发连接继续放大。调优要看 EXPLAIN 的 Memory、Disk、Batches 与 temp I/O,优先局部设置和并发治理;可控 spill 通常比全局大内存安全。

实验清理

sql 复制代码
DROP TABLE IF EXISTS mem_demo;

官方资料

相关推荐
云登指纹浏览器1 小时前
RPA 与指纹浏览器集成:多账号自动化运营的5项环境配置
运维·自动化·rpa
wtblszn1 小时前
城市内河在线监测系统方案
运维·物联网
数字生命卡兹克2 小时前
GPT-6 Astra全面解析 - “欢迎来到AGI时代。”
经验分享
姜太小白2 小时前
【Oracle】记排查sh脚本调用SQL执行卡顿的排查总结
数据库·sql·oracle
bgy66662 小时前
MariaDB 数据库 基础详解
数据库·mariadb
Le_ee2 小时前
科来流量分析/结合MCP自动化
运维·自动化
自动化监测Learner2 小时前
QGIS 把geojson数据导入 PostgreSQL/PostGIS 的完整流程
数据库·postgresql
发光的沙子2 小时前
嵌入式系统架构师----Ubuntu如何安装不在列表内的软件?
linux·运维·ubuntu
虎虎(_ _)。゜zzZ2 小时前
Qdrant向量数据库工程实战
数据库·人工智能·大模型·向量数据库·rag·qdrant