索引使用监控
建索引容易,删索引难。一个从不被使用的索引不会报错,它只是安静地占着磁盘、拖慢每一次写入、被备份复制一遍又一遍。判断「哪个索引真的在被使用」,靠的不是直觉,而是 PostgreSQL 内置的统计收集器(Statistics Collector)。
系统视图 pg_stat_user_indexes 为每个索引维护一行计数器,用 idx_scan 记录它被扫描的次数。看起来简单,但语义有坑:计数什么时候清零?空闲连接会丢数据吗?为什么昨天还在用的索引今天 idx_scan = 0?本节把这些讲清楚,并给出一套可落地的「找出无用索引」巡检方法。
前置知识
阅读本节前,建议先了解:EXPLAIN 执行计划、索引膨胀检测、VACUUM 清理
基础概念
统计收集器:计数从哪来
PostgreSQL 有一个独立的统计收集器(Statistics Collector)进程,它从各后端收集运行时数据并汇总成系统视图。默认配置下:
track_counts = on # 是否收集表/索引的访问计数(默认 on)
track_io_timing = off # 是否收集 I/O 耗时(默认 off,开启有少量开销)
track_functions = none # 函数调用统计数据不是实时更新的——后端会把计数缓存在自己的私有内存里,在特定时机批量刷给收集器。因此:
后端执行索引扫描
│ 累加本地计数
▼
本地缓存(PGSTAT_MIN_INTERVAL,默认 1 秒节流)
│ 后端空闲 / 事务结束 / 缓存满
▼
统计收集器
│ 汇总
▼
pg_stat_* 视图统计的三个关键特性
| 特性 | 含义 | 后果 |
|---|---|---|
| 非实时 | 空闲前刷出,节流不低于 1 秒 | 刚执行的查询可能还没进视图 |
| 会重置 | 崩溃恢复、非干净关闭、pg_stat_reset() | 重启后计数归零,不可当永久累计值 |
| 可缓存 | stats_fetch_consistency 控制快照一致性 | 同一事务内多次查询结果可能一致或漂移 |
干净关闭(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 | 两者之和 |
关键列及其语义:
| 列 | 类型 | 含义 |
|---|---|---|
relid | oid | 索引所属表的 OID |
indexrelid | oid | 索引自身的 OID |
schemaname | name | 模式名 |
relname | name | 表名 |
indexrelname | name | 索引名 |
idx_scan | bigint | 该索引上发起的索引扫描次数 |
last_idx_scan | timestamptz | 最近一次被扫描的时间(PG 16+) |
idx_tup_read | bigint | 扫描返回的索引项数量 |
idx_tup_fetch | bigint | 由简单索引扫描从表取回的活行数 |
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 之前,先确认统计没被重置。
-- 查看统计系统本身的运行状态
SELECT stats_reset FROM pg_stat_database WHERE datname = current_database(); stats_reset
-------------------------------
2026-09-01 03:00:12.448+08如果这个时间是几天前,说明统计已稳定运行,idx_scan = 0 才可信;如果是十分钟前,别急着下结论。
坑二:idx_scan 不区分「有用扫描」与「无效扫描」。 一次索引扫描把上万条目读出来又被 Filter 全部滤掉,照样计入 idx_scan。所以高 idx_scan 也可能是个坏索引,要结合 idx_tup_read 一起看。
语法与用法
最常用的巡检查询:
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; 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。
观察最近使用时间:
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; 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 MBlast_idx_scan 为 NULL 表示自统计重置以来从未被扫描过——这是最强有力的「无用索引」证据。
详细说明
idx_scan / idx_tup_read / idx_tup_fetch 的精确语义
三个计数器的定义常常被混淆,把官方语义拆开:
| 计数器 | 触发时机 | 是否含过滤掉的条目 | 是否触发回表 |
|---|---|---|---|
idx_scan | 一次索引扫描启动 | — | — |
idx_tup_read | 每读出一个索引条目 | 是 | 否 |
idx_tup_fetch | 每从表取回一个活行 | 否(只算最终取回的) | 是 |
WHERE 条件命中率低时:
idx_scan = 1
idx_tup_read = 10000 ← 索引里读了 1 万个条目
idx_tup_fetch = 2 ← 只有 2 行通过过滤被取回
这种情况说明索引选择性差:扫描代价高、收益低。反过来,一个选择性好的索引应该呈现:
idx_scan = 5000
idx_tup_read = 5200 ← 每次扫描平均读 1 个条目
idx_tup_fetch = 5100 ← 几乎都取回了-- 计算「平均每次扫描读多少条目」,衡量扫描效率
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; indexrelname | idx_scan | idx_tup_read | tup_per_scan
---------------------+----------+--------------+--------------
idx_events_status | 1204 | 48290100 | 40108.06
idx_events_user | 184203 | 184203 | 1.00tup_per_scan 高达 4 万,说明每次扫描几乎翻遍整个索引——这样的索引很可能不如直接顺序扫描。
Index Only Scan 与 idx_tup_fetch
仅索引扫描不回表,因此不累加 idx_tup_fetch。看到某个覆盖索引 idx_scan 很高但 idx_tup_fetch 很低(甚至为 0),往往说明它正在高效地服务仅索引扫描。
找出从未使用的索引
巡检的核心目标。基础查询:
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; 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 |
-- 排除约束支撑的索引后再列出候选
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; 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 解决了这个问题:
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; indexrelname | idx_scan | idle_for | size
--------------------+----------+------------------------+--------
idx_orders_legacy | 2 | 139 days 01:12:33.421 | 44 MB判断标准建议:
last_idx_scan 距今 < 7 天 → 活跃,保留
7 天 ~ 90 天 → 观察,确认是否有周期性用途
> 90 天(或覆盖完整业务周期) → 候选删除关键:观察窗口必须覆盖完整的业务周期。 如果业务有「季度结账」这类周期性负载,观察窗口至少一个季度,否则会误删只在季度末使用的索引。
找出「低效索引」
idx_scan 高不代表索引好。低效索引的典型特征是「扫描次数多、每次读出的条目多、最终取回的行少」:
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; 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解读:
read_per_scan 高 + fetch_ratio 极低
→ 每次扫描读几万条目,最后只取回极少数行
→ 索引选择性不足,或统计信息过期导致优化器误判
read_per_scan 低 + fetch_ratio 接近 1
→ 精准命中,健康fetch_ratio 低有两种常见成因:
- 索引选择性差:在低基数列(如
status只有 4 个取值)上建索引,命中比例太高。 - 统计信息过期:优化器对行数估算失真,选了不该选的索引。
-- 查看索引列的基数和选择性,辅助判断
SELECT attname AS column_name,
n_distinct,
correlation
FROM pg_stats
WHERE tablename = 'events' AND attname = 'status'; column_name | n_distinct | correlation
-------------+------------+-------------
status | 4 | 0.0031n_distinct = 4 直接说明这列基数极低,普通 B-Tree 索引很难带来收益——idx_events_status 的 fetch_ratio 约为 0,正是这个原因。这种索引要么删掉,要么改造成部分索引。
pg_statio_*:索引的 I/O 画像
pg_stat_user_indexes 只统计「用了几次」,要了解 I/O 成本得看 pg_statio_* 系列:
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'; 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 持续走高,说明索引太大装不下,此时缩小索引体积(重建或改部分索引)收益会很大。
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 比例能揭示「表是不是被全表扫穿了」:
SELECT relname,
seq_scan,
idx_scan,
n_live_tup,
seq_tup_read,
idx_tup_fetch
FROM pg_stat_user_tables
WHERE relname = 'events'; relname | seq_scan | idx_scan | n_live_tup | seq_tup_read | idx_tup_fetch
---------+----------+----------+------------+--------------+---------------
events | 14 | 1096454 | 3000000 | 4200000 | 274922seq_scan 很低、idx_scan 很高 → 索引被有效利用
seq_scan 很高、表很大 → 可能缺索引,或优化器判断不出走索引的价值
n_live_tup 很小 + seq_scan 高 → 表很小,全表扫本来就更快,无需索引一个常见的误判:小表上的索引 idx_scan = 0。因为优化器对小表总是选择顺序扫描(更便宜),索引自然用不上。这类索引不是「没用」,而是「不需要」——同样可以删。
统计的重置与一致性控制
stats_fetch_consistency 决定「同一事务内多次查视图能否得到一致结果」:
| 取值 | 行为 | 适用 |
|---|---|---|
cache(默认) | 首次访问缓存快照到事务结束,后续读缓存 | 常规监控、成本最低 |
snapshot | 每次读都取新快照,事务内一致 | 需要一致性的报表 |
none | 每次都从收集器重新取 | 调试、成本最高 |
-- 手动清除当前事务内的统计快照,强制下次重新读取
SELECT pg_stat_clear_snapshot(); pg_stat_clear_snapshot
------------------------重置统计(谨慎,会丢失历史数据):
-- 重置当前库所有统计
SELECT pg_stat_reset();
-- 只重置指定表的索引统计
SELECT pg_stat_reset_single_table_counters('events'::regclass);
-- 重置共享统计(含 pg_stat_bgwriter 等)
SELECT pg_stat_reset_shared('bgwriter'); pg_stat_reset
---------------重置统计会抹掉评估依据
pg_stat_reset() 之后所有 idx_scan 归零,你此前的「无用索引」判断基础就没了。重置前建议先把结果快照到自己的监控表里。
监控的持久化与外部集成
系统视图的数据在实例重启(非干净关闭)时会丢,要做长期趋势必须定期采样落库:
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;INSERT 0 42-- 对比两次快照,得出「这段时间内真实被使用了多少次」
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; index_name | scans_in_window
---------------------+-----------------
idx_logs_created_at | 0
idx_events_user | 182044这是最可靠的判断方式:直接看窗口内增量,不受统计重置影响,也天然对齐业务周期。
实战示例
下面用一张电商 orders 表,完整演示「识别无用索引 → 验证 → 安全删除」的流程。
步骤一:准备带冗余索引的表
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;DROP TABLE
CREATE TABLE
INSERT 0 1000000
CREATE INDEX
CREATE INDEX
CREATE INDEX
ANALYZE步骤二:模拟真实负载
-- 应用主要按 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;(输出略)-- 让统计刷出(等待节流窗口并触发一次事务结束)
SELECT pg_sleep(2);
SELECT 1 AS flush_probe; flush_probe
-------------
1步骤三:查看索引使用情况
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; 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结论清晰:
orders_pkey 有扫描(可能来自 ORDER BY id 之外的内部访问)
idx_orders_user 3 次,匹配我们跑的三条按 user_id 的查询
idx_orders_status 从未被扫描
idx_orders_id 从未被扫描,且与主键完全重复步骤四:交叉验证再决定删除
先确认不是「约束底层索引」:
SELECT c.conname,
c.contype,
c.conindid::regclass AS backing_index
FROM pg_constraint c
WHERE c.conrelid = 'orders'::regclass; conname | contype | backing_index
-------------+---------+---------------
orders_pkey | p | orders_pkeyidx_orders_status 与 idx_orders_id 都不在约束背后,删除是安全的。再确认列为低基数:
SELECT attname, n_distinct, correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname IN ('status', 'id', 'user_id'); attname | n_distinct | correlation
----------+------------+-------------
id | -1 | 1.0
user_id | 19983 | 0.001
status | 4 | 0.002status 的 n_distinct = 4 印证了低基数问题。
步骤五:安全删除
-- 用 CONCURRENTLY 避免长时间阻塞写入
DROP INDEX CONCURRENTLY idx_orders_status;
DROP INDEX CONCURRENTLY idx_orders_id;DROP INDEX
DROP INDEX-- 回收空间(可选,视情况而定)
VACUUM orders;VACUUM-- 确认删除结果
SELECT indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'orders'; indexrelname | size
-----------------+--------
orders_pkey | 30 MB
idx_orders_user | 21 MB删掉两个无用索引后,orders 表的索引占用从 90 MB 降到 51 MB,后续每次插入要维护的索引从 5 个减到 3 个——写入性能的改善往往比省下的磁盘更值钱。
注意事项
别在统计刚重置后判断索引无用。 先看
pg_stat_database.stats_reset,确认观察窗口足够长。sqlSELECT 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落库,用两次快照之差判断真实使用量。sqlINSERT 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;优先处理「大 + 零使用」的索引。 收益最高、风险最低。
sqlSELECT 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足够久。sqlSELECT 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做长期闲置判断。 前者能直接给出「最后一次使用时间」。sqlSELECT 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做筛选。 扫描频繁但取回率极低的索引,应改造为部分索引或删除。sqlSELECT 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,判断缓存是否被大索引挤占。sqlSELECT 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,并在低峰执行。sqlDROP INDEX CONCURRENTLY idx_orders_id;把「索引数量」当作一项容量指标来管。 每张表的索引数建议控制在 5~6 个以内,写密集表更要克制。
监控脚本与变更流程解耦。 脚本只负责发现并产出报告,删除动作走独立的评审与执行流程。
记录删除决策与依据。 记下删除时间、
idx_scan、last_idx_scan与体积,便于事后追溯与恢复。删除后观察一个业务周期再彻底确认。 若发现负面影响,可快速重建回来。
sql-- 回滚手段:重新建回索引 CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);
下一节
继续学习:索引设计最佳实践