pg_stat_statements:你以为的查询商店,其实是一张计数器哈希表

2026-06-03 27 预计阅读时间:1 分钟
来源:postgr.es AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:19 分钟

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;,你会看到每个"查询形状"占一行,背后是一组累加计数器:callstotal_exec_timerowsshared_blks_hitshared_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 = 42WHERE 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.orderstenant_b.orders,schema-per-tenant 应用会把一个逻辑查询打散到每个 tenant 的 OID 上。想汇总"所有对 orders 的读取",你得自己解决 OID 映射。

别名也会泄露进去——SELECT c.id FROM customers cSELECT id FROM customers 是两个不同形状,两个不同 queryid

ORM 的行爆炸

jumble 归一化了常量,但没有归一化结构。两个你肉眼看来"一样的查询",只要解析树不同——列列表不同、表别名不同、多了个 LIMITAND 顺序不同——就是不同形状,不同 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 直接支持数组绑定(setArrayARRAY[...]、pgx 的 slice 绑定、ActiveRecord 的 where(id: array)),规划器也像 IN 列表一样用它做索引选择。

对 ORM 重度团队来说,这是保持 pg_stat_statements 可读的最高杠杆操作。

首次见到的文本被冻结

每个 entry 展示一条查询文本——是创建该 entry 的第一次执行的文本,不是最近的,不是最常见的。之后计数器不断累加,磁盘上的文本不再改变,直到 entry 被驱逐后重建。

常量被归一化为 $1,大多数时候没问题。问题出在查询携带注释时——现代应用大量使用 sqlcommentermarginalia 约定:

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 会打破这个假设。可信的快照差值必须处理三种情况:

  1. Entry 被驱逐后重建。 查询掉出哈希表后再次运行,计数器从零开始。total_exec_time_now < total_exec_time_then,减法给出负值。这不是"查询变快了",这是"这个窗口对该 queryid 无效"。
  2. 全局 reset。 有人跑了 pg_stat_statements_reset()。每行的"现在"都比"那时"小。检查 pg_stat_statements_info.stats_reset 是否比早期快照新——如果是,整个窗口跨了一次 reset,差值全是垃圾。
  3. queryid 只出现在一侧。 "那时"有但"现在"没有——被驱逐了,或者表被 drop+recreate(新 OID,新 id)。"现在"有但"那时"没有——新建或重建。用 full outer join,把不匹配的行当事件处理,不要当零去减。

PG 17 加了一个更干净的逐行守卫:每行带 stats_since,表示 entry 创建或重建的时刻。如果某行的 stats_since 比你的早期快照新,它的计数器在你窗口内 reset 过,丢弃该窗口。PG 17 之前,全局 stats_reset 是唯一的内置信号,逐行驱逐只能靠"计数器倒退"来发现。

平均值掩盖了分布

扩展对每个形状只维护四个数字:minmaxmeanstddev。每次执行用 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_resetstats_since,过滤掉无效窗口
想看 p99 或延迟分布 auto_explainlog_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 是地板,不是房子。 它给你原始的、碎片化的运行计数——这已经非常有用,但离"查询商店"还有一整层建筑要做。理解它是什么,你就能用它做出更快的决策;误解它是什么,你就会被数字牵着走进死胡同。


相关推荐