PostgreSQL 慢查询定位实录:一条 SQL 从 2.3 秒降到 12 毫秒

举报
搞点薯条 发表于 2026/11/14 09:32:54 2026/11/14
【摘要】 从 pg_stat_statements 找到元凶,用 EXPLAIN (ANALYZE, BUFFERS) 看清执行计划,再到隐式类型转换、统计信息过期与游标分页:一次完整的慢查询排查复盘。

上周有个订单查询接口的 P99 从 80 毫秒涨到 2.3 秒,应用日志干干净净,监控上 CPU 和内存都不高。翻了半天代码没找到问题,最后把数据库慢查询日志打开,五分钟就锁定了元凶:一条带 LIMIT 的分页 SQL。这篇把定位过程和优化思路完整写下来,都是一线能直接用的手段。

一、先让数据库自己说话

不要靠猜。PostgreSQL 自带两件利器:pg_stat_statements 扩展按 SQL 指纹聚合执行次数、总耗时和平均耗时,慢查询日志则按阈值逐条记录。两者配合,问题 SQL 基本藏不住。

启用方式很简单,先在 postgresql.conf 的 shared_preload_libraries 里加上 pg_stat_statements 并重启,然后建扩展、设阈值:

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

ALTER SYSTEM SET log_min_duration_statement = '200ms';

SELECT pg_reload_conf();

接着按总耗时排序,前三名通常就是真正的麻烦制造者:

SELECT calls, round(total_exec_time::numeric,1) AS total_ms, round(mean_exec_time::numeric,2) AS mean_ms, rows, query FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;

这里有个容易踩的坑:要按 total_exec_time 排序,而不是 mean_exec_time。平均只要 5 毫秒、但每秒被调用两千次的 SQL,对数据库的伤害远大于偶尔跑三秒的报表查询。优化收益等于单次节省时间乘以调用次数,这一点决定了你该先动哪条 SQL。

二、EXPLAIN 只看三样东西

定位到目标 SQL 后,用 EXPLAIN (ANALYZE, BUFFERS) 看真实执行计划。不加 ANALYZE 只是优化器的估算,加 BUFFERS 才能看到缓存命中和磁盘读取,两个参数缺一不可。看计划我一般只关注三点:

第一,大表上出现 Seq Scan,说明索引没被用上或者压根没有。第二,估算行数 rows 与实际行数 actual rows 差一个数量级以上,说明统计信息过期,优化器选错了路径。第三,shared read 很高而 shared hit 很低,说明数据不在共享缓冲区里,瓶颈在磁盘 IO 而不在 CPU。

我那条 SQL 的计划里,Seq Scan 扫了 42 万行,actual rows 却只有 20 行。原因是 WHERE 里的 user_id 字段是 bigint,而应用传进来的是字符串,PostgreSQL 做了隐式类型转换,索引直接失效。

三、三类高频病因

第一类是隐式类型转换。bigint 对字符串常量、varchar 对整型,都会让 B-Tree 索引失效。这类问题最隐蔽,因为 SQL 语法完全正确,跑起来也不报错,只有看执行计划才能发现。解决办法是把参数类型在应用层对齐,别指望数据库替你兜底。

第二类是统计信息过期。大批量导入或删除之后,pg_statistic 里的数据分布还是旧的,优化器会严重低估或高估行数。手动 ANALYZE 一张表通常就能恢复,但生产环境对大表执行会有短暂的锁竞争,建议放在低峰期,或者调大 autovacuum_analyze_scale_factor 让自动分析更积极一些。

第三类是会让索引白建的写法。在索引列上套函数,比如 where date(created_at) = '2026-11-14';用 LIKE '%关键字' 做前缀模糊;用 OR 连接不同字段。前者可以改写成范围查询 created_at >= ... and created_at < ...,中者考虑 pg_trgm 的 GIN 索引,后者拆成 UNION ALL 往往更快。

四、索引不是越多越好

索引的本质是用写入成本和磁盘空间换查询速度。每加一个索引,INSERT 和 UPDATE 都多一次维护开销,表膨胀也会更快。我的原则是:组合索引按选择度从高到低排列,把最常用的等值条件放最左边;能靠组合索引覆盖查询字段时就别回表,Index Only Scan 比 Index Scan 快一个量级;单表索引超过六个就该停下来复盘,想想是不是查询模式本身有问题。

再提醒一个分页的细节。ORDER BY 加 LIMIT 的分页,如果偏移量很大,比如 OFFSET 100000,数据库仍然要扫描并丢弃前十万行,耗时随页码线性增长。改成基于游标的分页,用 where id > 上一页最后一个 id order by id limit 20,耗时可以从秒级直接降到毫秒级。这也是我那条 SQL 最终最主要的优化点,配合 user_id 上的组合索引,整体从 2.3 秒降到 12 毫秒。

五、把定位变成日常

优化完并不算结束。把 log_min_duration_statement 长期开着,阈值设 200 到 500 毫秒,配合 pg_stat_statements 每周看一次 Top 20,比等到接口超时再救火划算得多。稳定的系统不是没有慢查询,而是慢查询在变成事故之前就被看见了。

最后补一个血的教训:任何 EXPLAIN ANALYZE 都不要直接在生产主库上跑,尤其是带 UPDATE 或 DELETE 的语句,它会真的执行。稳妥的做法是先开事务再 ROLLBACK,或者干脆在只读从库上验证计划。慢查询排查是个手艺活,工具只是放大器,判断还得靠对数据分布和执行路径的理解。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。