现象与影响面
接口变慢、超时报错增多、应用侧连接池被占满、连接数曲线抬升——这是 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 当前文档为准。