Skip to content

索引使用监控 ​

建索引容易,删索引难。一个从不被使用的索引不会报错,它只是安静地占着磁盘、拖慢每一次写入、被备份复制一遍又一遍。判断「哪个索引真的在被使用」,靠的不是直觉,而是 PostgreSQL 内置的统计收集器(Statistics Collector)。

系统视图 pg_stat_user_indexes 为每个索引维护一行计数器,用 idx_scan 记录它被扫描的次数。看起来简单,但语义有坑:计数什么时候清零?空闲连接会丢数据吗?为什么昨天还在用的索引今天 idx_scan = 0?本节把这些讲清楚,并给出一套可落地的「找出无用索引」巡检方法。

前置知识

阅读本节前,建议先了解:EXPLAIN 执行计划、索引膨胀检测、VACUUM 清理

基础概念 ​

统计收集器:计数从哪来 ​

PostgreSQL 有一个独立的统计收集器(Statistics Collector)进程,它从各后端收集运行时数据并汇总成系统视图。默认配置下:

text
track_counts = on        # 是否收集表/索引的访问计数(默认 on)
track_io_timing = off    # 是否收集 I/O 耗时(默认 off,开启有少量开销)
track_functions = none   # 函数调用统计

数据不是实时更新的——后端会把计数缓存在自己的私有内存里,在特定时机批量刷给收集器。因此:

text
后端执行索引扫描
      │ 累加本地计数
      ▼
   本地缓存(PGSTAT_MIN_INTERVAL,默认 1 秒节流)
      │ 后端空闲 / 事务结束 / 缓存满
      ▼
   统计收集器
      │ 汇总
      ▼
   pg_stat_* 视图

统计的三个关键特性 ​

特性含义后果
非实时空闲前刷出,节流不低于 1 秒刚执行的查询可能还没进视图
会重置崩溃恢复、非干净关闭、pg_stat_reset()重启后计数归零,不可当永久累计值
可缓存stats_fetch_consistency 控制快照一致性同一事务内多次查询结果可能一致或漂移
text
干净关闭(fast / smart)→ 统计保留到 pg_stat 子目录,重启后仍在
非干净关闭(immediate / 崩溃)→ 统计重置
pg_stat_reset() / pg_stat_reset_shared() → 手动清零

pg_stat_user_indexes:每索引一行 ​

这是索引监控的主力视图,只包含用户表上的索引。同族视图还有两个:

视图范围
pg_stat_user_indexes用户表上的索引
pg_stat_sys_indexes系统表上的索引
pg_stat_all_indexes两者之和

关键列及其语义:

列类型含义
relidoid索引所属表的 OID
indexrelidoid索引自身的 OID
schemanamename模式名
relnamename表名
indexrelnamename索引名
idx_scanbigint该索引上发起的索引扫描次数
last_idx_scantimestamptz最近一次被扫描的时间(PG 16+)
idx_tup_readbigint扫描返回的索引项数量
idx_tup_fetchbigint由简单索引扫描从表取回的活行数
text
idx_scan   ——「这个索引被用了多少次?」
idx_tup_read  ——「一共读到多少个索引条目?」(含被过滤掉的)
idx_tup_fetch ——「多少次回到表里取行?」(仅 Index Scan 累加)

留意 last_idx_scan 是 PostgreSQL 16 新增的列,能直接回答「这个索引最近一次被用是什么时候」,比只看累计次数有用得多。

关于计数清零的两个坑 ​

坑一:idx_scan 不是「永久累计」。 它只是「自上次重置以来」。数据库崩溃恢复、pg_ctl restart -m immediate、或有人跑了 pg_stat_reset(),都会让它归零。看到 idx_scan = 0 之前,先确认统计没被重置。

sql
-- 查看统计系统本身的运行状态
SELECT stats_reset FROM pg_stat_database WHERE datname = current_database();
text
          stats_reset
-------------------------------
 2026-09-01 03:00:12.448+08

