pg_stat_user_indexes仅统计索引扫描频次,不反映是否覆盖查询;真实覆盖情况需结合explain (buffers, analyze)中的heap fetches和缓冲命中率判断,且需综合where、select、order by等字段是否全被索引键或include列包含。

pg_stat_user_indexes 只告诉你“用了哪个索引”,不告诉你“用得干不干净”
它统计的是索引被扫描的次数、元组读取量等基础操作频次,但完全不反映查询实际是否只靠索引就拿到了全部需要的数据。比如 SELECT id, name FROM users WHERE status = 'active' 如果 users(status) 是单列索引,pg_stat_user_indexes 会显示这个索引被扫了 1000 次——但它没说这 1000 次里有 999 次还得回表查 name,只有 1 次是纯索引覆盖(比如加了 INCLUDE (name))。你看到高命中,可能只是“高频低效”。
explain (buffers) 才暴露真实 I/O 成本和覆盖缺口
加上 EXPLAIN (BUFFERS, ANALYZE) 后,你能直接看到:Index Scan 后有没有跟 Heap Fetches,以及 shared hit/read 的比例。关键信号是:
-
Heap Fetches: 12345—— 每次索引定位后都要去堆里捞数据,说明没覆盖 -
Buffers: shared hit=1000 read=87——read值高,大概率在反复读数据页,不是光读索引页 - 执行计划里出现
Bitmap Heap Scan而非Bitmap Index Scan单独存在,也暗示后续要回表
覆盖索引判断不能只看 CREATE INDEX 语句
很多人以为建了 CREATE INDEX ON t(a) INCLUDE (b) 就万事大吉,但实际是否覆盖,取决于查询的 WHERE、SELECT、ORDER BY、GROUP BY 全部字段是否都被索引“兜住”。常见漏点:
-
ORDER BY a, b用了INCLUDE (b)索引?不行——INCLUDE列不参与排序,必须是索引键列 -
SELECT * FROM t WHERE a = 1——*必然触发回表,哪怕你加了INCLUDE所有列,也不等于覆盖(除非是覆盖整个表结构的超宽索引,且 PostgreSQL 版本 ≥ 16 支持INCLUDE (*)) - 隐式类型转换让索引失效:比如
WHERE a = '1'而a是integer,索引虽在,但无法用于覆盖,因为内部走了函数隐式转换
监控覆盖率得组合查,单看一个视图全是假阳性
想盯住真实覆盖情况,得写查询把两者串起来,例如:
SELECT
s.schemaname,
s.indexrelname,
s.idx_scan,
e.heap_fetches,
round(100.0 * (s.idx_tup_read - e.heap_fetches) / NULLIF(s.idx_tup_read, 0), 1) AS cover_pct
FROM pg_stat_user_indexes s
JOIN (
SELECT
string_to_array(objid::regclass::text, '.') AS parts,
(regexp_match(extra, 'Heap Fetches: (\d+)'))[1]::int AS heap_fetches
FROM pg_stat_statements
WHERE query ~* 'SELECT.*FROM.*WHERE'
AND extra ~ 'Heap Fetches:'
) e ON array_length(e.parts, 1) = 2
AND s.schemaname = e.parts[1]
AND s.indexrelname = e.parts[2];注意:这里 pg_stat_statements.extra 非默认开启,得配 pg_stat_statements.track = all 并重启;而且 extra 字段内容不稳定,PostgreSQL 15+ 才保证含 Heap Fetches,旧版得靠 EXPLAIN 日志抓取。真要长期监控,别依赖解析 extra,而是定期采样慢查询跑 EXPLAIN (BUFFERS, ANALYZE) 存档分析。










