PostgreSQL 慢查询定位:EXPLAIN ANALYZE 与 pg_stat_statements

现象与影响面

接口变慢、超时报错增多、应用侧连接池被占满、连接数曲线抬升——这是 PostgreSQL(下文简称 PG)慢查询的典型外观。先别急着做两件事:不要重启实例(现场证据全没,问题还会回来);不要凭感觉直接加索引(建索引本身会占用资源,选错列等于白付代价)。PG 侧的排查主线是一条两段式链路:先用 pg_stat_statements 回答”谁最慢”,再用 EXPLAIN (ANALYZE, BUFFERS) 回答”它为什么慢”,最后按索引失效和表膨胀两条线去治。

第一步:pg_stat_statements 找出谁最慢

这个扩展默认随安装包提供但默认关闭,需要两步启用。第一步改配置(postgresql.conf 或 ALTER SYSTEM,注意这个参数必须重启实例才生效,reload 无效);第二步在数据库会话里建扩展:

-- postgresql.conf:shared_preload_libraries = 'pg_stat_statements'
ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements';
-- 重启实例后,在业务库里:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

日常就用一句查询拿榜单(在哪跑:psql,连到业务库):

SELECT query, calls,
       round(total_exec_time::numeric, 1)  AS total_ms,
       round(mean_exec_time::numeric, 1)   AS mean_ms,
       rows,
       shared_blks_read, shared_blks_hit,
       temp_blks_written
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
字段 含义 异常时指向什么
calls / total_exec_time 调用次数与累计耗时 按 total 排序找影响面最大的查询,它是全局耗时的贡献大头
mean_exec_time 平均单次耗时 mean 高但 calls 低:单条要优化的重查询;反之是高频小查询在积少成多
rows / calls 平均每次返回的行数 比值异常大:该带 LIMIT 的列表接口没带,或误把全表查当接口用
shared_blks_read vs hit 读盘块数与缓存命中块数 read 持续偏高:缓冲区装不下热数据,或索引缺失导致大范围扫描
temp_blks_written 排序/哈希溢写到磁盘的块数 大于 0:work_mem 不够,排序在落盘,单条查询的隐性大头耗时

两个口径提醒:定位”全局影响”永远看 total,单条 SQL 的优化空间看 mean,只看 mean 会被偶发重查询带偏;看完一波可以用 SELECT pg_stat_statements_reset(); 清零,再观察一段固定窗口,避免历史数据干扰归因。这层正常找到了嫌疑对象,就往下走看单条 SQL 的执行计划。

第二步:EXPLAIN (ANALYZE, BUFFERS) 看为什么慢

光 EXPLAIN 只拿到优化器的估算计划;加 ANALYZE 才会真实执行并给出每节点的实际耗时和行数;再加 BUFFERS 才能看到缓存命中情况。三件套一起用:

EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.amount, u.name
FROM orders o JOIN users u ON u.id = o.user_id
WHERE o.created_at >= '2026-01-01'
ORDER BY o.amount DESC
LIMIT 20;
计划里看什么 正常样子 异常信号
扫描节点 大表条件查询走 Index Scan 或 Bitmap Heap Scan 大表出现 Seq Scan 且 rows 估计巨大:缺索引或条件写法让索引失效
rows 估计 vs actual 两者同一个数量级 偏差超过约一个数量级:统计信息过期,计划本身就不可信,先 ANALYZE 该表
Buffers: shared hit/read hit 占绝对多数 read 很大:这段在实打实读盘,和第一步的 blks_read 高互相印证
Sort 节点的 Method quicksort / top-N heapsort(内存内) external merge Disk: xxxMB:work_mem 不够,排序溢盘
Join 节点 小结果集用 Nested Loop,大批量用 Hash Join 大表上嵌套循环套出天文数字的循环次数:连接顺序或索引出了问题

反模式点名两处。其一,只看 EXPLAIN 的估算值就下结论——估算和实际偏差大时,计划本身是误导,必须看 ANALYZE 的 actual 行数。其二,对 UPDATE/DELETE 直接跑 ANALYZE 版本——它真的会执行修改;要看这类语句的计划,包进事务再回滚:

BEGIN;
EXPLAIN (ANALYZE, BUFFERS) DELETE FROM events WHERE created_at < '2024-01-01';
ROLLBACK;   -- 分析完立刻回滚,不产生真实删除

第三步:索引为什么没用(PG 版失效场景)