如果这个时间是几天前,说明统计已稳定运行,idx_scan = 0 才可信;如果是十分钟前,别急着下结论。

坑二:idx_scan 不区分「有用扫描」与「无效扫描」。 一次索引扫描把上万条目读出来又被 Filter 全部滤掉,照样计入 idx_scan。所以高 idx_scan 也可能是个坏索引,要结合 idx_tup_read 一起看。

语法与用法 ​

最常用的巡检查询:

sql
SELECT schemaname,
       relname                          AS table_name,
       indexrelname                     AS index_name,
       idx_scan,
       idx_tup_read,
       idx_tup_fetch,
       pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'events'
ORDER BY idx_scan DESC;
text
 schemaname | table_name |       index_name       | idx_scan | idx_tup_read | idx_tup_fetch | size
------------+------------+------------------------+----------+--------------+---------------+-------
 public     | events     | events_pkey            |   912441 |        91244 |        912441 | 62 MB
 public     | events     | idx_events_user        |   184203 |       184203 |        91021 | 21 MB
 public     | events     | idx_events_status      |        0 |            0 |             0 | 18 MB

一眼就能看出 idx_events_status 从未被使用,却占了 18 MB。

观察最近使用时间:

sql
SELECT indexrelname,
       idx_scan,
       last_idx_scan,
       pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY last_idx_scan NULLS FIRST;
text
    indexrelname     | idx_scan |        last_idx_scan        |  size
---------------------+----------+-----------------------------+-------
 idx_events_status   |        0 |                             | 18 MB
 idx_orders_legacy   |        2 | 2026-05-11 09:12:44.101+08  | 44 MB
 idx_events_user     |   184203 | 2026-09-28 10:02:31.552+08  | 21 MB

last_idx_scan 为 NULL 表示自统计重置以来从未被扫描过——这是最强有力的「无用索引」证据。

详细说明 ​

idx_scan / idx_tup_read / idx_tup_fetch 的精确语义 ​

三个计数器的定义常常被混淆,把官方语义拆开:

计数器触发时机是否含过滤掉的条目是否触发回表
idx_scan一次索引扫描启动——
idx_tup_read每读出一个索引条目是否
idx_tup_fetch每从表取回一个活行否(只算最终取回的)是
text
WHERE 条件命中率低时:

  idx_scan      = 1
  idx_tup_read  = 10000   ← 索引里读了 1 万个条目
  idx_tup_fetch = 2       ← 只有 2 行通过过滤被取回

这种情况说明索引选择性差:扫描代价高、收益低。

反过来,一个选择性好的索引应该呈现:

text
  idx_scan      = 5000
  idx_tup_read  = 5200    ← 每次扫描平均读 1 个条目
  idx_tup_fetch = 5100    ← 几乎都取回了
sql
-- 计算「平均每次扫描读多少条目」,衡量扫描效率
SELECT indexrelname,
       idx_scan,
       idx_tup_read,
       round(idx_tup_read::numeric / nullif(idx_scan, 0), 2) AS tup_per_scan
FROM pg_stat_user_indexes
WHERE idx_scan > 0
ORDER BY tup_per_scan DESC
LIMIT 10;
text
    indexrelname     | idx_scan | idx_tup_read | tup_per_scan
---------------------+----------+--------------+--------------
 idx_events_status   |     1204 |     48290100 |     40108.06
 idx_events_user     |   184203 |       184203 |         1.00

tup_per_scan 高达 4 万,说明每次扫描几乎翻遍整个索引——这样的索引很可能不如直接顺序扫描。

Index Only Scan 与 idx_tup_fetch

仅索引扫描不回表,因此不累加 idx_tup_fetch。看到某个覆盖索引 idx_scan 很高但 idx_tup_fetch 很低(甚至为 0),往往说明它正在高效地服务仅索引扫描。

找出从未使用的索引 ​

巡检的核心目标。基础查询:

