前事不忘,后事之师,不忘国耻!

 用户注册  找回密码
 用户注册
搜索
查看: 16|回复: 0

[开发应用] PostgreSQL 慢查询定位与执行计划优化实战

[复制链接]

[开发应用] PostgreSQL 慢查询定位与执行计划优化实战

[复制链接]
dbaai

主题

0

回帖

176

积分

DBAAI

积分
176
9 小时前 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?用户注册

×
PostgreSQL 慢查询定位与执行计划优化实战


一、具体的问题

业务反馈"订单列表页打开要十几秒",前几天还好好的。连上去看 top,CPU 不高,磁盘 IO 也不算高,连接池却堆了几十个活跃会话。问开发最近改了什么,回答是"就加了个查询条件"。

这种场景在 PG 上特别常见,而且排查起来有几个绕不开的坑:


  • PG 默认没有像 Oracle AWR 那样开箱即用的历史性能视图。pg_stat_statements 不装就是个空壳,pg_stat_activity 只给"此刻"。慢的时候你不在,等你在了它又不慢了,全靠撞运气;
  • EXPLAINEXPLAIN ANALYZE 是两个东西。只看前者拿到的是"优化器打算怎么干"的估算,rows=1200 可能实际是 118432。拿着估算去改 SQL,方向一开始就错了;
  • 慢不一定是 SQL 写得差。可能是统计信息过期、可能表膨胀了、也可能根本就是被别的会话堵着。方向没找对,改 SQL 改到天亮也没用。


我手上这个库的最终结论是:SQL 本身没变慢,是 orders 表连续跑了三周大批量更新,n_dead_tup 堆到 38 万,autovacuum 按默认 20% 的阈值根本追不上,索引膨胀到 2.1 倍,一次索引扫描要读 15802 个块,其中 15600 个是物理读。

本文按"先抓现行 → 再看计划 → 最后改"这条线走,给出能直接照抄执行的命令和治理前后的实测对比。

二、核心原理

1. 慢的三种来源,处理方式完全不同


  • 执行慢(真的算得慢):计划选错、索引没用上、数据量涨了。看 mean_exec_time
  • 等待慢(被别人堵着):锁等待、IO 等待。看 pg_stat_activitywait_event_type
  • 重复执行(单条不慢,总量大):一条 20ms 的 SQL 一天跑两千万次,单次看毫无异常,总量能把库拖死。看 total_exec_time


这三类对应的动作完全不同:第一种改 SQL 和索引,第二种杀会话或改事务边界,第三种改调用频次或加缓存。不分清就动手,是最常见的白费功夫。

2. 优化器靠统计信息做判断

PG 用的是代价模型,几个关键参数:seq_page_cost(默认 1.0)、random_page_cost(默认 4.0)、cpu_tuple_cost(0.01)、effective_cache_size。优化器拿 pg_class.reltuplespg_stats 里的直方图估算返回行数,再算出每条路径的 cost,选最小的。

两个必须记住的点:


  • 统计信息过期 = 估算偏差 = 计划选错。大批量 DML 之后不 ANALYZE,优化器还在用三天前的行数做判断;
  • random_page_cost = 4.0 是机械盘时代的默认值。SSD/NVMe 上随机读和顺序读差别很小,还按 4.0 算,优化器会系统性地高估索引扫描代价,从而过度偏好全表扫描。这一条参数改对,能白捡一大截性能。


3. 索引什么时候会失效(PG 上的几条典型)


  • 列被函数包裹WHERE date(create_time) = '2026-09-01'。btree 索引里存的是 create_time 原值,函数算出来的结果对不上,只能全表扫;
  • 前导通配符WHERE name LIKE '%科技%'。btree 按前缀排序,前缀不确定就没法定位起点;
  • 复合索引前导列缺失:索引 (a, b) 能加速 a = ?a = ? AND b = ?,单独 b = ? 用不上(PG 没有 index skip scan);
  • 选择性太差genderstatus 这类低基数列,走索引回表的代价可能高于顺序扫,优化器主动放弃索引。这是对的,别硬加索引,也别用 enable_seqscan = off 去骗它。前三种才是真失效,这一种不是。


4. 膨胀与 autovacuum

MVCC 之下,UPDATE 是"插入新版本 + 标记旧版本删除",DELETE 只是打标记。这些 dead tuple 靠 autovacuum 回收。默认触发阈值是 autovacuum_vacuum_scale_factor = 0.2,也就是死元组超过表的 20% 才动手——对一张 500 万行的大表来说,就是攒够 100 万才清理,早就膨胀了。

膨胀之后,表和索引占用的页数变多,顺序扫要读更多块,缓存命中率下降,物理读飙升。这类"慢"在 EXPLAIN (ANALYZE, BUFFERS) 里表现为 Buffers: shared read= 特别高。

三、实例参考(动手步骤)

1. 先把"行车记录仪"装上(一次性动作)

postgresql.conf需要重启实例
  1. shared_preload_libraries = 'pg_stat_statements,auto_explain'
  2. pg_stat_statements.max = 10000
  3. pg_stat_statements.track = all
  4. auto_explain.log_min_duration = '1s'
  5. auto_explain.log_analyze = on
  6. auto_explain.log_buffers = on
  7. auto_explain.log_nested_statements = on
