分享一个pg_stat_statements内存泄露案例,尽快升级版本吧。
一个idle的进程占用内存80G,并且在持续缓慢上涨,疑似内存泄露。
ps -eo pid,user,rss,vsz,etime,cmd --sort=-rss | grep postgres | head -3
PID USER RSS VSZ ELAPSED CMD
2886325 postgres 80.4G 85.2G 10-17:53 postgres: postgres postgres 127.0.0.1(35458) idle
9112 postgres 4.8G 8.1G 10-22:01 postgres: checkpointer
9113 postgres 2.1G 6.0G 10-22:01 postgres: background writer
关键观察:
• PID 2886325 一个 backend 独占 80.4 GB,是其它进程的 16 倍以上;
• ELAPSED 字段显示该进程已存活 10 天 17 小时,是典型的常驻长连接;
• STATE 为 idle,说明问题不在执行计划,泄漏发生在某种非事务路径上;
• VSZ 与 RSS 几乎贴合,意味着泄漏的内存被全部 touch 过、是 dirty 物理页,而不是一次性 malloc 大段未使用虚地址。
继续拿出三板斧:pmap、MemoryContext、gdb/bpftrace
0000000001b26000 84259500 84257380 84257380 rw--- [ anon ]
从结果看80G都是匿名内存。
pmap -xx 2886325
2886325: postgres: postgres postgres 127.0.0.1(35458) idle
Address Kbytes RSS Dirty Mode Mapping
0000000000400000 9404 7032 0 r-x-- postgres
0000000000f2e000 4 4 4 r---- postgres
0000000000f2f000 112 112 100 rw--- postgres
0000000000f4b000 220 64 64 rw--- [ anon ]
0000000001aaf000 476 336 336 rw--- [ anon ]
0000000001b26000 84259500 84257380 84257380 rw--- [ anon ]
00007f1ad8a94000 1012396 1012396 1012396 rw--- [ anon ]
...省略...
SELECT pg_log_backend_memory_contexts(2886325); 拿到的日志开头如下:
level: 0; TopMemoryContext: 525136 total in 11 blocks; 9736 free (17 chunks); 515400 used
level: 1; CacheMemoryContext: 1048576 total in 8 blocks; 385776 free (1 chunks); 662800 used
level: 2; relation rules: 32768 total in 7 blocks; 2216 free (1 chunks); 30552 used: pg_stat_activity
level: 2; relation rules: 16384 total in 5 blocks; 1968 free (2 chunks); 14416 used: pg_stat_statements
... (244 个 context, 全部 index info / Cache / Portal / Timezones)
Grand total: 2406776 bytes in 244 blocks; 652168 free (233 chunks); 1754608 used
MemoryContext合计仅2.3MB,那么看起来这80GB都走的malloc申请的内存。
怀疑是内存泄露,大概率是代码执行中的malloc和free不匹配。
让AI写了一个bpftrace脚本,来统计malloc和free情况,脚本如下:
cat trace_leak.bt
#!/usr/bin/env bpftrace
/*
* malloc/free 配对追踪器
*
*
* 1. 直接输出"未释放分配"(leaked),无需人工对比 malloc vs free
* 2. 增加实时进度提示(每次 malloc 打印 size)
* 3. 用 @leaked_cnt / @leaked_bytes 直接统计泄漏量
* 4. 保留原始 malloc/free 总量供参考
*
* 用法: sudo bpftrace -p <BACKEND_PID> trace_leak.bt
*/
BEGIN {
printf("=== malloc/free 泄漏追踪 (优化版) ===\n");
printf("目标 PID: %d, 阈值: >= 64KB\n", pid);
printf("Ctrl-C 结束后直接输出泄漏调用栈.\n\n");
}
/* ===== malloc 入口:只暂存 size(不抓栈) ===== */
uprobe:/usr/lib64/libc.so.6:malloc
/arg0 >= 65536/
{
@pending_sz[tid] = arg0;
}
/* ===== malloc 返回:在返回点抓栈(此时已退出 malloc 内部,栈更完整)===== */
uretprobe:/usr/lib64/libc.so.6:malloc
/@pending_sz[tid]/
{
/* 关键:在 uretprobe 中抓栈,而非 uprobe 中暂存
* 原因:malloc 内部(glibc)没有 frame pointer,uprobe 入口抓栈会丢帧
* uretprobe 时已返回到调用者(qtext_load_file),frame pointer 链完整 */
$stk = ustack(perf, 10);
/* 用指针做 key,记录 size 和调用栈 */
@alloc_sz[retval] = @pending_sz[tid];
@alloc_stk[retval] = $stk;
/* 统计总 malloc */
@total_malloc_cnt[$stk] = count();
@total_malloc_bytes[$stk] = sum(@pending_sz[tid]);
/* 实时打印(可注释掉减少输出) */
printf("[MALLOC] size=%lu ptr=%p\n", @pending_sz[tid], retval);
delete(@pending_sz[tid]);
}
/* ===== free:配对成功则从记录中移除 ===== */
uprobe:/usr/lib64/libc.so.6:free
/@alloc_sz[arg0]/
{
/* 统计总 free */
@total_free_cnt[@alloc_stk[arg0]] = count();
@total_free_bytes[@alloc_stk[arg0]] = sum(@alloc_sz[arg0]);
printf("[FREE] size=%lu ptr=%p\n", @alloc_sz[arg0], arg0);
/* 配对成功,从未释放记录中移除 */
delete(@alloc_sz[arg0]);
delete(@alloc_stk[arg0]);
}
/* ===== 结束时输出报告 ===== */
END {
printf("\n");
printf("╔══════════════════════════════════════════════╗\n");
printf("║ 内存泄漏分析报告 ║\n");
printf("╚══════════════════════════════════════════════╝\n");
/*
* 核心:遍历 @alloc_sz 中仍然存在的 key
* 这些就是"malloc 了但从未 free"的指针 = 泄漏
*/
printf("\n===== 1. 未释放的分配(泄漏) =====\n");
printf("以下指针 malloc 后从未被 free:\n\n");
print(@alloc_sz);
printf("\n===== 2. 未释放分配的调用栈 =====\n");
printf("泄漏指针对应的 malloc 调用栈:\n\n");
print(@alloc_stk);
printf("\n===== 3. malloc 总计(按调用栈聚合)=====\n");
print(@total_malloc_cnt);
print(@total_malloc_bytes);
printf("\n===== 4. free 总计(按调用栈聚合)=====\n");
print(@total_free_cnt);
print(@total_free_bytes);
printf("\n===== 判定方法 =====\n");
printf("• 第1节中仍存在的 ptr → 泄漏\n");
printf("• 第2节中对应的调用栈 → 泄漏位置\n");
printf("• 第3节 malloc_cnt - 第4节 free_cnt = 泄漏次数\n");
printf("════════════════════════════════════════════════\n");
clear(@alloc_sz);
clear(@alloc_stk);
clear(@pending_sz);
clear(@total_malloc_cnt);
clear(@total_malloc_bytes);
clear(@total_free_cnt);
clear(@total_free_bytes);
}
执行sudo bpftrace -p 2886325 trace_leak.bt
sudo bpftrace -p 2886325 trace_leak.bt
Attaching 5 probes...
=== malloc/free 泄漏追踪 (优化版) ===
目标 PID: 2888525, 阈值: >= 64KB
Ctrl-C 结束后直接输出泄漏调用栈.
...省略...
===== 2. 未释放分配的调用栈 =====
泄漏指针对应的 malloc 调用栈:
@alloc_stk[300048528]:
7f353370b0b7 qtext_load_file+302 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
7f3533709bb0 pg_stat_statements_internal+1283 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
7f353370951b pg_stat_statements_1_12+52 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
6b84c8 ExecMakeTableFunctionResult+742 (/data/postgres/postgres-REL_18_3/bin/postgres)
6d1d4e FunctionNext+178 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9c1a ExecScanFetch+611 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9c81 ExecScanExtended+101 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9ded ExecScan+104 (/data/postgres/postgres-REL_18_3/bin/postgres)
6d20df ExecFunctionScan+42 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b5b21 ExecProcNodeFirst+77 (/data/postgres/postgres-REL_18_3/bin/postgres)
@alloc_stk[300211360]:
7f353370b0b7 qtext_load_file+302 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
7f3533709bb0 pg_stat_statements_internal+1283 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
7f353370951b pg_stat_statements_1_12+52 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
6b84c8 ExecMakeTableFunctionResult+742 (/data/postgres/postgres-REL_18_3/bin/postgres)
6d1d4e FunctionNext+178 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9c1a ExecScanFetch+611 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9c81 ExecScanExtended+101 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9ded ExecScan+104 (/data/postgres/postgres-REL_18_3/bin/postgres)
6d20df ExecFunctionScan+42 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b5b21 ExecProcNodeFirst+77 (/data/postgres/postgres-REL_18_3/bin/postgres)
@alloc_stk[300374192]:
7f353370b0b7 qtext_load_file+302 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
7f3533709bb0 pg_stat_statements_internal+1283 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
7f353370951b pg_stat_statements_1_12+52 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
6b84c8 ExecMakeTableFunctionResult+742 (/data/postgres/postgres-REL_18_3/bin/postgres)
6d1d4e FunctionNext+178 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9c1a ExecScanFetch+611 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9c81 ExecScanExtended+101 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9ded ExecScan+104 (/data/postgres/postgres-REL_18_3/bin/postgres)
6d20df ExecFunctionScan+42 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b5b21 ExecProcNodeFirst+77 (/data/postgres/postgres-REL_18_3/bin/postgres)
@alloc_stk[300537024]:
7f353370b0b7 qtext_load_file+302 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
7f3533709bb0 pg_stat_statements_internal+1283 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
7f353370951b pg_stat_statements_1_12+52 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
6b84c8 ExecMakeTableFunctionResult+742 (/data/postgres/postgres-REL_18_3/bin/postgres)
6d1d4e FunctionNext+178 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9c1a ExecScanFetch+611 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9c81 ExecScanExtended+101 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9ded ExecScan+104 (/data/postgres/postgres-REL_18_3/bin/postgres)
6d20df ExecFunctionScan+42 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b5b21 ExecProcNodeFirst+77 (/data/postgres/postgres-REL_18_3/bin/postgres)
@alloc_stk[300699856]:
7f353370b0b7 qtext_load_file+302 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
7f3533709bb0 pg_stat_statements_internal+1283 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
7f353370951b pg_stat_statements_1_12+52 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
6b84c8 ExecMakeTableFunctionResult+742 (/data/postgres/postgres-REL_18_3/bin/postgres)
6d1d4e FunctionNext+178 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9c1a ExecScanFetch+611 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9c81 ExecScanExtended+101 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9ded ExecScan+104 (/data/postgres/postgres-REL_18_3/bin/postgres)
6d20df ExecFunctionScan+42 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b5b21 ExecProcNodeFirst+77 (/data/postgres/postgres-REL_18_3/bin/postgres)
可以看到malloc但未free的栈为:
7f353370b0b7 qtext_load_file+302 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
7f3533709bb0 pg_stat_statements_internal+1283 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
7f353370951b pg_stat_statements_1_12+52 (/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so)
6b84c8 ExecMakeTableFunctionResult+742 (/data/postgres/postgres-REL_18_3/bin/postgres)
6d1d4e FunctionNext+178 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9c1a ExecScanFetch+611 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9c81 ExecScanExtended+101 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b9ded ExecScan+104 (/data/postgres/postgres-REL_18_3/bin/postgres)
6d20df ExecFunctionScan+42 (/data/postgres/postgres-REL_18_3/bin/postgres)
6b5b21 ExecProcNodeFirst+77 (/data/postgres/postgres-REL_18_3/bin/postgres)
从栈可以看出来是pg_stat_statements的问题,疑点在qtext_load_file+302这个偏移量,可以使用addr2line转换为源码行号。
PGSS_SO=/data/postgres/postgres-REL_18_3/lib/pg_stat_statements.so
# qtext_load_file 的起始地址
FUNC_OFF=$(nm $PGSS_SO | awk '/ qtext_load_file$/{print $1}')
echo "qtext_load_file 起始地址: 0x$FUNC_OFF"
# +302 = +0x12E
TARGET=$(printf "0x%x" $((0x$FUNC_OFF + 302)))
echo "目标地址: $TARGET"
# 翻译成源码行
addr2line -e $PGSS_SO -f -C $TARGET
qtext_load_file 起始地址: 0x0000000000004f89
目标地址: 0x50b7
qtext_load_file
/data/postgres/postgres-REL_18_3/contrib/pg_stat_statements/pg_stat_statements.c:2338
定位到/data/postgres/postgres-REL_18_3/contrib/pg_stat_statements/pg_stat_statements.c:2338为函数qtext_load_file:
static char *
qtext_load_file(Size *buffer_size)
{
char *buf;
int fd;
struct stat stat;
fd = OpenTransientFile(PGSS_TEXT_FILE, O_RDONLY | PG_BINARY);
if (fd < 0)
{
if (errno != ENOENT)
ereport(LOG, ...);
return NULL;
}
if (fstat(fd, &stat))
{
ereport(LOG, ...);
CloseTransientFile(fd);
return NULL;
}
/* ★ 关键点 1:用 malloc 分配 buffer */
if (stat.st_size <= MaxAllocHugeSize)
buf = (char *) malloc(stat.st_size); // ←2338行,泄漏源头
else
buf = NULL;
if (buf == NULL)
{
ereport(LOG, ...);
CloseTransientFile(fd);
return NULL;
}
/* 读取文件到 buf */
errno = 0;
if (read(fd, buf, stat.st_size) != stat.st_size)
{
if (errno)
ereport(LOG, ...);
free(buf); // ← 只有读取失败时有 free
CloseTransientFile(fd);
return NULL;
}
CloseTransientFile(fd);
*buffer_size = stat.st_size;
return buf; // ★ 正常路径:返回 buf 给调用者负责释放
}
再看调用着pg_stat_statements_internal:
static void
pg_stat_statements_internal(FunctionCallInfo fcinfo, ...)
{
char *qbuffer = NULL;
Size qbuffer_size = 0;
...
if (showtext)
{
if (n_writers == 0)
qbuffer = qtext_load_file(&qbuffer_size); // ★ 第一次调用
}
LWLockAcquire(pgss->lock, LW_SHARED);
if (showtext)
{
if (qbuffer == NULL || pgss->extent != extent || pgss->gc_count != gc_count)
{
if (qbuffer)
free(qbuffer);
qbuffer = qtext_load_file(&qbuffer_size); // ★ 第二次调用(重新加载)
}
}
/* 遍历哈希表 ... */
while ((entry = hash_seq_search(&hash_seq)) != NULL)
{
if (showtext)
{
char *qstr = qtext_fetch(entry->query_offset,
entry->query_len,
qbuffer,
qbuffer_size);
if (qstr)
{
char *enc;
enc = pg_any_to_server(qstr, // ★ 关键点 2:编码转换
entry->query_len,
entry->encoding);
values[i++] = CStringGetTextDatum(enc); // ★ 关键点 3
if (enc != qstr)
pfree(enc);
}
}
...
tuplestore_putvalues(tupstore, tupdesc, values, nulls);
}
LWLockRelease(pgss->lock);
if (qbuffer)
free(qbuffer); // ★ 正常路径:函数末尾释放 qbuffer
tuplestore_donestoring(tupstore);
}
从bpftrace结果看,这里肯定没有进入正常free路径。
同时发现进程一直在报错:
ERROR: invalid byte sequence for encoding "UTF8": 0x00
因此泄露的路径为:
qtext_load_file() 用 malloc 分配 buf,读取文件内容(含 NUL 字节)
↓
返回 buf 指针赋给 qbuffer
↓
进入 while 循环遍历哈希表
↓
qtext_fetch() 从 qbuffer 中提取某条 query text(qstr)
↓
pg_any_to_server(qstr, entry->query_len, entry->encoding)
↓
内部调用 pg_verify_mbstr_len() 校验编码合法性
↓
遇到 0x00 字节 → 触发 ereport(ERROR):
"invalid byte sequence for encoding "UTF8": 0x00"
↓
ereport(ERROR) 内部调用 longjmp() → 直接跳回错误处理器
↓
★★★ 关键:longjmp 跳过了函数末尾的 free(qbuffer) ★★★
↓
qbuffer 指向的 malloc 内存永远无法被释放 → 泄漏!
这很明确是一个BUG,并且我的dba agent给出了BUG详情:
Avoid memory leak on error while parsing pg_stat_statements dump file
By using palloc() instead of raw malloc().
Reported-by: Gaurav Singh gaurav.singh@yugabyte.comReviewed-by: Lukas Fittl lukas@fittl.comReviewed-by: Daniel Gustafsson daniel@yesql.seDiscussion: https://www.postgresql.org/message-id/CAEcQ1bYR9s4eQLFDjzzJHU8fj-MTbmRpW-9J-r2gsCn+HEsynw@mail.gmail.com Backpatch-through: 14
项 | 值 |
|---|---|
Commit Hash | 3c74cb5762db6373187c3cf83f1b955e88773e33 |
标题 | Avoid memory leak on error while parsing pg_stat_statements dump file |
作者 | Heikki Linnakangasheikki.linnakangas@iki.fi |
日期 | 2026-03-27 |
Reporter | Gaurav Singh (Yugabyte) |
Reviewers | Lukas Fittl, Daniel Gustafsson |
回合分支 | Backpatch-through: 14(即 14/15/16/17/18/master 都已修复) |
讨论邮件 | https://www.postgresql.org/message-id/CAEcQ1bYR9s4eQLFDjzzJHU8fj-MTbmRpW-9J-r2gsCn+HEsynw@mail.gmail.com |
Web 链接 | https://github.com/postgres/postgres/commit/3c74cb5762db |
patch Diff 完整内容(共 6 处修改、净 +8 / -7) 修改点 1:pgss_shmem_shutdown 正常路径(行 805)
diff
- free(qbuffer);
+ pfree(qbuffer);
qbuffer = NULL;
修改点 2:pgss_shmem_shutdown 错误路径 error: label(行 829)
diff
- free(qbuffer);
+ if (qbuffer)
+ pfree(qbuffer);
if (file)
FreeFile(file);
修改点 3:pg_stat_statements_internal 重读文件路径(行 1825)
diff
- free(qbuffer);
+ if (qbuffer)
+ pfree(qbuffer);
qbuffer = qtext_load_file(&qbuffer_size);
修改点 4:pg_stat_statements_internal 函数末尾正常清理(行 2046)⭐ 本次内存泄漏的关键路径
diff
LWLockRelease(pgss->lock);
- free(qbuffer);
+ if (qbuffer)
+ pfree(qbuffer);
关键原理:把 qbuffer 从 malloc 切到 palloc_extended(见修改点 5),buffer 直接挂在 CurrentMemoryContext 上。一旦 pg_any_to_server() 在循环内 throw ERROR 22P05,AbortTransaction → MemoryContextDelete 会自动回收 qbuffer,不再依赖被 longjmp 跳过的 free(qbuffer)——根因彻底消除。
修改点 5:qtext_load_file 改 malloc 为 palloc_extended(行 2333、2378) diff
/*
- * Read the external query text file into a malloc'd buffer.
+ * Read the external query text file into a palloc'd buffer.
*
* Returns NULL (without throwing an error) if unable to read, eg
* file not there or insufficient memory.
@@ qtext_load_file ...
/* Allocate buffer; beware that off_t might be wider than size_t */
if (stat.st_size <= MaxAllocHugeSize)
- buf = (char *) malloc(stat.st_size);
+ buf = (char *) palloc_extended(stat.st_size, MCXT_ALLOC_HUGE | MCXT_ALLOC_NO_OOM);
else
buf = NULL;
用 MCXT_ALLOC_HUGE | MCXT_ALLOC_NO_OOM 完全保留原 malloc 的语义:允许 >1GB 的超大分配 + OOM 时返回 NULL 而非 throw。
修改点 6:qtext_load_file 内部 read 失败路径(行 2417)
diff
ereport(LOG, ...);
- free(buf);
+ pfree(buf);
CloseTransientFile(fd);
return NULL;
修改点 7:gc_qtexts 正常路径(行 2625)
diff
- free(qbuffer);
+ pfree(qbuffer);
修改点 8:gc_qtexts 失败 label gc_fail:(行 2642) diff
- free(qbuffer);
+ if (qbuffer)
+ pfree(qbuffer);
赶紧升级吧!!!
大版本 | ❌未修复(最后一个含 bug 的版本) | ✅已修复(包含此修复的最早小版本) |
|---|---|---|
14.x | 14.22 及之前所有版本 | 14.23 |
15.x | 15.17 及之前所有版本 | 15.18 |
16.x | 16.13 及之前所有版本 | 16.14 |
17.x | 17.9 及之前所有版本 | 17.10 |
18.x | 18.3 及之前所有版本 | 18.4 |
13.x 及更早 | 全部版本 | ❌永不会修复(13 已 EOL,且 backpatch 只到 14) |
本文分享自 PostgreSQL运维之道 微信公众号,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文参与 腾讯云自媒体同步曝光计划 ,欢迎热爱写作的你一起参与!