pg_stat_statements SQL 统计
pg_stat_statements 是 PostgreSQL 生态中最重要的性能诊断扩展,没有之一。它按「归一化后的 SQL」聚合统计执行信息:每条语句被调用了多少次、总共花了多少时间、平均/最大耗时多少、读了多少块、写了多少 WAL、产生多少临时文件。有了它,才能回答「这个数据库到底慢在哪些 SQL 上」这个根本问题——前面两页的实时视图只能告诉你「此刻谁在跑」,而它能告诉你「这段时间里谁最重」。
它的工作方式是把每条 SQL 归一化成参数化的「语句模板」(常量替换成 $1、$2),然后以模板为粒度累计计数。这带来两个特性:一是相似语句(比如只有 WHERE 值不同)会合并成一条统计,统计维度不会爆炸;二是它统计的是「自上次重置以来」的累计值,属于历史聚合而非实时快照。
本页覆盖三块内容:安装与配置、视图字段的完整解读、以及一组拿来即用的 Top SQL 查询模板。生产环境的性能巡检、容量评估、慢查询治理,都以它为数据源。
前置知识
建议先阅读:pg_locks 锁监控
基础概念
理解 pg_stat_statements,先要接受它的三个核心设计:
| 概念 | 说明 |
|---|---|
| 归一化(normalization) | 常量被替换成 $n 占位符,使同类语句合并统计 |
| queryid | 归一化语句的哈希标识,同一模板共享一个 queryid |
| 统计粒度 | 按 (userid, dbid, queryid, toplevel) 聚合,一行 = 一个用户在某个库上执行的一个语句模板 |
| 统计周期 | 从上次 pg_stat_statements_reset()(或实例启动)到现在的累计值 |
| top / all / none | 统计范围开关:只统计顶层语句 / 连函数内部语句一起 / 完全关闭 |
它统计什么、不统计什么
- 统计:DML、查询、部分工具语句(
track_utility = on时包括COPY、VACUUM、CREATE TABLE等)、以及track = all时存储过程/函数内部的嵌套语句。 - 不统计:
track = none时全部关闭;跨连接的 PREPARE/EXECUTE 细节、简单查询协议与扩展协议统一归一化处理(对使用者透明)。 - 不提供:逐次执行明细、语句执行计划、执行时的等待事件——这些分别要靠日志、
auto_explain与pg_stat_activity补充。
归一化示例
-- 应用发的两条语句
SELECT * FROM orders WHERE customer_id = 42 AND status = 'paid';
SELECT * FROM orders WHERE customer_id = 99 AND status = 'shipped';
-- 归一化后合并为一条统计,query 列显示为:
SELECT * FROM orders WHERE customer_id = $1 AND status = $2-- 查看实际归一化结果
SELECT queryid, calls, query
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 5;需要注意:queryid 是「语句分析树」的哈希,不是查询文本的哈希——等价但写法不同的 SQL 可能得到不同 queryid,而同一条 SQL 在大版本升级后 queryid 也可能变化。不要把 queryid 当作跨版本稳定的持久键。
语法与用法
安装两步走
第一步,把扩展加入预加载列表并重启(shared_preload_libraries 的修改只能重启生效):
# postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
# 可选:计算内核级 query_id(PG14+,auto 即「有消费者时自动开启」)
compute_query_id = auto
# 可选:让块读写时间可统计(默认 off,开启后有少量额外开销)
track_io_timing = on# 重启实例使 shared_preload_libraries 生效
pg_ctl restart -D /var/lib/postgresql/18/main第二步,在需要观测的数据库里创建扩展:
CREATE EXTENSION pg_stat_statements;CREATE EXTENSION-- 验证可用
SELECT * FROM pg_stat_statements LIMIT 1;说明:统计是按数据库分别记录的,扩展本身可以在多个库里各建一份(常见做法是只在业务库建);shared_preload_libraries 是集簇级的,设置一次即可。
常用配置参数
以下参数都带 pg_stat_statements. 前缀,多数支持 reload 生效(max 与 save 例外,需重启):
# postgresql.conf 或 ALTER SYSTEM
# 统计范围:top(默认,只统计顶层语句) / all(连函数内语句) / none(关闭)
pg_stat_statements.track = top
# 是否统计工具语句(COPY、VACUUM、CREATE 等),默认 on
pg_stat_statements.track_utility = on
# 最多保留多少条语句模板,默认 5000;超出时淘汰最少使用的
pg_stat_statements.max = 10000
# 关机时把统计保存到 $PGDATA/pg_stat_statements.save,重启后延续,默认 on
pg_stat_statements.save = on
# 是否统计语句的规划(planning)耗时与次数,PG13+,默认 off
pg_stat_statements.track_planning = off-- 在线查看与修改(部分参数)
SHOW pg_stat_statements.track;
ALTER SYSTEM SET pg_stat_statements.track = all;
SELECT pg_reload_conf();基础查询
-- 按总耗时排 Top 10
SELECT queryid,
calls,
round(total_exec_time::numeric, 1) AS total_ms,
round(mean_exec_time::numeric, 3) AS mean_ms,
rows,
left(query, 80) AS query_head
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
-- 只看当前数据库、排除扩展自身的查询
SELECT *
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
AND query NOT LIKE '%pg_stat_statements%'
ORDER BY mean_exec_time DESC
LIMIT 10;详细说明
视图字段全览(一):标识与执行
| 字段 | 类型 | 说明 |
|---|---|---|
userid | oid | 执行该语句的角色 OID,关联 pg_roles 取用户名 |
dbid | oid | 数据库 OID |
toplevel | boolean | 是否顶层语句(PG13+;track = all 时函数内语句此列为 false) |
queryid | bigint | 语句模板的哈希标识 |
query | text | 归一化后的语句文本 |
calls | bigint | 累计调用次数 |
rows | bigint | 累计返回或影响的行数(不含函数内部处理) |
total_exec_time | double precision | 累计执行耗时(毫秒),含所有子操作 |
min_exec_time / max_exec_time | double precision | 单次执行的最小/最大耗时(毫秒) |
mean_exec_time | double precision | 平均单次耗时(毫秒) |
stddev_exec_time | double precision | 耗时标准差,越大说明执行差异越大 |
total_exec_time 是旧版 total_time 在 PG13 的更名——网上老文章如果出现 total_time,说明针对 PG12 及更早版本。max_exec_time 值得单独盯:平均值健康但最大值巨大的语句,往往意味着偶发的慢执行(参数敏感、锁等待、计划翻转)。
视图字段全览(二):规划统计(PG13+)
| 字段 | 类型 | 说明 |
|---|---|---|
plans | bigint | 累计规划次数(prepared statement 复用计划时不重复计) |
total_plan_time | double precision | 累计规划耗时(毫秒) |
min_plan_time / max_plan_time / mean_plan_time / stddev_plan_time | double precision | 规划耗时统计 |
规划统计由 pg_stat_statements.track_planning 控制(默认 off)。普通 OLTP 的规划耗时通常远小于执行耗时,只有 mean_plan_time 显著时才值得打开它分析,因为规划统计会带来额外开销。
视图字段全览(三):缓冲块统计
| 字段 | 类型 | 说明 |
|---|---|---|
shared_blks_hit | bigint | 命中共享缓冲区(shared_buffers)的块数 |
shared_blks_read | bigint | 需要从磁盘读入共享缓冲区的块数 |
shared_blks_dirtied | bigint | 被该语句弄脏的共享块数 |
shared_blks_written | bigint | 由该语句写出的共享块数 |
local_blks_hit / local_blks_read / local_blks_dirtied / local_blks_written | bigint | 临时表(本地缓冲)的对应统计 |
temp_blks_read / temp_blks_written | bigint | 排序、哈希、物化等操作溢出到临时文件的块读写量 |
shared_blks_read 大的语句是磁盘 I/O 的主要嫌疑人;temp_blks_written 大说明工作内存不够、大量数据落盘,通常该调 work_mem 或优化排序/去重逻辑(见《work_mem 工作内存》一页)。
视图字段全览(四):I/O 时间与 WAL
| 字段 | 类型 | 说明 |
|---|---|---|
blk_read_time / blk_write_time | double precision | 该语句花在共享/本地块读写上的时间(毫秒),需 track_io_timing = on |
temp_blk_read_time / temp_blk_write_time | double precision | 临时文件块读写时间(PG15+ 拆分出来单独统计) |
wal_records | bigint | 累计产生的 WAL 记录数(PG13+) |
wal_fpi | bigint | 累计产生的全页镜像(FPI)数(PG13+) |
wal_bytes | double precision | 累计产生的 WAL 字节数(PG13+) |
wal_buffers_full | bigint | WAL 缓冲区写满的次数(PG14+) |
WAL 三兄弟是「谁在制造复制压力与归档压力」的最佳答案。wal_fpi 偏高的语句往往涉及大量页面首次触碰(如整表 UPDATE),结合 wal_buffers_full 还能判断 WAL 缓冲区是否偏小。
视图字段全览(五):JIT 与重置时间
| 字段 | 类型 | 说明 |
|---|---|---|
jit_functions | bigint | 该语句累计 JIT 编译的函数数 |
jit_generation_time | double precision | 累计 JIT 代码生成耗时 |
jit_inlining_count / jit_inlining_time | bigint / double precision | JIT 内联的次数与耗时 |
jit_deforming_count / jit_deforming_time | bigint / double precision | JIT 元组变形的次数与耗时 |
stats_reset | timestamptz | 整个视图的统计最后重置时间(所有行相同) |
JIT 统计只在 jit = on 且语句达到成本阈值时才有意义。jit_generation_time 相对执行总耗时不划算时,就是「关掉这条语句 JIT」的论据。
Top SQL 查询模板
以下模板覆盖了性能巡检的绝大多数场景,建议原样入库:
-- Top 1:总耗时排行(整体优化收益最大的语句)
SELECT round(total_exec_time::numeric / 1000, 1) AS total_sec,
calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
round((100 * total_exec_time /
sum(total_exec_time) OVER ())::numeric, 1) AS pct_total,
left(query, 100) AS query_head
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
-- Top 2:平均耗时排行(单次最慢的语句)
SELECT calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
round(max_exec_time::numeric, 2) AS max_ms,
left(query, 100) AS query_head
FROM pg_stat_statements
WHERE calls > 100
ORDER BY mean_exec_time DESC
LIMIT 10;
-- Top 3:缓冲区命中差(磁盘读最重的语句)
SELECT shared_blks_read,
shared_blks_hit,
round(100.0 * shared_blks_hit /
nullif(shared_blks_hit + shared_blks_read, 0), 1) AS hit_ratio,
calls,
left(query, 100) AS query_head
FROM pg_stat_statements
WHERE shared_blks_read > 0
ORDER BY shared_blks_read DESC
LIMIT 10;
-- Top 4:临时文件大户(排序/哈希溢出)
SELECT temp_blks_written,
pg_size_pretty((temp_blks_written *
current_setting('block_size')::bigint)::bigint) AS temp_written,
calls,
left(query, 100) AS query_head
FROM pg_stat_statements
WHERE temp_blks_written > 0
ORDER BY temp_blks_written DESC
LIMIT 10;
-- Top 5:WAL 制造机(复制与归档压力来源)
SELECT pg_size_pretty(wal_bytes::bigint) AS wal_total,
wal_records,
wal_fpi,
calls,
left(query, 100) AS query_head
FROM pg_stat_statements
WHERE wal_bytes > 0
ORDER BY wal_bytes DESC
LIMIT 10;
-- Top 6:调用次数最高(QPS 主力,最值得做缓存或索引微优化)
SELECT calls,
rows,
round(rows::numeric / nullif(calls, 0), 2) AS rows_per_call,
round(mean_exec_time::numeric, 3) AS mean_ms,
left(query, 100) AS query_head
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 10;track = all 与 toplevel
默认 track = top 只统计顶层语句。存储过程、函数内部执行的语句不会被单独统计——它们的耗时全部归到「调用那个函数的那条语句」头上。
-- 打开嵌套语句统计(reload 生效)
ALTER SYSTEM SET pg_stat_statements.track = all;
SELECT pg_reload_conf();打开后,函数内语句会以 toplevel = false 出现,可以单独分析:
-- 只看函数内部的慢语句
SELECT queryid, calls, round(mean_exec_time::numeric, 3) AS mean_ms, query
FROM pg_stat_statements
WHERE NOT toplevel
ORDER BY total_exec_time DESC
LIMIT 10;all 模式统计开销更大,一般只在深入剖析函数性能时临时开启。
重置与 pg_stat_statements_info
-- 全量重置
SELECT pg_stat_statements_reset();
-- PG13+ 支持选择性重置:指定用户、数据库、语句
SELECT pg_stat_statements_reset(
(SELECT oid FROM pg_roles WHERE rolname = 'app'),
(SELECT oid FROM pg_database WHERE datname = 'appdb'),
NULL
);PG14+ 提供了 pg_stat_statements_info 视图,记录统计机制自身的运行信息:
SELECT dealloc, stats_reset FROM pg_stat_statements_info;| 字段 | 说明 |
|---|---|
dealloc | 因超出 pg_stat_statements.max 而被淘汰的语句统计条数 |
stats_reset | 统计最后一次重置的时间 |
dealloc 持续增长说明 max 设小了——热语句统计被反复挤出再重建,Top 排行会失真,应调大 max 或增加采样频率。
版本演进速览
- PG13:视图大改版——
total_time更名total_exec_time;新增toplevel、plans与四个规划耗时字段;新增wal_records/wal_fpi/wal_bytes;pg_stat_statements_reset()支持按用户/库/语句选择性重置。 - PG14:queryid 改由内核统一计算(
compute_query_id),pg_stat_activity.query_id与本扩展打通;新增wal_buffers_full;新增pg_stat_statements_info视图。 - PG15:新增
temp_blk_read_time/temp_blk_write_time,临时文件 I/O 时间与共享/本地块 I/O 时间分开统计。 - 更早版本(PG12 及以前)没有 WAL、规划、toplevel 字段,旧列名
total_time需要改写为total_exec_time才能在新版本运行。
多用户与多数据库视角
pg_stat_statements 按 (userid, dbid, queryid, toplevel) 聚合,意味着同一条语句在不同用户、不同库上的统计各占一行。利用这一点可以做「责任划分」:
-- 用户名 + 库名联查,定位「谁在哪个库跑重查询」
SELECT r.rolname,
d.datname,
s.calls,
round(s.total_exec_time::numeric / 1000, 1) AS total_sec,
round(s.mean_exec_time::numeric, 2) AS mean_ms,
left(s.query, 60) AS query_head
FROM pg_stat_statements s
JOIN pg_roles r ON r.oid = s.userid
JOIN pg_database d ON d.oid = s.dbid
ORDER BY s.total_exec_time DESC
LIMIT 15;-- 只看某个用户的负载画像(如报表账号)
SELECT calls,
round(total_exec_time::numeric / 1000, 1) AS total_sec,
left(query, 80) AS query_head
FROM pg_stat_statements
WHERE userid = (SELECT oid FROM pg_roles WHERE rolname = 'report_user')
ORDER BY total_exec_time DESC;两个实用结论:
- 给报表、ETL 分配专用角色后,这条联查能直接回答「重查询是谁家的」——这是权限分账与优化责任划分的基础。所有业务共用一个应用角色的话,这些信息就被抹平了。
- 统计按库隔离,
pg_stat_statements_reset()也支持按库重置(PG13+);跨库对比负载时逐库取数再汇总。
与 auto_explain、慢语句日志的分工
三件工具回答三个不同的问题,混用不如分工:
| 工具 | 回答的问题 | 数据形态 | 成本 |
|---|---|---|---|
pg_stat_statements | 哪些语句长期最重(聚合画像) | 归一化聚合 | 低,可常开 |
log_min_duration_statement | 超阈值语句的逐条流水(含原始参数) | 日志行 | 中,看日志量 |
auto_explain | 慢语句当时的执行计划长什么样 | 日志中的计划 | 中,只记慢的 |
组合套路:用 pg_stat_statements 找到优化目标 → 怀疑计划问题时对目标角色临时开启 auto_explain 抓计划 → 需要还原真实参数值(归一化后的 $1)时回到慢语句日志找原始行。慢语句日志的配置细节由慢查询章节展开,本章日志部分也会涉及。
容量与开销评估
扩展的内存占用与语句模板数线性相关(每条记录含归一化文本等固定开销)。评估口径:
-- 当前模板条数与淘汰情况
SELECT (SELECT count(*) FROM pg_stat_statements) AS entries,
i.dealloc,
i.stats_reset
FROM pg_stat_statements_info i;entries长期逼近pg_stat_statements.max且dealloc在涨,说明槽位不足——热语句被挤出后重新统计,Top 榜单会失真。调大max(需重启)或靠周期采样弥补。- 反过来,把
max设到几十万也没有收益:绝大多数库的有效模板数在数千到数万之间,多余槽位只是浪费内存。 track = all与track_planning = on都会增加每语句的处理开销,按需临时开启是更稳妥的姿势。
与 pg_stat_activity.query_id 的联动边界
PG14 起,内核可以用 compute_query_id 统一计算 query id,pg_stat_activity.query_id 与本扩展的 queryid 同源,于是「正在跑的语句 ↔ 历史统计」可以直接关联(示例四演示过)。但要知道这条链路的三个边界:
compute_query_id = off时pg_stat_activity.query_id恒为 NULL,联动失效;auto表示「有消费者才计算」,要稳定联动就显式设on。- 关联键只在同一次实例生命周期内可靠;重启后 queryid 理论上可能变化,不要把它当跨重启的持久键。
- 匹配是「模板级」的——同一模板的不同次执行共享一个 id,这正好是想要的粒度;归一化文本相同的两条语句也会合并,解读时知道即可。
实战示例
示例一:每日巡检报告 SQL
-- 一条查询同时给出「总耗时、平均耗时、读盘、临时文件」四个维度
WITH top_total AS (
SELECT queryid,
row_number() OVER (ORDER BY total_exec_time DESC) AS rn
FROM pg_stat_statements
),
top_mean AS (
SELECT queryid,
row_number() OVER (ORDER BY mean_exec_time DESC) AS rn
FROM pg_stat_statements
WHERE calls > 50
),
top_io AS (
SELECT queryid,
row_number() OVER (ORDER BY shared_blks_read DESC) AS rn
FROM pg_stat_statements
)
SELECT s.queryid,
t.rn AS rank_total,
m.rn AS rank_mean,
i.rn AS rank_io,
s.calls,
round(s.total_exec_time::numeric / 1000, 1) AS total_sec,
round(s.mean_exec_time::numeric, 2) AS mean_ms,
s.shared_blks_read,
s.temp_blks_written,
left(s.query, 60) AS query_head
FROM pg_stat_statements s
LEFT JOIN top_total t USING (queryid)
LEFT JOIN top_mean m USING (queryid)
LEFT JOIN top_io i USING (queryid)
WHERE t.rn <= 10 OR m.rn <= 10 OR i.rn <= 10
ORDER BY coalesce(t.rn, 99), s.total_exec_time DESC;三个榜单同时进前十的语句,就是当日最值得投入的优化目标。
示例二:定位「偶发慢」语句
平均耗时不高、但最大耗时巨大的语句,通常是偶发慢的来源:
SELECT calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
round(max_exec_time::numeric, 2) AS max_ms,
round((max_exec_time / nullif(mean_exec_time, 0))::numeric, 1) AS max_over_mean,
stddev_exec_time,
left(query, 100) AS query_head
FROM pg_stat_statements
WHERE calls > 10
AND max_exec_time > 1000 -- 单次超过 1 秒
AND mean_exec_time < 50 -- 平均却不到 50ms
ORDER BY max_over_mean DESC
LIMIT 10;进一步用 EXPLAIN ANALYZE 复现单条语句,确认是否参数敏感计划(见《EXPLAIN ANALYZE 实际执行》一页)。
示例三:全量重置后观察一个周期
统计周期越长,「平均值」越容易被历史稀释。做优化验证时常用「重置 → 观察一小时 → 读数」的循环:
# 定时任务示例:每小时归档 Top SQL 到自建表
psql -c "INSERT INTO sql_stats_history
SELECT now(), * FROM pg_stat_statements;"
psql -c "SELECT pg_stat_statements_reset();"-- 归档表
CREATE TABLE sql_stats_history (
captured_at timestamptz,
dbid oid,
userid oid,
queryid bigint,
query text,
calls bigint,
total_exec_time double precision,
mean_exec_time double precision,
rows bigint
);这样既保住了长期趋势,又让重置后的统计保持「新鲜」。
示例四:关联 pg_stat_activity 验证
实时视图与累计统计相互印证:巡检时发现某条慢语句正在执行,去 pg_stat_statements 查它的历史画像:
-- 当前正在跑的语句的历史统计
SELECT a.pid,
a.query_start,
now() - a.query_start AS running_for,
s.calls,
s.mean_exec_time,
s.max_exec_time,
left(a.query, 80) AS current_query
FROM pg_stat_activity a
LEFT JOIN pg_stat_statements s ON s.queryid = a.query_id
WHERE a.state = 'active'
AND a.query_id IS NOT NULL
ORDER BY running_for DESC;PG14+ 两个视图通过 queryid 打通,这种「实时 + 历史」的组合排查是标配手法。
示例五:优化前后基线对比
目标:验证给 orders 加了复合索引后,那条高频查询真的变快了。
-- 1) 优化前:锁定目标语句的基线(记录下来)
SELECT queryid, calls,
round(mean_exec_time::numeric, 3) AS mean_ms,
shared_blks_read,
round(100.0 * shared_blks_hit /
nullif(shared_blks_hit + shared_blks_read, 0), 1) AS hit_pct
FROM pg_stat_statements
WHERE query LIKE 'SELECT * FROM orders WHERE customer_id%'; queryid | calls | mean_ms | shared_blks_read | hit_pct
-------------------+-------+---------+------------------+---------
-371940268581990 | 12043 | 142.551 | 18402213 | 61.2
(1 row)-- 2) 建索引(CONCURRENTLY,业务不受阻)
CREATE INDEX CONCURRENTLY orders_customer_created_idx
ON orders (customer_id, created_at DESC);
-- 3) 低峰期重置统计,让新基线从零开始
-- (代价是全库其他语句的历史统计清零,评估后再做)
SELECT pg_stat_statements_reset();
-- 4) 积累足够样本量后再次读取同一条语句 queryid | calls | mean_ms | shared_blks_read | hit_pct
-------------------+-------+---------+------------------+---------
-371940268581990 | 9812 | 3.204 | 20413 | 99.8
(1 row)平均耗时 142ms → 3.2ms、读块量降三个数量级——数据说话,优化生效。把「优化前 / 优化后」两行快照贴进变更记录,是性能工作最可复用的交付物。
注意事项
统计粒度是「模板」不是「单次执行」。 mean_exec_time 掩盖了单次波动,max_exec_time 与 stddev_exec_time 才能暴露偶发慢。只看平均值的优化常常会漏掉真正的痛点。
queryid 不是稳定主键。 它来自分析树哈希:SQL 写法变化、大版本升级、参数化差异都可能改变 queryid。拿它做长期对比时要理解这个边界,跨周期对比用「归一化文本」更稳妥。
共享缓冲区命中率只统计 shared_buffers 层面。 shared_blks_read 表示「没在 shared_buffers 命中、需要从存储读入」,但操作系统的页缓存仍可能让这次「磁盘读」实际发生在内存里。命中率低于预期时,先确认 shared_buffers 是否偏小,再谈存储。
统计在实例崩溃时会丢失。 非 save 正常关机场景(如崩溃后恢复)统计从零开始;把「统计重置时间」纳入巡检输出,避免误读异常小的累计值。
track = all 有可见的开销。 每条嵌套语句都要做一次归一化与统计,函数调用密集的负载下开销可感。默认 top 就够日常使用,深挖函数时临时切 all。
max 太小会让热语句被淘汰。 出现 pg_stat_statements_info.dealloc 持续增长时,Top 榜单会缺少真正的热语句;调整后旧统计不清零,但已淘汰的不会回来。
工具语句也会占统计槽位。 track_utility = on 时 VACUUM、COPY、DDL 都会进入统计——这对容量分析有价值,但批量 COPY 可能长期霸榜,解读时要区分业务 SQL 与运维 SQL。
它不记录执行计划与等待原因。 「这条语句平均 3 秒」只是起点,为什么慢要交给 EXPLAIN ANALYZE、pg_stat_activity.wait_event 与日志系统。工具组合使用才闭环。
最佳实践
- 所有生产实例标配
pg_stat_statements。 预加载 + 建扩展的开销极低,而它提供的信息无可替代。新实例初始化清单里应该有这两行配置与一条CREATE EXTENSION。 - 开启
track_io_timing = on。 毫秒级 I/O 时间是区分「CPU 型慢」与「IO 型慢」的关键,开销在绝大多数硬件上可以忽略。 - 把 Top SQL 模板做成定时采样落库。 每小时快照一次
pg_stat_statements到自建历史表(或接监控系统的 exporter),既保留趋势,又支持「优化前后对比」。 - 巡检看四个榜单:总耗时、平均耗时、读盘、临时文件。 单看任何一个都会跑偏;四个榜单的交集就是优化优先级。
- 对
calls极高、单次极快的语句做「微优化」。 平均 1ms、每天百万次的语句,砍掉 0.2ms 就是每天几十秒的 CPU——这类收益 EXPLAIN 里看不明显,全靠统计视图发现。 - 用「重置 → 观察 → 读数」验证每次优化。 优化前重置统计,观察足够样本量后对比 calls、mean_exec_time、shared_blks_read,用数据证明优化有效,而不是凭感觉。
pg_stat_statements.max按库规模设余量。 高并发多租户库的语句模板数轻松过万,默认 5000 可能不够,配合dealloc计数确认容量是否合理。- 升级大版本前导出统计基线。 queryid 与部分字段跨版本会变,升级前把当时的 Top SQL 快照存档,升级后重新采样对比,判断性能回归。
- 权限上给监控账号只读视图的访问即可。 视图的查询文本里可能含业务敏感信息(即便常量已参数化),监控账号用普通只读权限 +
pg_read_all_stats精细控制,不要顺手给超级用户。 - 与 auto_explain、慢日志组合使用。 统计视图找「哪条最慢」,日志与
auto_explain拿「慢时的现场」,两者互补而非互替。 - 把
wal_bytes榜纳入复制与归档容量规划。 WAL 生成大户决定了归档带宽与备机恢复速度,扩容、做大批量数据迁移前先看这张榜。 - 解读统计数据永远带上
stats_reset时间。 「总量」没有「时间窗」就没有意义,报告里注明统计周期是基本功。
下一节