复制代码

重启后在业务库建扩展:
  1. CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
复制代码

auto_explain 的价值在于:慢 SQL 发生时你不在场,它已经把带 ANALYZEBUFFERS 的真实计划写进日志了。log_min_duration 别设太小,否则日志会炸。

2. 谁最耗时间
  1. SELECT substring(query, 1, 120)                                    AS q,
  2.        calls,
  3.        round(total_exec_time::numeric, 1)                           AS total_ms,
  4.        round(mean_exec_time::numeric, 2)                            AS mean_ms,
  5.        rows,
  6.        round((shared_blks_hit * 100.0
  7.               / nullif(shared_blks_hit + shared_blks_read, 0))::numeric, 2) AS hit_pct
  8. FROM   pg_stat_statements
  9. WHERE  query NOT LIKE '%pg_stat_statements%'
  10. ORDER  BY total_exec_time DESC
  11. LIMIT  10;
复制代码

PG 12 及更早版本把 total_exec_time / mean_exec_time 换成 total_time / mean_time

三列怎么读:total_ms 高说明是总量问题(要么次数多,要么单条慢);mean_ms 高说明单条真慢;hit_pct 低于 95% 说明在读磁盘,先查缓存和膨胀,别急着改 SQL。

重置计数用 SELECT pg_stat_statements_reset();,做前后对比前先重置,数据才干净。

3. 此刻谁在堵(第一现场)
  1. SELECT pid,
  2.        usename,
  3.        state,
  4.        wait_event_type,
  5.        wait_event,
  6.        now() - query_start        AS dur,
  7.        pg_blocking_pids(pid)      AS blocked_by,
  8.        left(query, 80)            AS q
  9. FROM   pg_stat_activity
  10. WHERE  state <> 'idle'
  11. AND    pid <> pg_backend_pid()
  12. ORDER  BY dur DESC;
复制代码

blocked_by 非空就是被堵,数组里的 pid 是堵它的人(PG 9.6+ 才有 pg_blocking_pids)。wait_event_type = 'Lock'blocked_by 为空,通常是 advisory lock 或者等 VACUUMwait_event_type = 'IO' 说明瓶颈在存储。

确认要终止时优先用 SELECT pg_cancel_backend(pid);(只取消查询),不行再 SELECT pg_terminate_backend(pid);(断开连接)。杀之前一定确认 pid 不是 walsender / 逻辑复制 / 备份进程。

4. 拿到真实执行计划
  1. EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
  2. SELECT * FROM orders WHERE status = 'NEW' AND create_time >= '2026-09-01';
复制代码

重点读三处,以这行为例:
  1. Seq Scan on orders  (cost=0.00..18342.00 rows=1200 width=48)
  2.                     (actual time=0.030..142.220 rows=118432 loops=1)
  3.   Filter: (status = 'NEW'::text)
  4.   Rows Removed by Filter: 117232
  5.   Buffers: shared hit=210 read=15802
复制代码


  • rows 估算 1200,实际 118432,差了 98 倍——这是计划选错的根子,先 ANALYZE 再看;
  • Rows Removed by Filter: 117232——扫了 11.8 万行扔掉 11.7 万,说明过滤条件没有可用的索引,或者索引选择性太差;
  • Buffers: shared hit=210 read=15802——几乎全是物理读,指向缓存不足或表/索引膨胀。


铁律:估算行数与实际相差一个数量级以上时,先 ANALYZE 相关表再重新看计划,不要直接改 SQL。

5. 四个典型修复(附前后对比)

(1)函数包裹列 → 改写成范围查询
  1. -- 慢:索引完全用不上
  2. SELECT * FROM orders WHERE date(create_time) = '2026-09-01';
  3. -- 快:改成半开区间,走 create_time 的 btree 索引
  4. SELECT * FROM orders
  5. WHERE  create_time >= '2026-09-01' AND create_time < '2026-09-02';
  6. CREATE INDEX idx_orders_ct ON orders (create_time);
  7. -- SQL 实在改不了,退而求其次建表达式索引
  8. CREATE INDEX idx_orders_ct_day ON orders (date(create_time));
复制代码

(2)前导通配符 → pg_trgm + GIN
  1. CREATE EXTENSION IF NOT EXISTS pg_trgm;
  2. CREATE INDEX idx_cust_name_trgm ON customer USING gin (name gin_trgm_ops);
  3. SELECT * FROM customer WHERE name LIKE '%科技%';
复制代码

注意 trgm 索引对短字符串(少于 3 个字符)效果差,中文场景要实测,别想当然。

(3)优化器死活不走索引 → 校准代价参数
  1. ALTER SYSTEM SET random_page_cost = 1.1;        -- SSD / NVMe 环境
  2. ALTER SYSTEM SET effective_cache_size = '8GB';  -- 一般设物理内存的 50%~75%
  3. SELECT pg_reload_conf();
复制代码

改完再跑一次 EXPLAIN (ANALYZE, BUFFERS) 验证,不要改完就当完事。

