文章目录
-
- 一、为什么需要清理无用索引?
-
- [1. 资源浪费](#1. 资源浪费)
- [2. 性能损耗](#2. 性能损耗)
- [3. 索引膨胀(Index Bloat)](#3. 索引膨胀(Index Bloat))
- 二、第一步:识别从未使用的索引
-
- [1. 启用索引统计(前提)](#1. 启用索引统计(前提))
- [2. 查询未使用索引](#2. 查询未使用索引)
- [3. 排除约束类索引](#3. 排除约束类索引)
- 三、第二步:评估"低效"索引(非零但极少使用)
-
- [1. 计算索引使用效率](#1. 计算索引使用效率)
- [2. 结合查询日志分析](#2. 结合查询日志分析)
- 四、第三步:检测索引膨胀(Bloat)
-
- [1. 使用社区脚本估算膨胀率](#1. 使用社区脚本估算膨胀率)
-
- [方法一:使用 `pgstattuple` 扩展(精确但锁表)](#方法一:使用
pgstattuple扩展(精确但锁表)) - 方法二:使用轻量级估算脚本(推荐)
- [方法一:使用 `pgstattuple` 扩展(精确但锁表)](#方法一:使用
- 五、第四步:安全删除无用索引
-
- [1. 删除前 checklist](#1. 删除前 checklist)
- [2. 执行删除](#2. 执行删除)
- [3. 验证删除效果](#3. 验证删除效果)
- 六、第五步:重建膨胀索引(而非删除)
-
- [1. 重建命令](#1. 重建命令)
- [2. 自动化重建策略](#2. 自动化重建策略)
- 七、建立自动化监控与治理机制
-
- [1. 创建监控视图](#1. 创建监控视图)
- [2. 设置告警](#2. 设置告警)
- [3. 定期巡检脚本(Shell + psql)](#3. 定期巡检脚本(Shell + psql))
- 八、最佳实践总结
在 PostgreSQL 的长期运行中,索引数量往往会随着业务迭代不断增长 。然而,并非所有索引都被有效利用------有些是历史遗留、有些因查询模式变更而失效、有些甚至从未被使用过。这些"僵尸索引"不仅 浪费宝贵的磁盘空间 ,还会 拖慢 DML 操作(INSERT/UPDATE/DELETE),因为每次数据变更都需同步更新所有相关索引。
更严重的是,PostgreSQL 的 MVCC 机制会导致 索引膨胀(Index Bloat)------当表数据频繁更新或删除时,索引中的 dead tuples 无法及时回收,使得索引物理大小远超实际需求,进一步加剧 I/O 压力与查询延迟。
本文将系统性地阐述 如何识别、评估、安全删除无用索引 ,并建立 自动化监控与治理机制,帮助你持续优化数据库存储效率与性能。
一、为什么需要清理无用索引?
1. 资源浪费
- 磁盘空间:每个索引都是独立的物理结构,占用与表相当甚至更多的空间;
- 内存缓存:索引页会进入 shared_buffers 和 OS cache,挤占热数据空间;
- WAL 日志:索引变更产生额外 WAL,增加主从复制负担。
2. 性能损耗
- 写放大 :每新增一个索引,
INSERT/UPDATE/DELETE需多一次索引维护; - 优化器负担:过多索引使查询计划选择更复杂,可能选错执行路径;
- VACUUM 压力:需扫描更多索引清理 dead tuples。
3. 索引膨胀(Index Bloat)
- 当表发生
UPDATE或DELETE时,旧索引项不会立即删除,而是标记为 dead; - 若
autovacuum不及时,dead tuples 积累,索引体积膨胀; - 膨胀后的索引导致:
- 更多随机 I/O;
- 更低的缓存命中率;
- 更长的查询时间。
📊 经验数据:生产环境中,10%~30% 的索引可能从未被使用。
二、第一步:识别从未使用的索引
PostgreSQL 提供了 索引使用统计信息,可精准定位"零访问"索引。
1. 启用索引统计(前提)
确保 postgresql.conf 中已开启:
conf
track_counts = on # 默认开启
track_io_timing = off # 可选
无需重启,但统计信息从开启后才开始累积。
2. 查询未使用索引
sql
SELECT
schemaname AS schema,
tablename AS table,
indexname AS index,
pg_size_pretty(pg_relation_size(quote_ident(schemaname) || '.' || quote_ident(indexname))) AS index_size,
idx_tup_read, -- 索引扫描返回的行数(Index Scan)
idx_tup_fetch -- 通过索引获取的堆元组数(Index Only Scan + Bitmap Heap Scan)
FROM pg_stat_user_indexes
WHERE idx_tup_read = 0
AND idx_tup_fetch = 0
ORDER BY pg_relation_size(quote_ident(schemaname) || '.' || quote_ident(indexname)) DESC;
- 关键条件 :
idx_tup_read = 0 AND idx_tup_fetch = 0; - 注意 :该统计基于 自上次统计重置以来 的数据;
- 排除对象 :
- 主键(
PRIMARY KEY)和唯一约束(UNIQUE)索引(即使未用于查询,也用于约束校验); - 外键引用的索引(用于级联操作加速)。
- 主键(
3. 排除约束类索引
sql
-- 获取所有约束索引
WITH constraint_indexes AS (
SELECT conindid AS index_oid
FROM pg_constraint
WHERE contype IN ('p', 'u', 'f') -- primary, unique, foreign key
)
SELECT
n.nspname AS schema,
c.relname AS table,
i.relname AS index,
pg_size_pretty(pg_relation_size(i.oid)) AS size
FROM pg_index idx
JOIN pg_class i ON i.oid = idx.indexrelid
JOIN pg_class c ON c.oid = idx.indrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN constraint_indexes ci ON ci.index_oid = i.oid
WHERE ci.index_oid IS NULL -- 非约束索引
AND (SELECT idx_tup_read + idx_tup_fetch
FROM pg_stat_user_indexes
WHERE indexrelid = i.oid) = 0
ORDER BY pg_relation_size(i.oid) DESC;
✅ 此结果集中的索引 可安全评估删除。
三、第二步:评估"低效"索引(非零但极少使用)
有些索引虽有少量访问,但收益远低于维护成本。
1. 计算索引使用效率
sql
SELECT
schemaname,
tablename,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_tup_read,
idx_tup_fetch,
idx_scan, -- 索引被扫描次数
ROUND(
(idx_tup_read + idx_tup_fetch) * 100.0 / NULLIF(idx_scan, 0), 2
) AS avg_rows_per_scan
FROM pg_stat_user_indexes
WHERE idx_scan > 0
ORDER BY pg_relation_size(indexrelid) DESC;
- 低效特征 :
idx_scan很高,但avg_rows_per_scan极低(如 < 10)→ 可能走错索引;idx_scan极低(如 < 10 次/天),但索引很大(>1GB)→ 收益不足。
2. 结合查询日志分析
使用 pg_stat_statements 查看哪些查询实际使用了该索引:
sql
-- 需先启用 pg_stat_statements
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT
query,
calls,
total_exec_time,
rows
FROM pg_stat_statements
WHERE query LIKE '%INDEX_NAME%' -- 替换为实际索引名(需解析执行计划)
ORDER BY total_exec_time DESC;
💡 更准确方式:通过
EXPLAIN (ANALYZE, BUFFERS)确认执行计划是否使用目标索引。
四、第三步:检测索引膨胀(Bloat)
即使索引被使用,也可能因膨胀而效率低下。
1. 使用社区脚本估算膨胀率
PostgreSQL 官方未提供内置膨胀视图,但社区有成熟脚本。
方法一:使用 pgstattuple 扩展(精确但锁表)
sql
-- 安装扩展
CREATE EXTENSION pgstattuple;
-- 分析单个索引(会加 AccessShareLock,短暂阻塞)
SELECT * FROM pgstatindex('your_index_name');
输出关键字段:
avg_leaf_density:叶子页填充率(理想 >80%);leaf_pages:实际叶子页数;estimated_pages:理论所需页数;- 膨胀率 ≈ (leaf_pages - estimated_pages) / leaf_pages。
⚠️ 缺点:对大索引分析耗时长,不适用于高频监控。
方法二:使用轻量级估算脚本(推荐)
以下 SQL 基于统计信息估算膨胀,无锁、快速:
sql
-- 来源:https://github.com/ioguix/pgsql-bloat-estimation
WITH btree_index_atts AS (
SELECT
nspname, relname, reltuples, relpages, indrelid, relam,
regexp_split_to_table(indkey::text, ' ')::smallint AS attnum,
indexrelid AS index_oid
FROM pg_index
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
JOIN pg_am ON pg_class.relam = pg_am.oid
WHERE pg_am.amname = 'btree'
),
index_item_sizes AS (
SELECT
i.nspname, i.relname, i.reltuples, i.relpages, i.indrelid, i.relam, i.index_oid,
SUM(COALESCE(s.stawidth, 0)) + 8 AS item_size -- 8 bytes for ctid
FROM btree_index_atts i
LEFT JOIN pg_stats s ON s.schemaname = i.nspname
AND s.tablename = i.relname
AND s.attname = pg_get_indexdef(i.indrelid, i.attnum, true)
GROUP BY i.nspname, i.relname, i.reltuples, i.relpages, i.indrelid, i.relam, i.index_oid
),
index_aligned AS (
SELECT
nspname, relname, reltuples, relpages, indrelid, relam, index_oid,
CEIL(reltuples * item_size / (current_setting('block_size')::float - 24)) AS aligned_est_pages
FROM index_item_sizes
)
SELECT
nspname AS schema,
relname AS index,
indrelid::regclass AS table,
relpages AS actual_pages,
aligned_est_pages AS estimated_pages,
ROUND(100.0 * (relpages - aligned_est_pages) / relpages, 1) AS bloat_pct,
pg_size_pretty((relpages - aligned_est_pages) * current_setting('block_size')::int) AS wasted_space
FROM index_aligned
WHERE relpages > 100 -- 忽略小索引
AND aligned_est_pages > 0
AND (relpages - aligned_est_pages) > 100 -- 浪费 >100 pages
ORDER BY (relpages - aligned_est_pages) DESC;
bloat_pct > 30%且wasted_space > 100MB的索引建议重建。
五、第四步:安全删除无用索引
1. 删除前 checklist
- 确认非主键/唯一/外键约束索引;
- 确认
idx_tup_read = 0 AND idx_tup_fetch = 0; - 确认近期(如 7 天)无相关查询(结合应用日志);
- 在低峰期操作;
- 备份 DDL(以防误删)。
2. 执行删除
sql
-- 标准删除(会锁表,阻塞 DML)
DROP INDEX CONCURRENTLY your_schema.your_index_name;
✅ 务必使用
CONCURRENTLY!
- 普通
DROP INDEX会加ACCESS EXCLUSIVE锁,阻塞所有读写;CONCURRENTLY仅加SHARE UPDATE EXCLUSIVE锁,允许并发 DML。
3. 验证删除效果
sql
-- 确认索引消失
SELECT * FROM pg_indexes WHERE indexname = 'your_index_name';
-- 观察磁盘空间释放(可能需 VACUUM)
du -sh $PGDATA/base/...
💡 注意:
DROP INDEX立即释放磁盘空间 (与DELETE不同)。
六、第五步:重建膨胀索引(而非删除)
对于 有用但膨胀 的索引,应重建而非删除。
1. 重建命令
sql
-- 在线重建(PostgreSQL 12+)
REINDEX INDEX CONCURRENTLY your_index_name;
-- 旧版本需手动创建+切换
CREATE INDEX CONCURRENTLY new_index ON table (col);
DROP INDEX CONCURRENTLY old_index;
ALTER INDEX new_index RENAME TO old_index;
2. 自动化重建策略
- 对
bloat_pct > 50%且wasted_space > 1GB的索引自动重建; - 使用 cron 或 pg_cron 定期执行。
七、建立自动化监控与治理机制
1. 创建监控视图
sql
-- 无用索引视图
CREATE MATERIALIZED VIEW unused_indexes AS
SELECT ... -- 上述查询逻辑
REFRESH MATERIALIZED VIEW unused_indexes; -- 每日刷新
2. 设置告警
-
Prometheus + exporter 采集
pg_stat_user_indexes; -
告警规则:
yaml- alert: UnusedLargeIndex expr: pg_stat_user_indexes_idx_tup_read == 0 and pg_stat_user_indexes_size_bytes > 1e9 for: 7d labels: severity: warning
3. 定期巡检脚本(Shell + psql)
bash
#!/bin/bash
# weekly_index_cleanup.sh
psql -Atq -c "
SELECT 'DROP INDEX CONCURRENTLY ' || schemaname || '.' || indexname || ';'
FROM pg_stat_user_indexes
WHERE idx_tup_read = 0 AND idx_tup_fetch = 0
AND pg_relation_size(indexrelid) > 100*1024*1024 -- >100MB
AND indexrelid NOT IN (SELECT conindid FROM pg_constraint);
" | psql -e
⚠️ 脚本需人工审核后再执行!
八、最佳实践总结
| 场景 | 操作 |
|---|---|
| 从未使用的非约束索引 | DROP INDEX CONCURRENTLY |
| 低效大索引(极少使用) | 评估业务后删除 |
| 有用但膨胀的索引 | REINDEX INDEX CONCURRENTLY |
| 主键/唯一/外键索引 | 禁止删除,即使未用于查询 |
| 新上线索引 | 监控 7--30 天使用情况,无效则清理 |
开发规范建议
- 索引上线需评审:提供 EXPLAIN 计划证明必要性;
- 命名规范 :如
idx_tab_col1_col2,便于识别; - 定期清理:每季度执行一次索引健康检查。
结语:索引是性能的双刃剑------用得好,查询飞快;用不好,拖垮系统。通过本文所述方法,你可系统性地:
- 识别僵尸索引,释放磁盘与内存;
- 检测索引膨胀,保持高效结构;
- 安全删除/重建,避免服务中断;
- 建立自动化机制,实现持续优化。
记住:最好的索引,是刚好够用的索引。定期清理无用索引,不仅是节省成本,更是对数据库健康负责。