sql
SELECT schemaname,
       relname      AS table_name,
       indexrelname AS index_name,
       idx_scan,
       pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
text
 schemaname | table_name |     index_name      | idx_scan |  size
------------+------------+---------------------+----------+--------
 public     | logs       | idx_logs_created_at |        0 |  96 MB
 public     | events     | idx_events_status   |        0 |  18 MB

但别直接把结果全删掉。有几类索引的 idx_scan 天然为 0,删了会出事:

索引类型为何 idx_scan 可能为 0处置
唯一约束 / 主键底层索引唯一性由插入时检查,不产生「扫描」计数绝不能删
外键引用侧的索引若引用列索引被用于约束检查,计数口径不同谨慎,先验证
排他约束底层索引同上绝不能删
新建但尚未上线功能的索引应用还没发布等等再看
周期性任务专用索引例如月末报表才用一次看 last_idx_scan
sql
-- 排除约束支撑的索引后再列出候选
SELECT s.schemaname,
       s.relname      AS table_name,
       s.indexrelname AS index_name,
       s.idx_scan,
       pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes s
LEFT JOIN pg_index i  ON i.indexrelid = s.indexrelid
LEFT JOIN pg_constraint c ON c.conindid = s.indexrelid
WHERE s.idx_scan = 0
  AND c.oid IS NULL                 -- 排除约束底层索引
  AND NOT i.indisprimary            -- 排除主键索引
ORDER BY pg_relation_size(s.indexrelid) DESC;
text
 schemaname | table_name |     index_name      | idx_scan |  size
------------+------------+---------------------+----------+--------
 public     | logs       | idx_logs_created_at |        0 |  96 MB

用 last_idx_scan 判断「长期闲置」 ​

累计 idx_scan 只能说明「从重置到现在被用过」,无法回答「最近有没有用」。PostgreSQL 16 引入的 last_idx_scan 解决了这个问题:

sql
SELECT indexrelname,
       idx_scan,
       last_idx_scan,
       now() - last_idx_scan                     AS idle_for,
       pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan > 0
  AND (last_idx_scan IS NULL OR last_idx_scan < now() - interval '30 days')
ORDER BY pg_relation_size(indexrelid) DESC;
text
    indexrelname    | idx_scan |        idle_for        |  size
--------------------+----------+------------------------+--------
 idx_orders_legacy  |        2 | 139 days 01:12:33.421  |  44 MB

判断标准建议:

text
last_idx_scan 距今 < 7 天     → 活跃,保留
7 天 ~ 90 天                  → 观察,确认是否有周期性用途
> 90 天(或覆盖完整业务周期) → 候选删除

关键:观察窗口必须覆盖完整的业务周期。 如果业务有「季度结账」这类周期性负载,观察窗口至少一个季度,否则会误删只在季度末使用的索引。

找出「低效索引」 ​

idx_scan 高不代表索引好。低效索引的典型特征是「扫描次数多、每次读出的条目多、最终取回的行少」:

sql
SELECT indexrelname,
       idx_scan,
       idx_tup_read,
       idx_tup_fetch,
       round(idx_tup_read::numeric / nullif(idx_scan, 0), 0) AS read_per_scan,
       round(idx_tup_fetch::numeric / nullif(idx_tup_read, 0), 4) AS fetch_ratio
FROM pg_stat_user_indexes
WHERE idx_scan > 100
ORDER BY idx_tup_read DESC
LIMIT 10;
text
    indexrelname     | idx_scan | idx_tup_read | idx_tup_fetch | read_per_scan | fetch_ratio
---------------------+----------+--------------+---------------+---------------+-------------
 idx_events_status   |     1204 |     48290100 |          1022 |         40108 |      0.0000
 idx_events_user     |   184203 |       184203 |        183901 |             1 |      0.9984

解读:

text
read_per_scan 高 + fetch_ratio 极低
  → 每次扫描读几万条目,最后只取回极少数行
  → 索引选择性不足,或统计信息过期导致优化器误判

