pg_stat_statements 大概是 PostgreSQL 生态里最常用的扩展之一。它随 contrib 一起发布,开启成本几乎为零,大多数人的第一反应就是——"数据库到底在干什么?"打开它看一眼,心里就有底了。
但如果你是从其他数据库过来的,可能会期待它像一个真正的 Query Store:记录每条查询的执行历史、保留计划演进、能按时间窗口回溯。它不是。 一旦你深度依赖它,就会发现各种"不对劲":同一个逻辑查询散成几十行、监控数值和 mean_exec_time 对不上、查询突然消失、数字一夜之间变了……这些都不是 bug。它们全部源自一个事实:
pg_stat_statements 是一个固定大小的哈希表,里面装的是运行计数器,键是解析树的哈希值。它只计数,不记录。
把这句话刻在脑子里,下面所有"奇怪"的现象就不再奇怪了。
搭一个可复现的沙盒
所有现象都可以在一张空库里复现。先建表、灌数据:
CREATE TABLE customers (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE orders (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id integer NOT NULL REFERENCES customers(id),
amount numeric(10,2) NOT NULL,
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO customers (name)
SELECT 'Customer ' || i FROM generate_series(1, 2000) AS i;
INSERT INTO orders (customer_id, amount, status, created_at)
SELECT (random() * 1999 + 1)::int,
(random() * 500 + 5)::numeric(10,2),
(ARRAY['pending','shipped','delivered','cancelled'])[floor(random()*4+1)::int],
'2024-01-01'::date + (random() * 700)::int
FROM generate_series(1, 100000);
ANALYZE;
然后在 postgresql.conf 里加上:
shared_preload_libraries = 'pg_stat_statements'
重启后执行:
CREATE EXTENSION pg_stat_statements;
现在你有了一个干净的起点,下面所有实验都可以在这上面跑。
计数器,不是日志
执行 SELECT * FROM pg_stat_statements;,你会看到每个"查询形状"占一行,背后是一组累加计数器:calls、total_exec_time、rows、shared_blks_hit、shared_blks_read 等等。
关键在于——query 列保存的不是你的原始 SQL。常量被剥离了:WHERE id = 42 变成 WHERE id = $1。同一个形状无论执行多少次,只占一行。每次执行只是让计数器往上跳一格,那一次执行的耗时、计划、上下文,就此消失。
每行是一个运行总量,不是一条执行记录。本文几乎所有局限都源于此。
数据存放在启动时分配的共享内存里,是一个固定大小的哈希映射,上限由 pg_stat_statements.max 控制(默认 5000)。查询文本则落盘在 $PGDATA/pg_stat_tmp/pgss_query_texts.stat,视图读取时把磁盘文本和内存计数器做一次 join。
queryid:哈希,不是序列号
每条 entry 的键是 queryid——一个 64 位有符号整数。PostgreSQL 14 把计算逻辑收进了核心(compute_query_id = auto),扩展只是读取结果。
计算过程叫 query jumble:Postgres 解析并分析语句后,遍历解析树,把定义查询结构的部分哈希成一个数字,常量值排除在外。排除常量就是整个技巧——WHERE id = 42 和 WHERE id = 99 哈希到同一个值,常量显示为 $n 占位符,于是一个 entry 可以代表百万次只在参数值上不同的执行。
SELECT * FROM orders WHERE id = 42;
SELECT * FROM orders WHERE id = 99;
SELECT * FROM orders WHERE id = 12345;
SELECT queryid, query, calls
FROM pg_stat_statements
WHERE query LIKE 'SELECT * FROM orders WHERE id%';
结果:
queryid | query | calls
----------------------+------------------------------------+-------
-8198406948996196422 | SELECT * FROM orders WHERE id = $1 | 3
三次执行,一行。注意 queryid 是负数——它是 signed 64-bit,如果你的仪表盘把它当 unsigned 存或者当友好标识打印,就会出问题。把它当成一个可能为负的 opaque token 就好。
跨版本不稳定
手册写得很直白:不能假设 queryid 在大版本之间稳定。 哈希来自解析树的形状,不是查询文本。升级从 16 到 17,或者换到不同 CPU 架构,同样的 SQL 可能哈希到不同的值。跨机器按 queryid 汇聚成本的仪表盘,在集群不均匀的那一刻就悄悄失效了——要么重复计数,要么丢行。
逻辑复制下的 replica 也被手册专门点名:逻辑复制方案不保证 replica 在所有相关细节上与主库一致,所以 queryid 不能作为跨 replica 累加成本的标识。
唯一保证:同一服务器内跨小版本稳定。 除此之外,别当持久键用。
OID,不是名字
jumble 用的是对象 OID,不是名字。两个后果,都容易让人措手不及:
- drop + recreate = 新 queryid。 迁移、dump-restore、蓝绿切换时表被重建,OID 变了,所有针对该表的查询拿到新的
queryid。旧统计留在旧 id 下,看起来像"查询不再运行了"——其实只是 OID 换了。 - search_path 陷阱。
SELECT * FROM orders在不同连接里可能解析到tenant_a.orders和tenant_b.orders,schema-per-tenant 应用会把一个逻辑查询打散到每个 tenant 的 OID 上。想汇总"所有对 orders 的读取",你得自己解决 OID 映射。
别名也会泄露进去——SELECT c.id FROM customers c 和 SELECT id FROM customers 是两个不同形状,两个不同 queryid。
ORM 的行爆炸
jumble 归一化了常量,但没有归一化结构。两个你肉眼看来"一样的查询",只要解析树不同——列列表不同、表别名不同、多了个 LIMIT、AND 顺序不同——就是不同形状,不同 queryid,不同行。
ORM 恰恰是制造这些变体的主力。看这四条查询,都是"按 id 加载一个客户":
SELECT id, name FROM customers WHERE id = $1; -- queryid A
SELECT id, name, created_at FROM customers WHERE id = $1; -- queryid B(.select 多了一列)
SELECT c.id, c.name FROM customers c WHERE c.id = $1; -- queryid C(加了别名)
SELECT id, name FROM customers WHERE id = $1 LIMIT $2; -- queryid D(.first)
四行。calls 被拆成四份,每一行看起来都不多,但背后可能是你最热的路径。mean_exec_time 是按行算的,一个影响所有四个变体的性能退化被稀释了。
ORM 不会停在四个。每个条件 .where、每个可选 .select、每个 eager-load 都是一个新形状。真实生产里,一个逻辑查询可以散到几百个 entry 下,每个单独看都微不足道。
扩展没法修复这个,也不该修复——jumble 在"理解这些查询是同一个逻辑操作"之前就跑了。把变体聚类回逻辑查询,需要下游工具重新解析文本、归一化树(剥别名、排序 AND 谓词、识别列集合超集)、重新指纹。pg_stat_statements 给你的是原始碎片;拼装是它没有的一层。
工具如 qshape(boringSQL 栈的一部分)在做这件事。但有一个前提:被驱逐的 entry 已经不在视图里了,无法指纹。 先从源头控制基数——调大 pg_stat_statements.max、升级到 PG 18、改用数组绑定——否则你指纹的只是碰巧存活的那几行,不是真实负载。
IN 列表:PG 18 终于压平了,但数组绑定才是终极方案
IN 列表是历史上最严重的结构变体来源:
SELECT * FROM orders WHERE id IN (1, 2, 3);
SELECT * FROM orders WHERE id IN (1, 2, 3, 4);
SELECT * FROM orders WHERE id IN (1, 2, 3, 4, 5);
在 PostgreSQL 18 之前,每个长度是一个独立 entry,因为列表长度改变了解析树。动态构建 IN 子句的应用,一个查询形状就能烧掉几千个 entry,顺便把其他所有东西驱逐出去。
PG 18 终于把它们压平了。 上面三条现在合并成一行:
query | SELECT * FROM orders WHERE id IN ($1 /*, ... */)
calls | 3
无条件生效,没有 GUC 可以关闭。但有一个限制:只对内联常量生效。 JDBC 等驱动发送 IN ($1, $2, $3) 作为绑定参数,值从来不是解析树里的常量,没有东西可以压平。IN ($1,$2,$3) 和 IN ($1,$2,$3,$4) 仍然是独立 entry,每个长度一个新形状。
如果你用的是参数绑定驱动,不管 Postgres 什么版本,你都被卡住了。但有一个在所有版本和所有驱动上都有效的修复:
-- 不要这样:n 个参数,n 个不同形状
SELECT * FROM orders WHERE id IN ($1, $2, $3, ... $n);
-- 改成这样:一个参数,一个形状,永远
SELECT * FROM orders WHERE id = ANY($1);
= ANY(array) 接收单个数组参数,解析树无论数组有 3 个元素还是 3000 个都完全相同。一个 queryid,所有版本,所有驱动。大多数驱动和 ORM 直接支持数组绑定(setArray、ARRAY[...]、pgx 的 slice 绑定、ActiveRecord 的 where(id: array)),规划器也像 IN 列表一样用它做索引选择。
对 ORM 重度团队来说,这是保持 pg_stat_statements 可读的最高杠杆操作。
首次见到的文本被冻结
每个 entry 展示一条查询文本——是创建该 entry 的第一次执行的文本,不是最近的,不是最常见的。之后计数器不断累加,磁盘上的文本不再改变,直到 entry 被驱逐后重建。
常量被归一化为 $1,大多数时候没问题。问题出在查询携带注释时——现代应用大量使用 sqlcommenter 或 marginalia 约定:
SELECT id, name FROM customers WHERE id = $1
/*application='checkout',controller='orders',action='show'*/
注释不属于解析树,不影响 queryid,每次执行仍然映射到同一个 entry。但 entry 上的文本是第一次出现的那个。注释标签因此分成两类:
| 标签类别 | 例子 | pg_stat_statements 的行为 |
|---|---|---|
| 静态(每次调用值相同) | application, controller, action, job |
存活。 首次值具有代表性,可以抓取并信任 |
| 动态(每次请求值不同) | traceparent, request_id, trace_id, user_id |
丢失。 第一次请求的值被冻结,后续所有值被静默丢弃 |
第二类就是为什么 pg_stat_statements 永远不能作为 APM 或追踪工具的 join 目标。你没法问它"14:32 那次超时的 trace 对应哪些查询",因为它只保留了一个 traceparent——第一次的那个——其余全部扔了。计数器是总和,总和没有空间记住哪个请求贡献了什么。
如果你需要动态标签,需要一个逐执行捕获路径(auto_explain 或 hook-based harvester),而不是这个扩展。在 pg_stat_statements 文本里看到 traceparent 是一个警告信号,不是功能。
平均值在撒谎
这是坑人最多的局限,因为数字看起来太可信了。
没有时间维度
所有计数器自上次 reset 以来是累积的。扩展内部没有时间维度。视图就是一个运行总量的快照。
想知道"这周什么变慢了",直觉是看 mean_exec_time——这恰恰是错的。那个数字是自上次 reset 以来所有时间的平均值。在一个运行了几个月的集群上,它把冷缓存的周一早晨、夜间批处理、安静的周日搅成一个描述不了任何真实时刻的数字。
要得到"过去一小时"的数字,你得自己做账:
-- 1. 定时快照,存到表里
CREATE TABLE pgss_snapshot (
snap_time timestamptz NOT NULL DEFAULT now(),
queryid bigint NOT NULL,
calls bigint NOT NULL,
total_exec_time double precision NOT NULL
);
-- 每小时跑一次(cron 或 pg_cron)
INSERT INTO pgss_snapshot
SELECT now(), queryid, calls, total_exec_time
FROM pg_stat_statements;
-- 2. 计算差值
WITH recent AS (
SELECT * FROM pgss_snapshot WHERE snap_time > now() - interval '1 hour'
),
older AS (
SELECT * FROM pgss_snapshot WHERE snap_time BETWEEN now() - interval '2 hour' AND now() - interval '1 hour'
)
SELECT
r.queryid,
(r.total_exec_time - o.total_exec_time) / NULLIF(r.calls - o.calls, 0) AS hourly_mean_ms
FROM recent r
JOIN older o ON r.queryid = o.queryid;
但这个减法没那么安全。它默认"现在"的计数器从"那时"的计数器继续往上走。驱逐和 reset 会打破这个假设。可信的快照差值必须处理三种情况:
- Entry 被驱逐后重建。 查询掉出哈希表后再次运行,计数器从零开始。
total_exec_time_now < total_exec_time_then,减法给出负值。这不是"查询变快了",这是"这个窗口对该 queryid 无效"。 - 全局 reset。 有人跑了
pg_stat_statements_reset()。每行的"现在"都比"那时"小。检查pg_stat_statements_info.stats_reset是否比早期快照新——如果是,整个窗口跨了一次 reset,差值全是垃圾。 - queryid 只出现在一侧。 "那时"有但"现在"没有——被驱逐了,或者表被 drop+recreate(新 OID,新 id)。"现在"有但"那时"没有——新建或重建。用 full outer join,把不匹配的行当事件处理,不要当零去减。
PG 17 加了一个更干净的逐行守卫:每行带 stats_since,表示 entry 创建或重建的时刻。如果某行的 stats_since 比你的早期快照新,它的计数器在你窗口内 reset 过,丢弃该窗口。PG 17 之前,全局 stats_reset 是唯一的内置信号,逐行驱逐只能靠"计数器倒退"来发现。
平均值掩盖了分布
扩展对每个形状只维护四个数字:min、max、mean、stddev。每次执行用 Welford 在线算法折入运行均值和方差,然后丢弃该次计时。没有直方图,没有百分位数,没有逐调用记录可回溯。
一个 99% 时间跑 1ms、1% 时间跑 2 秒的查询,mean_exec_time 大约 21ms——一个不匹配任何真实执行的数字,同时藏住了正在 paging 你 on-call 的 p99。双峰延迟——真实性能问题的常见形状——就这样消失在一个平均数和标准差里。
扩展能告诉你一个查询平均慢,不能告诉你它有时灾难性地慢——后者通常是更危险的情况。想要百分位数或延迟时间线,这不是正确的工具,查询得更狠也没用。它在写行的时候就把分布丢了。
实用检查清单
把这些局限内化之后,pg_stat_statements 仍然是极有价值的工具——只要你知道它在说什么、不在说什么。以下是日常使用时的务实建议:
| 场景 | 正确做法 |
|---|---|
| ORM 应用查询散成几百行 | 改 = ANY($1) 数组绑定替代 IN 列表;控制 .select/.where 变体数量;调大 pg_stat_statements.max |
| 想看"过去一小时"的延迟 | 定时快照存表,做差值;差值前检查 stats_reset 和 stats_since,过滤掉无效窗口 |
| 想看 p99 或延迟分布 | 用 auto_explain(log_min_duration_statement + auto_explain.log_min_duration)或外部采集器,pgss 不提供百分位数 |
| 查询突然从仪表盘消失 | 检查是否发生了 drop+recreate(OID 变了)或 entry 被驱逐;不要假设查询"不再运行" |
用 queryid 跨机器汇聚 |
只在同一版本、同一架构的单机内可靠;跨集群用查询文本指纹,不要用 queryid |
注释里放了 traceparent |
去掉。动态标签在 pgss 里被冻结,无法回溯到具体请求;改用 auto_explain 或 hook-based harvester |
| 仪表盘出现负延迟 | 快照差值遇到了驱逐或 reset;加 stats_since / stats_reset 校验逻辑 |
一句话总结:pg_stat_statements 是地板,不是房子。 它给你原始的、碎片化的运行计数——这已经非常有用,但离"查询商店"还有一整层建筑要做。理解它是什么,你就能用它做出更快的决策;误解它是什么,你就会被数字牵着走进死胡同。