数据库响应变慢,第一反应往往是对症加索引,但很多时候问题恰恰出在已有索引的身上。索引在长期写入、更新、删除的过程中会逐渐膨胀,统计信息失真,甚至出现损坏,导致优化器放弃索引而选择全表扫描,慢查询随之而来。这篇文章从PostgreSQL索引维护的角度出发,讲清楚如何判断索引是否出了问题、如何安全地重建索引,以及如何建立日常维护机制。

一、慢查询与索引的因果关系
遇到慢查询,第一步是看执行计划。用EXPLAIN (ANALYZE, BUFFERS)跑一遍慢SQL,如果输出里出现Seq Scan而你的WHERE条件列上明明建了索引,那基本可以断定索引没有被使用。造成这种情况的原因通常有几类:统计信息过期导致优化器误判行数、索引膨胀导致随机IO成本估算过高、索引列上使用了函数或隐式类型转换导致匹配失败。
统计信息的问题可以通过ANALYZE快速解决,而索引膨胀和索引失效则需要更深入的排查。PostgreSQL的MVCC机制决定了UPDATE并不是原地修改,而是写入新版本行,旧行在事务提交后由VACUUM清理。索引页面中被清理出来的空间不会立即归还给操作系统,页面内部会出现大量空洞,这就是所谓的bloat。膨胀严重的索引,逻辑上可能只有几MB的数据,物理上却占用几百MB,查询时需要扫描更多页面,性能自然下降。
判断索引是否膨胀,可以用pgstattingspacer扩展的pgstatindex函数,它会给出一串指标。创建扩展的语句是CREATE EXTENSION pgstattuple;,然后针对某个索引执行:
SELECT * FROM pgstatindex('idx_orders_user_id');
-- 重点关注以下几个字段
-- leaf_fragmentation: 叶子页碎片率,超过30%建议关注
-- avg_leaf_density: 叶子页平均填充密度,低于60%说明膨胀明显
-- size: 索引实际大小如果avg_leaf_density低于60%,或者索引大小远超预期,就值得安排一次重建了。
二、索引重建的几种方式与选择
PostgreSQL提供了多种重建索引的方式,各有适用场景。最基础的是REINDEX INDEX 索引名,它会删除旧索引再重新构建,过程持有一个排他锁,阻塞该表上的读写。对生产环境来说,这种方式只适合维护窗口期使用,或者小表上操作。也可以用REINDEX TABLE 表名一次性重建某张表的全部索引,同样会锁表。
从PostgreSQL 12开始,REINDEX INDEX CONCURRENTLY 索引名成为生产环境的推荐方式。它不持有长事务锁,重建期间表仍可正常读写。代价是耗时更长,而且如果中途失败,会留下一个INVALID状态的索引,需要手动删除后重试。可以用下面的语句确认残留的无效索引:
SELECT indexrelid::regclass AS index_name FROM pg_index WHERE NOT indisvalid;
还有一种更灵活的做法:手动建一个新索引名不同的索引(加CONCURRENTLY),确认新索引有效后,在一个事务里删旧索引并对新索引执行ALTER INDEX ... RENAME。这种方式的好处是完全可控,失败风险最低,缺点是磁盘占用会短暂翻倍。
另外要提醒一点,重建并不总是最优解。如果膨胀率不高,跑一次普通的VACUUM配合ANALYZE可能就够了。重建适合膨胀严重、索引损坏或者需要调整填充因子的情况。
三、填充因子与索引寿命的延长
填充因子fillfactor控制索引页面的初始填充比例,默认是90。对于更新频繁的表,索引页预留空间不足会导致新版本行被挤到别的页面,加剧页面分裂和膨胀。为热点表设置更低的填充因子,比如80甚至70,可以让更新尽量在原页内完成,显著延缓膨胀速度。
调整填充因子需要在重建时进行,已存在的索引无法直接修改。对于B树索引可以这样操作:
-- 假设orders表更新频繁 REINDEX INDEX CONCURRENTLY idx_orders_user_id; ALTER INDEX idx_orders_user_id SET (fillfactor = 80); -- 注意上面的ALTER只影响后续的页分裂策略 -- 若要立即应用,需要在设置后重建一次
实际操作中更稳妥的顺序是:先删除原索引,然后带着fillfactor选项重建:
DROP INDEX CONCURRENTLY idx_orders_user_id; CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders (user_id) WITH (fillfactor = 80);
填充因子不是越小越好。预留太多空间会让索引变大,全索引扫描变慢,同时也占用更多内存缓存。一般写多读少的表设80左右,更新集中在少量列上的表收益最明显。
四、日常索引维护机制的建立
与其等慢查询爆发再处理,不如建立周期性的巡检。首先要定期执行ANALYZE(autovacuum默认会做,但高频写入表可以调低autovacuum_analyze_scale_factor),其次要监控索引使用情况,找出长期没被用到的索引。无用的索引不仅拖慢写入,还会占据缓存空间。查询方式如下:
SELECT relname AS table_name, indexrelname AS index_name, idx_scan AS scan_count, pg_size_pretty(pg_relation_size(indexrelid)) AS index_size FROM pg_stat_user_indexes ORDER BY idx_scan ASC, pg_relation_size(indexrelid) DESC;
注意pg_stat_user_indexes的计数自数据库启动以来累积,统计周期不够长的结论不可靠,建议监控至少覆盖一个月的业务周期再下判断。删索引时同样建议用DROP INDEX CONCURRENTLY,避免锁表。
巡检的另一半是膨胀检测。可以写一个定时任务,用pgstatindex检查核心索引的avg_leaf_density,低于阈值就告警,由DBA决定是否安排重建。一个简单的巡检脚本框架如下:
CREATE OR REPLACE FUNCTION check_index_bloat()
RETURNS TABLE(index_name text, density float8) AS $$
DECLARE
rec record;
BEGIN
FOR rec IN
SELECT indexrelid::regclass AS name
FROM pg_index
WHERE NOT indisprimary
LOOP
-- 逐个检查密度,仅示例
RETURN QUERY
SELECT rec.name::text,
(pgstatindex(rec.name::text)).avg_leaf_density;
END LOOP;
END;
$$ LANGUAGE plpgsql;最后强调一点原则:索引维护的目标不是消灭所有膨胀,而是让膨胀维持在性能可接受的区间。建立监控指标、掌握CONCURRENTLY重建的流程、理解fillfactor的作用,把这三件事做好,绝大多数索引相关的慢查询都能提前化解。
PostgreSQL慢查询优化索引重建索引维护修改时间:2026-09-15 05:04:32