read_per_scan 低 + fetch_ratio 接近 1
  → 精准命中,健康

fetch_ratio 低有两种常见成因:

  1. 索引选择性差:在低基数列(如 status 只有 4 个取值)上建索引,命中比例太高。
  2. 统计信息过期:优化器对行数估算失真,选了不该选的索引。
sql
-- 查看索引列的基数和选择性,辅助判断
SELECT attname              AS column_name,
       n_distinct,
       correlation
FROM pg_stats
WHERE tablename = 'events' AND attname = 'status';
text
 column_name | n_distinct | correlation
-------------+------------+-------------
 status      |          4 |      0.0031

n_distinct = 4 直接说明这列基数极低,普通 B-Tree 索引很难带来收益——idx_events_status 的 fetch_ratio 约为 0,正是这个原因。这种索引要么删掉,要么改造成部分索引。

pg_statio_*:索引的 I/O 画像 ​

pg_stat_user_indexes 只统计「用了几次」,要了解 I/O 成本得看 pg_statio_* 系列:

sql
SELECT schemaname,
       relname                      AS table_name,
       indexrelname                 AS index_name,
       idx_blks_read,
       idx_blks_hit,
       round(100.0 * idx_blks_hit / nullif(idx_blks_hit + idx_blks_read, 0), 2) AS hit_pct
FROM pg_statio_user_indexes
WHERE indexrelname = 'idx_events_user';
text
 schemaname | table_name |   index_name    | idx_blks_read | idx_blks_hit | hit_pct
------------+------------+-----------------+---------------+--------------+---------
 public     | events     | idx_events_user |            48 |      1920441 |   99.99
列含义
idx_blks_read从磁盘读取的索引块数(未命中缓存)
idx_blks_hit从共享缓冲区命中的索引块数

hit_pct 接近 100% 说明索引完全驻留内存;若 idx_blks_read 持续走高,说明索引太大装不下,此时缩小索引体积(重建或改部分索引)收益会很大。

text
hit_pct > 99%  → 索引小且热,状态良好
hit_pct 90~99% → 偶尔落盘,可接受
hit_pct < 90%  → 频繁读盘,考虑缩减体积或调整 shared_buffers

用 Index Scan 反向验证:pg_stat_all_tables ​

索引的统计要和表的访问方式对照看。pg_stat_user_tables 的 seq_scan 与 idx_scan 比例能揭示「表是不是被全表扫穿了」:

sql
SELECT relname,
       seq_scan,
       idx_scan,
       n_live_tup,
       seq_tup_read,
       idx_tup_fetch
FROM pg_stat_user_tables
WHERE relname = 'events';
text
 relname | seq_scan | idx_scan | n_live_tup | seq_tup_read | idx_tup_fetch
---------+----------+----------+------------+--------------+---------------
 events  |       14 |  1096454 |    3000000 |      4200000 |        274922
text
seq_scan 很低、idx_scan 很高 → 索引被有效利用
seq_scan 很高、表很大        → 可能缺索引,或优化器判断不出走索引的价值
n_live_tup 很小 + seq_scan 高 → 表很小,全表扫本来就更快,无需索引

一个常见的误判:小表上的索引 idx_scan = 0。因为优化器对小表总是选择顺序扫描(更便宜),索引自然用不上。这类索引不是「没用」,而是「不需要」——同样可以删。

统计的重置与一致性控制 ​

stats_fetch_consistency 决定「同一事务内多次查视图能否得到一致结果」:

取值行为适用
cache(默认)首次访问缓存快照到事务结束,后续读缓存常规监控、成本最低
snapshot每次读都取新快照,事务内一致需要一致性的报表
none每次都从收集器重新取调试、成本最高
sql
-- 手动清除当前事务内的统计快照,强制下次重新读取
SELECT pg_stat_clear_snapshot();
text
 pg_stat_clear_snapshot
------------------------

重置统计(谨慎,会丢失历史数据):

