每个用 PostgreSQL 的人都会开 pg_stat_statements。它便宜、即时、不用装额外组件——查一下就知道哪个查询最慢、哪个调用最多。但用久了你会发现一些诡异的事:昨天还在列表里的关键查询今天消失了;p99 突然飙升但平均值纹丝不动;一个跑了 30 秒然后超时崩溃的查询,在视图里根本找不到踪迹。
这些不是 bug,而是 pg_stat_statements 作为"一堆聚合计数器"的固有边界。上一篇文章讲了它有数据但会扭曲——queryid 碎片化、首见文本冻结、平均值埋没 p99。这篇讲剩下的部分:它悄悄扔掉的东西、瞬间消失的查询文本、永远不记录的计划和副本、以及那些会咬人的配置开关。
表满了,尾部被静默驱逐
pg_stat_statements.max 默认 5000。这是一个硬上限,在共享内存中预分配哈希表,改它需要重启。当第 5001 个不同查询形状出现时,Postgres 不扩表,而是驱逐最少执行的条目:
If more distinct statements than that are observed, information about the least-executed statements is discarded.
对于只有几百个稳定查询形状的健康应用,5000 绰绰有余。但回想上一篇的行爆炸问题:ORM 把一个逻辑查询碎成几百个形状,或者 PG 18 之前的动态 IN 列表,每小时能产生几千个条目。一旦开始驱逐,视图变成了一个滑动的窗口——你的稳定重要查询被赶出去给一次性查询腾地方,下次再跑时重新建条目、重置计数器、冻结新的首见文本。你信赖的统计数据自己重置了,视图一声不吭。
唯一会说话的地方是伴生视图 pg_stat_statements_info:
SELECT dealloc, stats_reset FROM pg_stat_statements_info;
dealloc | stats_reset
---------+-------------------------------
18342 | 2026-05-01 03:00:11.482+00
dealloc:因为超出max而驱逐条目的总次数。当它在两次重置之间持续攀升,说明你的表太小了,工作负载产生的形状太多。你在颠簸(thrashing),统计数据是有损的。stats_reset:整个视图最后一次全局重置的时间。它不是装饰——它是你计算每个 diff 的有效性印章。如果你每小时快照一次视图来算增量,stats_reset告诉你某个窗口是否合法:如果它在两次快照之间前移了,说明窗口内发生了一次重置,跨重置的所有 delta 都是无意义的。
几乎没有任何监控设置会检查这两个字段。一个颠簸的表悄悄丢掉它本该报告的工作负载的一部分,直到数字不再对得上才有人发现。
修复方向:要么调大 pg_stat_statements.max(付出更多共享内存),要么从源头降低形状基数——规范化查询形状、升级到 PG 18、改用数组绑定。
下面是一个可以直接跑的巡检脚本,每分钟检查一次颠簸状态:
#!/usr/bin/env bash
# pgss_thrash_check.sh — 检测 pg_stat_statements 是否在颠簸驱逐
PGHOST="${PGHOST:-localhost}"
PGPORT="${PGPORT:-5432}"
PGUSER="${PGUSER:-monitor}"
PGDATABASE="${PGDATABASE:-postgres}"
PREV_DEALLOC=""
while true; do
CURRENT=$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -t -A \
-c "SELECT dealloc FROM pg_stat_statements_info;")
if [ -n "$PREV_DEALLOC" ] && [ "$CURRENT" -gt "$PREV_DEALLOC" ]; then
DELTA=$((CURRENT - PREV_DEALLOC))
echo "[WARN] dealloc 增长了 ${DELTA},pg_stat_statements 可能正在颠簸驱逐,考虑调大 pg_stat_statements.max"
fi
PREV_DEALLOC="$CURRENT"
sleep 60
done
运行前确保 monitor 用户有 pg_read_all_stats 角色(后面会讲权限问题):
GRANT pg_read_all_stats TO monitor;
查询文本瞬间消失
比驱逐更糟的失败模式藏在外部查询文本文件里。
代表性文本堆积在磁盘上,文件可以变得很大(长查询、高 max、大量 churn)。扩展有一个最后手段:丢弃查询文本。此时 pg_stat_statements 视图中所有现有条目的 query 字段显示 NULL,但每个 queryid 关联的统计数据保留。
If this happens, consider reducing pg_stat_statements.max.
压力之下它保留了计数器,扔掉了文本。整个视图变成 query = NULL,数字继续走,你只剩一列匿名 queryid,不知道它们曾经是什么。
文档说得很模糊,但源码足够具体。两个阈值在起作用:
第一个是垃圾回收触发器。 条目被驱逐和重建时,旧文本在文件里留下死空间。扩展会在文件膨胀超过约 50% 时重写文件来清除死空间——具体是当磁盘上的文件增长超过 mean_query_len × pg_stat_statements.max × 2。默认 max 5000 加上平均 1 KB 的查询,大约在 10 MB 时触发重写。这是正常的内务处理,不是故障。
故障是重写本身失败的时候。 I/O 错误或者无法分配空间来构建替换文件。此时文件内容不可信,扩展把每个条目的文本标记为无效(内部 query_len = -1),整个 query 列读 NULL。计数器存活,文本一次性全部消失。
文件位于 $PGDATA/pg_stat_tmp/pgss_query_texts.stat(PG 14+;13 及更早可以通过 stats_temp_directory 重定位,该 GUC 在 14 中被移除)。
实操建议:对文件大小做告警,阈值设为 mean_query_len × max × 2。如果你经常接近那条线,你在不断重写文件,离空白视图只差一次坏的 write()。因为触发器随平均查询长度缩放,风险最大的集群是既有大量条目又有很长查询文本的——又是那个巨型生成 IN 列表的应用。
手册建议的修复——降低 pg_stat_statements.max——听起来反直觉但是对的:更小的表意味着更小的文件、更少的膨胀要收集、更少的出错机会。
# 计算你的告警阈值(示例:max=5000,平均查询长度 1KB)
PGSS_MAX=5000
MEAN_QUERY_LEN_KB=1
THRESHOLD_MB=$((PGSS_MAX * MEAN_QUERY_LEN_KB * 2 / 1024))
echo "告警阈值: ${THRESHOLD_MB} MB"
# 检查实际文件大小
DATA_DIR=$(psql -t -A -c "SHOW data_directory;")
FILE_SIZE=$(stat -c%s "${DATA_DIR}/pg_stat_tmp/pgss_query_texts.stat" 2>/dev/null || echo 0)
FILE_MB=$((FILE_SIZE / 1024 / 1024))
echo "当前文件大小: ${FILE_MB} MB"
if [ "$FILE_MB" -ge "$THRESHOLD_MB" ]; then
echo "[CRIT] 查询文本文件接近/超过重写阈值,存在文本批量消失风险!"
fi
它根本不记录的东西
前面讲的都是它记录了但降质的数据——归一化、冻结、平均、驱逐。这部分是缺席:pg_stat_statements 从未捕获的东西,因为它们在它建模的范围之外。
执行计划。这是最大的缺席。 扩展记录了一个查询跑了多久,不记录它怎么跑。没有计划树、没有每节点成本、没有 join 顺序、没有索引选择。一个查询的计划从 Index Scan 翻转到 Seq Scan——这是突然变慢最常见的原因——在视图里只表现为 mean_exec_time 上升。原因消失了。要计划,你需要 EXPLAIN、auto_explain 或计划捕获钩子。pg_stat_statements 坐在错误的层级,永远看不到计划。
实际行数 vs 估计行数。 没有计划就没有每节点的 actual_rows 来对比规划器的估计。估计误差——陈旧统计的领先信号、预测未来计划翻转的东西——在这里不可见。rows 列是所有调用的总返回行数,不是对照现实的估计测量。
参数值。 常量被混淆掉了。你无法恢复哪个 id 慢,只知道 WHERE id = $1 这个形状平均慢。
失败查询。这让人意外。 统计在成功阶段的末尾更新,只有那时。中途出错的查询不被计数。手册明确说了这种不对称:
plans and calls aren't always expected to match because planning and execution statistics are updated at their respective end phase, and only for successful operations.
跑了 30 秒然后死于 statement timeout 的查询——恰恰是你最想找到的——对 calls 或 total_exec_time 没有任何贡献。它规划了,所以如果 track_planning 开着可能会 bump plans,那是它留下的唯一痕迹。你最糟糕的查询恰恰因为是最糟糕的而不可见。也没有错误计数器——失败的语句不留自己的行,你无法问 pg_stat_statements 一个形状出错多少次,只能问它成功多少次。
** spills、并行 worker、每节点 buffer。** 排序和哈希 spill 到磁盘、workers_planned vs workers_launched、每个计划节点的 shared/local/temp buffer 分布——这些都活在 EXPLAIN (ANALYZE, BUFFERS) 输出里,不在聚合的 shared_blks_* 总数里。视图给你的是触碰块的总和,不能告诉你计划中哪里触碰了、排序是否 spill 了。
一个实用的补充方案是用 auto_explain 捕获慢查询的计划:
-- 在 postgresql.conf 或 ALTER SYSTEM 中配置
ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements,auto_explain';
ALTER SYSTEM SET auto_explain.log_min_duration = '500ms'; -- 只记录超过 500ms 的查询
ALTER SYSTEM SET auto_explain.log_analyze = true;
ALTER SYSTEM SET auto_explain.log_buffers = true;
ALTER SYSTEM SET auto_explain.log_format = 'json'; -- JSON 格式便于后续解析
SELECT pg_reload_conf();
这样 pg_stat_statements 告诉你哪个形状慢,auto_explain 在日志里留下那个形状的实际计划和 buffer 详情。两者互补,不是替代。
它只看一个节点
pg_stat_statements 是实例级的。哈希表在这个服务器的共享内存里,只统计这个服务器跑过的查询。它不知道其他节点存在。
在单主库时代这没问题。在现代部署里——主库加读副本、Aurora reader 集群、Patroni 集群、Citus——这是严重的盲点。读流量是大多数应用的主要工作,跑在副本上。每个副本有自己独立的 pg_stat_statements、自己的计数器、自己的驱逐、自己的首见文本。只快照主库(大多数监控设置的做法),你看到写路径加上碰巧落在主库的读请求,而读工作负载的主体未被测量。
合并节点也不干净。你想跨节点累加"同一个查询"的调用次数,但 queryid 是平台和版本敏感的——在混合舰队(中途升级、不同架构)上,同一个查询在不同节点可能携带不同 id。把集群工作负载合并到一个规范指纹下、带每节点下钻,是扩展之上的真正工程工作。扩展只给你一个节点的本地 tally。
一个简单的多节点快照方案:
#!/usr/bin/env bash
# pgss_multi_node_snapshot.sh — 从所有节点快照 pg_stat_statements
NODES="primary:5432 replica1:5433 replica2:5434"
PGUSER="monitor"
PGDATABASE="postgres"
SNAPSHOT_DIR="/var/lib/pgss_snapshots"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
for NODE_SPEC in $NODES; do
HOST=$(echo "$NODE_SPEC" | cut -d: -f1)
PORT=$(echo "$NODE_SPEC" | cut -d: -f2)
FILE="${SNAPSHOT_DIR}/${HOST}_${TIMESTAMP}.csv"
psql -h "$HOST" -p "$PORT" -U "$PGUSER" -d "$PGDATABASE" -c \
"COPY (SELECT queryid, query, calls, total_exec_time, mean_exec_time,
shared_blks_hit, shared_blks_read, stats_since
FROM pg_stat_statements
ORDER BY total_exec_time DESC) TO STDOUT WITH CSV HEADER" \
> "$FILE"
echo "[OK] ${HOST}:${PORT} 快照已保存到 ${FILE}"
done
这不会解决 queryid 跨节点不一致的问题,但至少让读流量不再完全隐形。
配置开关:默认值里藏的陷阱
| GUC | 默认值 | 要知道的事 |
|---|---|---|
pg_stat_statements.max |
5000 | 条目硬上限;改它要重启。盯 pg_stat_statements_info.dealloc。 |
pg_stat_statements.track |
top |
只追踪客户端发出的语句。PL/pgSQL 函数内部的查询不可见,除非设成 all。 |
pg_stat_statements.track_utility |
on |
计数非 DML(CREATE、VACUUM 等)。在 schema 频繁变动的系统上可能用一次性 DDL 洪泛表。 |
pg_stat_statements.track_planning |
off |
加规划时间统计,但手册警告有"明显的性能惩罚"。默认关闭是有原因的。 |
pg_stat_statements.save |
on |
干净重启时持久化统计。崩溃仍然丢失自上次保存以来的所有数据。 |
三个不容易从表格看出来的问题:
track = 'top' 隐藏函数内部。 如果你的热点逻辑在 PL/pgSQL 或存储过程里,默认只显示 SELECT do_the_thing() 包装器,内部跑了什么查询一无所知。你必须主动开 all 并承受额外条目。
track_utility 计数你的事务动词。 BEGIN、COMMIT、ROLLBACK 是 utility 语句,默认 track_utility = on 时每个都有自己的条目。它们各自归一化为一个形状,所以是几行而不是洪泛,但 ORM 在每个查询前后开关事务时,COMMIT 会变成调用次数最高的行之一——一个没有实质内容的动词坐在任何按调用次数排名的顶端,排在你真正想看的查询前面。如果你的按调用次数排名视图主要是事务控制,那就是关掉 track_utility 的信号。
I/O 时间需要第二个开关。 shared_blk_read_time 和 shared_blk_write_time 列只在服务器级 track_io_timing 开着时才填充,默认关闭。关着的话这些列读零,让每个查询看起来没做 I/O——一种安静误导性的空白。
-- 推荐的生产配置(根据工作负载调整 max)
ALTER SYSTEM SET pg_stat_statements.max = 10000; -- 需要重启
ALTER SYSTEM SET pg_stat_statements.track = 'all'; -- 捕获函数内部
ALTER SYSTEM SET pg_stat_statements.track_utility = off; -- 排除事务动词噪音
ALTER SYSTEM SET pg_stat_statements.track_planning = off; -- 保持关闭,避免争用
ALTER SYSTEM SET track_io_timing = on; -- 让 I/O 时间列有意义
-- 重启生效(max 改了必须重启)
-- SELECT pg_reload_conf(); 只能改不需要重启的参数
为什么 track_planning 特别伤
手册对 track_planning 的警告值得展开,因为明显的反驳是手册似乎跳过的那个:如果一个热查询被几千个后端执行了几十亿次都没问题,为什么规划同一个形状会是翻车的东西?
答案在计数器怎么写入。每个条目有自己的 spinlock(entry->mutex),更新条目要拿这个锁。更新在每个阶段的末尾发生一次。扩展在规划末尾写一次条目,在执行末尾再写一次,两个独立临界区。
track_planning关着:一个查询每次调用触碰其条目的 spinlock 一次,在执行末尾。track_planning开着:触碰同一把锁两次,规划末尾一次、执行末尾一次。
对于大量连接同时跑的形状,那个单条目的锁已经是系统中最热的,加倍抓锁大致加倍争用。不是规划本身有争用而执行免费——执行侧对那个每条目锁的争用本身就是超热相同查询的已知瓶颈。而是 track_planning 在每次调用上给已经最热的锁加第二次抓取,把额外流量堆到恰恰最争用的条目上(一个形状,到处跑)。这就是为什么一个听起来无害的"也记录规划时间"的设置默认关闭,以及为什么在任何由少数极热形状主导的工作负载上你应该让它关着。
PG 17 的列重命名
PostgreSQL 17 重命名了 I/O 时间列:blk_read_time → shared_blk_read_time,blk_write_time → shared_blk_write_time,新增了 local_blk_* 列。选旧名的查询升级后直接报错;SELECT * 的仪表盘静默偏移。
同一版本还加了 stats_since 和 minmax_stats_since 时间戳,以及 pg_stat_statements_reset() 的 minmax_only 参数。视图的形状不是跨大版本的稳定契约,所以固定你的列列表,每次升级重新检查。
如果你的监控查询还在用旧列名,升级前先改:
-- PG 17 之前的监控查询(升级后会报错)
SELECT queryid, calls, total_time, blk_read_time, blk_write_time
FROM pg_stat_statements ORDER BY total_time DESC LIMIT 20;
-- PG 17+ 兼容版本
SELECT queryid, calls, total_exec_time,
shared_blk_read_time, shared_blk_write_time,
local_blk_read_time, local_blk_write_time
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 20;
权限:为什么仪表盘看不到查询文本
统计对所有人可见,但其他用户查询的 SQL 文本和 queryid 不是:
For security reasons, only superusers and roles with privileges of the pg_read_all_stats role are allowed to see the SQL text and queryid of queries executed by other users.
低权限监控角色看到的行里 query 字段是 <insufficient privilege>。修复是授予 pg_read_all_stats——一个内置角色,不是 superuser:
GRANT pg_read_all_stats TO monitor;
如果你的"仪表盘不显示查询文本"之谜有一个无聊的原因,通常就是这个。
它到底擅长什么,又把你留在哪里
这些都不是反对 pg_stat_statements 的论据。它仍然是第一个该开的东西,对于它的真正工作——在一个节点上自上次重置以来按总时间、调用次数或 I/O 排名查询形状——没有别的东西这么便宜这么即时。每次先找它。
但要清楚你手里拿的是什么:
- 它计数,不记录。 累积总数,没有时间维度。想要历史,自己快照和 diff。
- 它给平均值,不给分布。 擅长"哪个形状主导总时间",对"我的 p99 是多少"无用。
- 它按形状碎片化。 ORM 把一个查询炸成多个条目,拼回来是上面归一化器的工作。
- 它冻结首见文本。 静态注释标签存活,动态每请求标签不行,所以它永远不能做 APM 的 join 目标。
- 它在压力下有损。 满表驱逐尾部。盯
dealloc。 - 它对计划盲。 告诉你查询慢,永远不告诉为什么。没有计划、没有估计误差、没有 spill。
- 它是单节点的。 服务大部分读流量的副本舰队从主库不可见。
pg_stat_statements 回答一个问题——什么慢了?——然后停下。接下来的问题在它的模型之外:为什么变慢(计划翻转、统计漂移)、谁在调用它、集群中哪里在发生、如果改了什么会怎样。这不是缺失的功能;这是一堆聚合计数器根本做不到的事。
每次执行的细节没有真正消失——它在日志里。log_min_duration_statement = 0 或 auto_explain 记录每次调用的参数、标签和计时,完整分布和动态标签住在那里。代价是成本:你不能在繁忙系统上记录每个查询,这就是廉价聚合存在的全部理由。
Postgres 没有自带你来找的 query store,pg_stat_statements 不是它。它是你建造 query store 的地板,不是那个东西本身。
所以别把 pg_stat_statements 读成你工作负载的画像。读它本来的样子:一个便宜、有损的计数器盒子,告诉你下一步该把真正的仪器指向哪里。