ALTER FUNCTION SET work_mem:无需全局更改即可修复 PostgreSQL 磁盘溢出
D
Dinesh
2026 年 9 月 29 日
·
5 分钟阅读
一个订单 API 在旺季吞吐量降到一半,却没有部署可以归咎。原因是排序每天向磁盘溢出 150 GB。修复只需一行 SQL,作用域限定在一个函数上。
跟随部署而来的性能下降容易调查:你回滚并比较。没有部署作为背后原因的慢速则难得多,因为没有明显的变化可以指向。
这就是我们某个产品遇到的情况。在两天内,吞吐量(TPS)急剧下降,进入的订单降到通常量的大约一半。我们检查了每个代码仓库,发现前一周没有部署。当时还是节日季,我们一年中最忙的时候,所以我们需要在几小时内修复。
修复结果是一条语句:
sql
ALTER FUNCTION fetch_all_products() SET work_mem TO '128MB';
复制
本文涵盖我们是如何走到这一步的、为什么我们没有为整个服务器提高 work_mem,以及如何验证函数级设置是否在按预期工作。
找到慢路径#
端到端 API 验证显示有一个 API 的响应远超我们可接受的 KPI 限制。
我们的大部分产品数据由 CDN 提供。当 CDN 缓存被失效时,第一个请求会到数据库重建内容。延迟就来自那个请求。
它指向构建产品目录的数据库函数 fetch_all_products()。它返回按类别分组并按排名排序的产品。按排名排序是常规操作。被排序的数据则不是:
- 目录包含超过 10,000 个产品。
- 每个产品行约 160 KB,因为产品描述和详情以内联的 base64 编码字符串存储。
这是生成 CDN 内容的查询:
sql
WITH products AS (
SELECT p.*, -- 每个产品列
(SELECT row_to_json(d) ...) AS discount,
(SELECT array_agg(t.name) ...) AS tags,
(SELECT jsonb_agg(...) ...) AS timings
FROM product p
JOIN category c ON p.category_id = c.id
WHERE c.active AND p.active
)
SELECT c.name, c.tax_type, c.view_option, c.sort, c.img,
jsonb_agg(row_to_json(p) ORDER BY p.sort) AS products -- 瓶颈
FROM products p
JOIN category c ON p.category_id = c.id
GROUP BY 1, 2, 3, 4, 5;
复制
根本原因:一个溢出到磁盘的排序#
jsonb_agg 内部的 ORDER BY p.sort 看起来开销很小,因为它只按一个整数列排序。但 PostgreSQL 并不只排序键。它排序的是将要进入聚合的整行:每个产品列、base64 负载,以及计算出的 discount、tags 和 timings 值。
行这么宽,排序需要的内存远超默认的 work_mem(4 MB)。一旦排序超过 work_mem,PostgreSQL 就将其写入临时文件并在磁盘上继续。因此每次缓存失效都会引起一阵临时文件 I/O,推高了 API 延迟并拖慢了应用其余部分。
索引在这里帮不上忙。被排序的行是在查询期间构建的(来自 CTE、子查询和 row_to_json),而不是直接从表读取,因此没有索引可供计划器使用。这留下了两个选择:
- 重新设计 CDN 内容的生成方式。这是正确的长期修复,但在旺季中期太慢且风险太大。
- 通过提高 work_mem 给排序足够的内存以在 RAM 中完成。
为什么不全局提高 work_mem?#
work_mem 不是每连接限制。它是每个排序或哈希操作的限制,所以一个复杂查询可能使用它的好几倍,而每个并发连接都会得到同样的额度。全局提高得足够大以容纳这一个函数,可能会在峰值负载下把服务器推入内存压力,那只会用一个事故替换另一个事故。
我们只需要为一个函数提供额外内存。
修复:ALTER FUNCTION ... SET work_mem#
PostgreSQL 允许你将配置参数附加到函数上。该值在函数开始时生效,并在函数退出时恢复到之前的值。函数内的每个查询都使用新设置,而会话的其余部分以及每个其他会话都不受影响。
sql
ALTER FUNCTION fetch_all_products() SET work_mem TO '128MB';
复制
该设置存储在函数的目录条目中,因此你可以确认它已附加:
sql
SELECT proname, proconfig
FROM pg_proc
WHERE proname = 'fetch_all_products';
-- proname | proconfig
-- --------------------+------------------
-- fetch_all_products | {work_mem=128MB}
复制
证明设置保持在函数内部#
值得亲自看看作用域。这个小演示函数返回它运行时的 work_mem 值(在 PostgreSQL 17 上测试):
sql
CREATE FUNCTION show_work_mem() RETURNS text
LANGUAGE sql AS $$ SELECT current_setting('work_mem') $$;
ALTER FUNCTION show_work_mem() SET work_mem TO '128MB';
SELECT current_setting('work_mem') AS session_work_mem,
show_work_mem() AS inside_function;
-- session_work_mem | inside_function
-- ------------------+-----------------
-- 4MB | 128MB
复制
在同一条语句中,会话报告 4 MB,而函数报告 128 MB。函数一返回,会话就回到 4 MB。
以后要移除该设置:
sql
ALTER FUNCTION fetch_all_products() RESET work_mem;
复制
结果:每天 150 GB 降到零#
我们比较了更改前后 24 小时内该函数的临时文件写入:
| 阶段 | 函数 | 临时写入(24 小时) | 调用次数 |
|---|---|---|---|
| 之前 | fetch_all_products | 150 GB | 1,135 |
| 之后 | fetch_all_products | 0 GB | 1,135 |
每天写入的临时文件
150 GB → 0 GB 同样是 1,135 次调用
一条 ALTER FUNCTION 语句,没有部署,没有全局配置更改。
函数被调用的次数相同,磁盘溢出从每天 150 GB 降到没有。API 延迟回到 KPI 限制内,应用恢复正常,每秒订单量也恢复了。
如果一个函数占了临时文件量的大部分,ALTER FUNCTION ... SET work_mem 就在问题所在处修复它,而不会让服务器其余部分承担风险。