sql
-- 重置当前库所有统计
SELECT pg_stat_reset();

-- 只重置指定表的索引统计
SELECT pg_stat_reset_single_table_counters('events'::regclass);

-- 重置共享统计(含 pg_stat_bgwriter 等)
SELECT pg_stat_reset_shared('bgwriter');
text
 pg_stat_reset
---------------

重置统计会抹掉评估依据

pg_stat_reset() 之后所有 idx_scan 归零,你此前的「无用索引」判断基础就没了。重置前建议先把结果快照到自己的监控表里。

监控的持久化与外部集成 ​

系统视图的数据在实例重启(非干净关闭)时会丢,要做长期趋势必须定期采样落库:

sql
CREATE TABLE IF NOT EXISTS index_usage_snapshot (
  captured_at  timestamptz NOT NULL DEFAULT now(),
  schema_name  text        NOT NULL,
  table_name   text        NOT NULL,
  index_name   text        NOT NULL,
  idx_scan     bigint      NOT NULL,
  size_bytes   bigint      NOT NULL
);

INSERT INTO index_usage_snapshot
  (schema_name, table_name, index_name, idx_scan, size_bytes)
SELECT schemaname, relname, indexrelname,
       idx_scan, pg_relation_size(indexrelid)
FROM pg_stat_user_indexes;
text
INSERT 0 42
sql
-- 对比两次快照,得出「这段时间内真实被使用了多少次」
WITH cur AS (
  SELECT index_name, idx_scan
  FROM index_usage_snapshot
  WHERE captured_at = (SELECT max(captured_at) FROM index_usage_snapshot)
), old AS (
  SELECT index_name, idx_scan
  FROM index_usage_snapshot
  WHERE captured_at = (SELECT max(captured_at) FROM index_usage_snapshot
                       WHERE captured_at < (SELECT max(captured_at) FROM index_usage_snapshot))
)
SELECT c.index_name,
       c.idx_scan - old.idx_scan AS scans_in_window
FROM cur c JOIN old ON old.index_name = c.index_name
ORDER BY scans_in_window;
text
     index_name      | scans_in_window
---------------------+-----------------
 idx_logs_created_at |               0
 idx_events_user     |          182044

这是最可靠的判断方式:直接看窗口内增量,不受统计重置影响,也天然对齐业务周期。

实战示例 ​

下面用一张电商 orders 表,完整演示「识别无用索引 → 验证 → 安全删除」的流程。

步骤一:准备带冗余索引的表 ​

sql
DROP TABLE IF EXISTS orders;

CREATE TABLE orders (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id     bigint        NOT NULL,
  status      text          NOT NULL,     -- 只有 4 个取值,低基数
  total       numeric(12,2) NOT NULL,
  created_at  timestamptz   NOT NULL DEFAULT now()
);

INSERT INTO orders (user_id, status, total)
SELECT (random() * 20000)::bigint,
       (ARRAY['pending','paid','shipped','refunded'])[1 + (random() * 3)::int],
       random() * 1000
FROM generate_series(1, 1000000);

-- 有用的索引:高基数列
CREATE INDEX idx_orders_user ON orders (user_id);

-- 可疑索引一:低基数列
CREATE INDEX idx_orders_status ON orders (status);

-- 可疑索引二:与主键列重复(id 已是主键)
CREATE INDEX idx_orders_id ON orders (id);

ANALYZE orders;
text
DROP TABLE
CREATE TABLE
INSERT 0 1000000
CREATE INDEX
CREATE INDEX
CREATE INDEX
ANALYZE

步骤二:模拟真实负载 ​

sql
-- 应用主要按 user_id 查询,并带状态过滤
SELECT * FROM orders WHERE user_id = 1234 ORDER BY created_at DESC LIMIT 20;
SELECT count(*) FROM orders WHERE user_id = 5678 AND status = 'paid';
SELECT * FROM orders WHERE user_id = 9012;
text
(输出略)
sql
-- 让统计刷出(等待节流窗口并触发一次事务结束)
SELECT pg_sleep(2);
SELECT 1 AS flush_probe;
text
 flush_probe
