PostgreSQL慢查询定位与索引失效的排查方法(pg_stat_statements抓取、EXPLAIN ANALYZE看执行计划与索引不命中的常见写法)

接口越用越慢,数据库CPU不高、连接数也正常,就是一条查询从几十毫秒涨到几秒。这种时候猜是哪张表没用没意义,先把它抓出来,再看执行计划为什么没走索引。


先把慢查询抓出来

pg_stat_statements是第一步。它要预加载,改完必须重启实例才生效,光CREATE EXTENSION是不够的:

shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 10000
pg_stat_statements.track = all

重启之后建扩展,然后按总耗时排序:

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

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

看total_ms而不是avg_ms。一条每次只要5毫秒、一天调200万次的查询,比一条偶尔跑3秒的报表查询更值得改。统计是实例级的累积值,重置用pg_stat_statements_reset()。

要看单条SQL到底慢在哪一步,开log_min_duration_statement,比如记录超过1000毫秒的语句:

log_min_duration_statement = 1000
log_line_prefix = '%m [%p] %q%u@%d '

生产环境常年开auto_explain更省事,它把执行计划直接写进日志,不用手工重跑:

shared_preload_libraries = 'pg_stat_statements,auto_explain'
auto_explain.log_min_duration = 1000
auto_explain.log_analyze = on
auto_explain.log_buffers = on

实测这套配置在一天之内就能把问题SQL捞全,代价是日志量会涨,磁盘不大的机器上把阈值设高一点。


执行计划要这么读

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE phone = '13800138000';

三个地方挨着看。一是节点类型出现Seq Scan且表有几百万行,基本就是没走索引。二是rows估算值和actual rows差出数量级,比如预估120行、实际14万行,说明统计信息过时,先跑一遍ANALYZE orders再看。三是Buffers那行,shared read远大于shared hit意味着数据在反复从磁盘读,加索引和加大内存都能缓解,但根子不在一处。


索引建了却不命中的几种写法

对列做了函数或运算。where lower(name) = 'abc'走不上name上的普通索引,得建表达式索引:

CREATE INDEX idx_orders_phone_lower ON orders (lower(phone));

按天统计写成where date(created_at) = '2026-10-01'也是一个道理,改成范围条件就能用上普通索引:

WHERE created_at >= '2026-10-01' AND created_at < '2026-10-02'

类型不匹配是最隐蔽的一种。phone是varchar,SQL里写成where phone = 13800138000,数据库会把列转成数字再比较,索引直接失效,执行计划里能看到一个cast。遇到过一次线上事故,就是这个原因让一张800万行的表天天全表扫。

前导模糊。like '%1380'用不上B-tree索引,要上pg_trgm加GIN索引:

CREATE EXTENSION pg_trgm;
CREATE INDEX idx_orders_phone_trgm ON orders USING gin (phone gin_trgm_ops);

复合索引的前导列没出现在条件里。索引是(status, created_at),查询只给created_at,就用不上。建索引时把等值条件的列放前面、范围条件的列放后面。

表太小。几百行的表全表扫比走索引还快,优化器故意选Seq Scan,这不是问题。


规划器的两个默认值

random_page_cost默认4.0,是按机械盘的随机寻道代价定的。机器上是SSD的话,这个值会让优化器高估随机读成本、偏不爱用索引,改到1.1是常见做法。见过不少机器换了SSD之后这个值一直没动过,索引建齐了照样全表扫:

ALTER SYSTEM SET random_page_cost = 1.1;
SELECT pg_reload_conf();

effective_cache_size默认4GB,告诉优化器有多少内存可用于缓存。64GB内存的机器只写4GB,优化器会以为缓存很小、更倾向顺序扫。这两处改完不用重启,reload即可。

统计信息过期的判断办法:pg_stat_user_tables里的last_analyze字段,长期不更新的大表手动跑一次ANALYZE,很多"突然变慢"就是这么来的。autovacuum默认阈值在大表上容易跟不上,可以单独调:

ALTER TABLE orders SET (autovacuum_analyze_scale_factor = 0.02);


顺手清掉没人用的索引

索引不是越多越好,每个索引都在拖慢写入。查一下哪些从没被用过:

SELECT relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;

idx_scan为0且体积不小的,基本可以删,删之前确认它不是唯一约束在用的那个。另外查一下哪些表在大量顺序扫:

SELECT relname, seq_scan, seq_tup_read, idx_scan
FROM pg_stat_user_tables
ORDER BY seq_tup_read DESC
LIMIT 10;

seq_scan数值大、idx_scan几乎是0的大表,就是要补索引的对象。


以上写法在PostgreSQL 13.0、14.0、15.0、16.0、17.0、18.0,上均适用;18.0发布于2025年9月25日,引入了异步I/O子系统,顺序扫描本身变快了,但索引该建还得建。数据截至2026年10月。