PostgreSQL性能优化:如何定期清理无用索引以释放磁盘空间(索引膨胀监控)

文章目录

在 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)

  • 当表发生 UPDATEDELETE 时,旧索引项不会立即删除,而是标记为 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 天使用情况,无效则清理

开发规范建议

  1. 索引上线需评审:提供 EXPLAIN 计划证明必要性;
  2. 命名规范 :如 idx_tab_col1_col2,便于识别;
  3. 定期清理:每季度执行一次索引健康检查。

结语:索引是性能的双刃剑------用得好,查询飞快;用不好,拖垮系统。通过本文所述方法,你可系统性地:

  • 识别僵尸索引,释放磁盘与内存;
  • 检测索引膨胀,保持高效结构;
  • 安全删除/重建,避免服务中断;
  • 建立自动化机制,实现持续优化。

记住:最好的索引,是刚好够用的索引。定期清理无用索引,不仅是节省成本,更是对数据库健康负责。

相关推荐
喵叔哟5 小时前
67.【.NET8 实战--孢子记账--从单体到微服务--转向微服务】--新增功能--分摊功能总体设计与业务流程
数据库·微服务·架构
tryCbest5 小时前
Oracle查看存储过程
数据库·oracle
咩咩不吃草5 小时前
【MySQL】表和列、增删改查语句及数据类型约束详解
数据库·mysql·语法
不懒不懒5 小时前
【MySQL 实战:从零搭建规范用户表(含完整 SQL 与避坑指南)】
数据库
ID_180079054735 小时前
Python结合淘宝关键词API进行商品价格监控与预警
服务器·数据库·python
Light605 小时前
Vue 的 defineAsyncComponent、import.meta.glob、Component、Suspense:现代前端零侵入架构的必备能力
性能优化·代码分割·vue3异步组件·自动化注册·智能加载
数据知道5 小时前
PostgreSQL 故障排查:万字详解如何找出数据库中的死锁
数据库·postgresql
John_ToDebug5 小时前
Chromium回调机制的隐秘角落:当const &参数遇见base::BindOnce
c++·chrome·性能优化
DemonAvenger5 小时前
Kafka消费者深度剖析:消费组与再平衡原理
性能优化·kafka·消息队列