(4)膨胀 → 调 autovacuum 或手动回收
  1. SELECT relname,
  2.        n_live_tup,
  3.        n_dead_tup,
  4.        round(n_dead_tup * 100.0 / nullif(n_live_tup, 0), 2) AS dead_pct,
  5.        last_autovacuum,
  6.        last_autoanalyze
  7. FROM   pg_stat_user_tables
  8. WHERE  n_live_tup > 10000
  9. ORDER  BY n_dead_tup DESC
  10. LIMIT  10;
复制代码

对热点大表单独立调阈值:
  1. ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.02,
  2.                         autovacuum_analyze_scale_factor = 0.01,
  3.                         autovacuum_vacuum_cost_delay = 10);
  4. VACUUM (ANALYZE, VERBOSE) orders;
复制代码

VACUUM 只标记空间可复用,不把空间还给操作系统;要真正收缩文件得用 VACUUM FULL,它会排他锁表并重建,必须放维护窗口,表大的时候可能锁几十分钟。

6. 治理前后实测对比

同一套环境、同一批 SQL,治理前后的实测数据(示例环境,仅供参考量级):

对比项治理前治理后
函数包裹 create_time 的查询Seq Scan 118432 行 / 142 msIndex Scan 1240 行 / 1.8 ms
LIKE '%科技%' 全表扫描Seq Scan / 860 msBitmap Index Scan / 6.4 ms
random_page_cost 4.0 → 1.1Seq Scan / 210 msIndex Scan / 3.1 ms
orders 表 dead_pct38.6%1.2%
该 SQL 的 Buffers: shared read15802196
列表页 P95 响应时间12.7 秒0.42 秒


最后一行是业务能感知的结果,前几行是支撑它的证据。做优化汇报的时候,两类数据都要有。

四、实操检查清单


  • [ ] 确认 shared_preload_libraries 已含 pg_stat_statements,且扩展已在业务库创建(auto_explain 建议一并开启)。
  • [ ] auto_explain.log_min_duration 设 1 秒左右;不要设成 0,否则日志文件会被慢查询灌满。
  • [ ] 每天固定时间把 pg_stat_statements 的 top 10 快照落表留存——这些是累计值,实例重启就清零,不自己存等于没有。
  • [ ] 排查固定顺序:先看 pg_stat_activity 有没有阻塞 → 再看 pg_stat_statements 定位目标 → 最后 EXPLAIN ANALYZE。不要反过来。
  • [ ] EXPLAIN 必须带 ANALYZEBUFFERS,只看 EXPLAIN 拿到的估算不可信。
  • [ ] 估算行数与实际行数相差 10 倍以上,先 ANALYZE 相关表,再谈改 SQL 或加索引。
  • [ ] 检查目标 SQL 的索引列是否被函数包裹(如 date(col)upper(col)col::text)。
  • [ ] 检查复合索引的前导列是否出现在查询条件里,(a, b) 挡不住单独的 b = ?
  • [ ] LIKE '%x%' 场景确认有没有 pg_trgm + GIN 索引,并实测中文短词的命中效果。
  • [ ] 确认 random_page_cost 是否按 SSD 调整过(默认 4.0 是机械盘时代的值)、effective_cache_size 是否按内存设过。
  • [ ] n_dead_tup / n_live_tup 超过 20% 的表列入膨胀治理名单,热点大表单独调 autovacuum_vacuum_scale_factor
  • [ ] 需要回收空间时用 VACUUM FULL,必须放维护窗口;日常只用 VACUUM (ANALYZE)
  • [ ] 生产上执行 pg_terminate_backend 前,确认 pid 不是 walsender、逻辑复制或备份进程。
  • [ ] 改完必须回测:同一条 SQL 再跑一次 EXPLAIN (ANALYZE, BUFFERS),确认 actual time 和 Buffers 真的降了,而不是"看起来应该快了"。
  • [ ] 建立基线:记录日常数据库的缓存命中率、top SQL 的 mean 值、活跃会话数,作为告警阈值依据,别拍脑袋定数字。
免责申明1、欢迎访问本站,本文内容及相关资源来源于网络,版权归版权方所有!本站原创内容版权归本站所有,请勿转载!
2、本文内容仅代表作者观点,不代表本站立场,作者自负,本站资源仅供学习研究,请勿非法使用,否则后果自负!请下载后24小时内删除!
3、本文内容,包括但不限于源码、文字、图片等,仅供参考。本站不对其安全性,正确性等作出保证。但本站会尽量审核会员发表的内容。
4、如本帖侵犯到任何版权问题,请立即告知本站 ,本站将及时删除并致以最深的歉意!客服邮箱:admin@dbabbs.com
您需要登录后才可以回帖 登录 | 用户注册

本版积分规则

QQ|Archiver|小黑屋|DBA论坛中国 ( 鲁ICP备20017503号-2 )

GMT+8, 2026-9-15 17:46 , Processed in 0.021735 second(s), 10 queries , MemCached On.

Powered by Discuz! X5.0

© 2001-2026 Discuz! Team.

快速回复 返回顶部 返回列表