写法 为什么走不了索引 验证与出路
WHERE upper(email) = ‘A@B.COM’ 列被函数包裹,普通 b-tree 不认识变换后的值 计划里见 Seq Scan 即中招;建表达式索引 CREATE INDEX ON users (upper(email)) 或改用大小写折叠的 citext 类型
varchar 列写 WHERE id = 123 列类型与常量类型不一致,隐式转换后索引失配 常量写成 ‘123’ 与列类型对齐;用 EXPLAIN 确认换回 Index Scan
LIKE ‘%abc’(前导通配) b-tree 按前缀有序,前导通配把有序性废了 换 pg_trgm 扩展建 GIN 索引;或业务上改为后缀匹配
WHERE a = 1 OR b = 2(两列各自有索引) 优化器可能放弃索引组合选择全表扫 看计划里有没有 Bitmap Or;没有就改写成 UNION ALL 两段各走各的索引
联合索引 (a, b) 只按 b 查询 不满足最左前缀,索引前段用不上 调整索引列序,或为 b 单独建索引;顺带检查是否该改 (b, a)
OFFSET 100000 LIMIT 20 偏移量让数据库白扫前面十万行,索引再好也省不掉 改 keyset 分页(WHERE id > 上页最大值 ORDER BY id LIMIT 20)

这些场景和 MySQL 高度同源,原理对照可读站内那篇 MySQL 慢查询排查;差异在第五步——MySQL 没有”膨胀”这个概念,而 PG 有。

第四步:膨胀与 autovacuum

PG 的 MVCC 实现决定了更新等于”写新版本、标旧版本死亡”,死元组由 autovacuum 异步回收。两个变量会打破这个循环:一是长事务(包括忘关的会话、挂着不结束的逻辑备份、无人认领的复制槽),它们的存在让旧版本对所有人都”还可能可见”,vacuum 清不动;二是写入节奏长期快于 autovacuum 的触发节奏,表和索引就越攒越肥——查询没变慢的逻辑,只是每次扫描都要越过成片的死元组。

-- 在哪跑:psql,业务库。看死元组规模和最近一次 vacuum 时间
SELECT relname, n_live_tup, n_dead_tup,
       round(n_dead_tup::numeric / GREATEST(n_live_tup, 1), 3) AS dead_ratio,
       last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC LIMIT 10;

-- 比例明显偏高的表:手动触发一次回收并刷新统计信息
VACUUM (ANALYZE) orders;

dead_ratio 常年超过两三成的表值得警惕;要在不停机的前提下把空间还给操作系统,得用在线重建类的工具方案,参数与风险以官方文档为准。反模式点名:夜里跑批的长事务挂着不提交,第二天发现相关表膨胀到原来的数倍还没瘦回来——先杀长事务,再谈回收。

根因清单(按出现频率排序)

根因 典型特征 一行验证
统计信息过期 计划里 rows 估计与 actual 差一个数量级以上 EXPLAIN (ANALYZE) 前后对比;ANALYZE 后是否立刻改善
缺索引或索引失效 大表 Seq Scan、temp 溢写为零但耗时集中 对照上面失效场景表逐条排查写法
排序/哈希溢盘 pg_stat_statements 里 temp_blks_written 高 计划里 Sort Method: external merge
表/索引膨胀 表大小远超业务行数应有体量, Vacuum 频繁但见效慢 pg_stat_user_tables 的 dead_ratio
锁等待 单条 SQL 单独跑很快,生产上偶发卡死 pg_stat_activity 看 wait_event_type = Lock,顺藤摸 blocked 锁源头
连接堆积放大 每条 SQL 都不算慢,但整体吞吐塌了 数 pg_stat_activity 总连接数,对照连接池上限

预防

三件低成本的常设配置:log_min_duration_statement = 500(单位毫秒,慢日志兜底,能抓到 pg_stat_statements 复位间隙里的慢查询);auto_explain 扩展把超阈值查询的计划自动记入日志,事后可复盘;pg_stat_statements 常开并在监控里固化三张趋势——Top10 total 耗时周环比、最大表 dead_ratio、连接数水位。监控要不要告警、阈值定多少,取决于业务对延迟的容忍度,没有通用数字。

结论与边界

这条主线的价值在于每一步都留证据:pg_stat_statements 榜单回答”谁”,EXPLAIN (ANALYZE, BUFFERS) 回答”为什么”,失效场景表回答”怎么改”。诚实地说局限:索引和改写解决的是”单条 SQL 不合理”;如果根因是数据量到了量级,加索引只是缓兵之计,该讨论的是分区、归档和读写分离。还要补一句成本账:索引不是免费的,每个索引都在拖慢写入、占据磁盘,”能治慢”和”值得加”之间隔着写入放大的权衡。

延伸阅读:PG 的安装与 MySQL 心智差异见《PostgreSQL 入门:与 MySQL 的心智差异》;同款问题的 MySQL 版排查路径见《MySQL 慢查询定位与索引失效:从 slow log 到 EXPLAIN》;work_mem 类参数全局放大导致内存出问题的联动排查见《OOM 与内存泄漏的定位:从 dmesg 到真凶》。

版本核实(以官方文档为准):pg_stat_statements 为内置扩展,耗时字段自 PostgreSQL 13 起更名为 total_exec_time / mean_exec_time(旧版为 total_time / mean_time);EXPLAIN 的 BUFFERS 选项、CREATE INDEX CONCURRENTLY、autovacuum 均为长期稳定特性;各参数默认值随版本变化,以 postgresql.org 当前文档为准。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