-------------
           1

步骤三:查看索引使用情况 ​

sql
SELECT indexrelname,
       idx_scan,
       last_idx_scan,
       pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'orders'
ORDER BY idx_scan DESC;
text
   indexrelname     | idx_scan |        last_idx_scan       |  size
--------------------+----------+----------------------------+-------
 orders_pkey        |      510 | 2026-09-28 10:05:11.204+08 | 30 MB
 idx_orders_user    |        3 | 2026-09-28 10:05:09.882+08 | 21 MB
 idx_orders_status  |        0 |                            | 18 MB
 idx_orders_id      |        0 |                            | 21 MB

结论清晰:

text
orders_pkey        有扫描(可能来自 ORDER BY id 之外的内部访问)
idx_orders_user    3 次,匹配我们跑的三条按 user_id 的查询
idx_orders_status  从未被扫描
idx_orders_id      从未被扫描,且与主键完全重复

步骤四:交叉验证再决定删除 ​

先确认不是「约束底层索引」:

sql
SELECT c.conname,
       c.contype,
       c.conindid::regclass AS backing_index
FROM pg_constraint c
WHERE c.conrelid = 'orders'::regclass;
text
   conname   | contype | backing_index
-------------+---------+---------------
 orders_pkey | p       | orders_pkey

idx_orders_status 与 idx_orders_id 都不在约束背后,删除是安全的。再确认列为低基数:

sql
SELECT attname, n_distinct, correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname IN ('status', 'id', 'user_id');
text
 attname  | n_distinct | correlation
----------+------------+-------------
 id       |         -1 |         1.0
 user_id  |      19983 |       0.001
 status   |          4 |       0.002

status 的 n_distinct = 4 印证了低基数问题。

步骤五:安全删除 ​

sql
-- 用 CONCURRENTLY 避免长时间阻塞写入
DROP INDEX CONCURRENTLY idx_orders_status;
DROP INDEX CONCURRENTLY idx_orders_id;
text
DROP INDEX
DROP INDEX
sql
-- 回收空间(可选,视情况而定)
VACUUM orders;
text
VACUUM
sql
-- 确认删除结果
SELECT indexrelname,
       pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'orders';
text
  indexrelname   |  size
-----------------+--------
 orders_pkey     |  30 MB
 idx_orders_user |  21 MB

删掉两个无用索引后,orders 表的索引占用从 90 MB 降到 51 MB,后续每次插入要维护的索引从 5 个减到 3 个——写入性能的改善往往比省下的磁盘更值钱。

注意事项 ​

  • 别在统计刚重置后判断索引无用。 先看 pg_stat_database.stats_reset,确认观察窗口足够长。

    sql
    SELECT stats_reset, now() - stats_reset AS uptime
    FROM pg_stat_database WHERE datname = current_database();
  • 绝不能删约束底层索引。 主键、唯一、排他约束的索引 idx_scan 可能为 0,但它们是约束的实现载体。

    sql
    -- 错误!会导致约束被删除,破坏数据完整性
    DROP INDEX orders_pkey;
  • 观察窗口要覆盖完整业务周期。 只在工作日观察,会误删周末批处理专用索引。

  • idx_scan = 0 不等于「没用过」。 统计可能刚被重置;索引也可能刚建好还没上线。

  • idx_scan 高不代表索引好。 要结合 idx_tup_read / idx_tup_fetch 判断效率,否则会漏掉「扫描多但收益低」的低效索引。

    sql
    -- 不推荐:只看 idx_scan 就下结论
    SELECT indexrelname, idx_scan FROM pg_stat_user_indexes ORDER BY idx_scan DESC;
  • 统计不是实时的。 刚执行的查询可能还没计入;验证时加个短暂等待或触发事务结束。

  • 不要用 pg_stat_reset() 频繁重置。 历史计数是判断趋势的重要依据,重置等于自毁依据。

  • 删除索引用 DROP INDEX CONCURRENTLY。 普通 DROP INDEX 会加 ACCESS EXCLUSIVE 锁阻塞读写。

    sql
    -- 推荐
    DROP INDEX CONCURRENTLY idx_orders_status;
  • 小表上的索引不用过度在意。 优化器对小表本来就会选顺序扫描,idx_scan = 0 属正常。

  • 副本上的统计是独立的。 主库与只读副本各自维护统计,判断索引使用情况要在真正承受该负载的实例上做。

  • 别只看索引,也要看表。 索引无人使用可能说明查询模式已经变了,值得回头审视整体设计。

最佳实践 ​

  • 建立「增量快照」机制,而非依赖累计值。 定期把 idx_scan 落库,用两次快照之差判断真实使用量。

    sql
    INSERT INTO index_usage_snapshot (schema_name, table_name, index_name, idx_scan, size_bytes)
    SELECT schemaname, relname, indexrelname, idx_scan, pg_relation_size(indexrelid)
    FROM pg_stat_user_indexes;
  • 优先处理「大 + 零使用」的索引。 收益最高、风险最低。

    sql
    SELECT indexrelname,
           pg_size_pretty(pg_relation_size(indexrelid)) AS size
    FROM pg_stat_user_indexes
    WHERE idx_scan = 0
    ORDER BY pg_relation_size(indexrelid) DESC
    LIMIT 20;
  • 删除前做三重确认:不是约束索引、观察窗口足够、last_idx_scan 足够久。

    sql
    SELECT s.indexrelname, s.idx_scan, s.last_idx_scan,
           c.conname AS constraint_name
    FROM pg_stat_user_indexes s
    LEFT JOIN pg_constraint c ON c.conindid = s.indexrelid
    WHERE s.idx_scan = 0;
  • 用 last_idx_scan 而非仅 idx_scan 做长期闲置判断。 前者能直接给出「最后一次使用时间」。

    sql
    SELECT indexrelname, last_idx_scan,
           now() - last_idx_scan AS idle
    FROM pg_stat_user_indexes
    WHERE last_idx_scan < now() - interval '90 days'
       OR last_idx_scan IS NULL;
  • 定期复查低效索引,用 fetch_ratio 做筛选。 扫描频繁但取回率极低的索引,应改造为部分索引或删除。

    sql
    SELECT indexrelname, idx_scan, idx_tup_read, idx_tup_fetch
    FROM pg_stat_user_indexes
    WHERE idx_scan > 100
      AND idx_tup_fetch::numeric / nullif(idx_tup_read, 0) < 0.01;
  • 把索引体积纳入巡检。 结合 pg_statio_* 的 hit_pct,判断缓存是否被大索引挤占。

    sql
    SELECT indexrelname,
           pg_size_pretty(pg_relation_size(indexrelid)) AS size,
           idx_blks_hit, idx_blks_read
    FROM pg_statio_user_indexes
    ORDER BY pg_relation_size(indexrelid) DESC
    LIMIT 10;
  • 删除索引一律走 CONCURRENTLY,并在低峰执行。

    sql
    DROP INDEX CONCURRENTLY idx_orders_id;
  • 把「索引数量」当作一项容量指标来管。 每张表的索引数建议控制在 5~6 个以内,写密集表更要克制。

  • 监控脚本与变更流程解耦。 脚本只负责发现并产出报告,删除动作走独立的评审与执行流程。

  • 记录删除决策与依据。 记下删除时间、idx_scan、last_idx_scan 与体积,便于事后追溯与恢复。

  • 删除后观察一个业务周期再彻底确认。 若发现负面影响,可快速重建回来。

    sql
    -- 回滚手段:重新建回索引
    CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);

下一节

继续学习:索引设计最佳实践

参考链接 ​