1. 从一次磁盘告警说起:为什么索引会变成“空间杀手”
前几天凌晨我收到一条磁盘空间告警,登录服务器一看,数据目录所在的磁盘使用率已经逼近92%。第一个反应是查大表,结果发现一张只有不到2000万行的业务表,数据文件本身不过2GB出头,索引却占了接近5GB。更离谱的是,这张表上有十来个索引,其中两三个从上线到现在几乎没被查询用过——它们就是纯纯的“空间钉子户”。
这个现象在PostgreSQL里太常见了。很多团队建索引的时候很积极,清理索引的时候却没人管,久而久之索引就变成了占用磁盘的大头。而且PostgreSQL的索引和MySQL的InnoDB不一样,它默认没有索引下推式的自动合并机制,索引膨胀(index bloat)如果没人管,会一路涨到让你怀疑人生。
这篇文章我打算系统梳理一遍索引膨胀的形成原理、无用索引的识别方法、膨胀率的监控手段,以及从安全清理到自动化维护的完整实操流程。内容偏运维向,但后端开发同样值得看——毕竟很多索引是开发同学建的,建的时候多想想,后面运维能少熬好几个夜。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 索引膨胀的本质:为什么PostgreSQL的索引会越用越“胖”
2.1 膨胀的核心机制:不是碎片,是残留版本
先说清楚一个概念:索引膨胀不是磁盘碎片,而是索引页面里堆积了大量“死元组指针”。
PostgreSQL的多版本并发控制(MVCC)机制决定了,一条记录被UPDATE之后,旧版本不会立刻物理删除,而是被打上“已删除”标记,等待VACUUM来清理。这个机制对表(heap)有效,对索引同样生效。索引里的每个条目都指向对应的行版本,当行版本更新时,索引并不会同步修改——它只会新插入一个指向新版本的条目,旧条目继续躺在索引页里。
想象一下这个场景:一张表有100万行,每行平均被更新10次,那么索引里最多可能积累1100万个条目。但实际有效的只有100万个,剩下的1000万个都是“僵尸条目”。VACUUM会清理这些条目,但注意:VACUUM只能标记索引页面中的空间为可复用,它不会自动把空间返还给操作系统。
这就是膨胀和磁盘空间占用长期居高不下的直接原因。表可以靠VACUUM FULL或者pg_repack来压缩,索引呢?如果你只做普通的VACUUM,索引页里的空洞会被后续插入复用,但如果你的表是“高频更新一批旧数据 + 低频插入新数据”的模式,那些空洞可能很久都不会被复用——膨胀就固化了。
2.2 哪些操作最容易制造膨胀
按严重程度排个序:
- 高频UPDATE场景:每次UPDATE都会在索引中插入新条目,旧条目等待清理。这是索引膨胀最主要的来源。
- 批量DELETE后再插入:大量删除操作会在索引里制造大量死条目,如果紧接着又有大量新插入,新条目会优先使用页面间的空闲空间,但如果删除和新插入之间隔了很长时间,死条目堆积期就成了膨胀窗口。
- 低VACUUM频率的表:autovacuum没触发或者触发太慢,死条目长期累积。
- fillfactor设置不合理:索引默认fillfactor是90%,意味着每个页面预留10%空间给更新。但对于更新非常频繁的表,10%空间很快就被塞满,后续更新不得不再去申请新页面。
很多DBA习惯性只监控表膨胀,对索引膨胀睁一只眼闭一只眼。但说实话,在OLTP场景里,索引膨胀带来的空间浪费往往比表膨胀更严重,因为一个表上有多个索引,翻倍起来就是好几份空间。
3. 识别无用索引:别靠猜,让统计数据说话
3.1 找出“建了没人用”的索引
清理前先要分清两类问题:一类是索引膨胀导致空间变大,另一类是索引本身就多余,根本不该存在。前者需要重建,后者需要删掉。很多人一上来就跑REINDEX,这是不对的——如果索引本身没人用,重建了也还是浪费空间。
我常用的SQL长这样:
sql复制SELECT
schemaname AS schema_name,
tablename AS table_name,
indexname AS index_name,
idx_scan,
idx_tup_read,
idx_tup_fetch,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;
重点关注idx_scan这一列。它代表该索引被索引扫描触发的次数。以我的经验:
- idx_scan为0,且这个表已经有了一段时间的运行数据(比如一个季度以上),那这个索引极大概率是冗余的。
- idx_scan非常低(个位数),但索引很大(GB级别),说明它偶尔被用到,但性价比极低,需要考虑是否值得保留。
- idx_scan很高,但表和索引膨胀都很严重,说明索引在正常工作,只是需要重建来压缩体积。
有人会问:pg_stat_user_indexes的统计是从什么时候开始累计的?默认是从实例启动或者统计信息重置开始累计。如果实例刚重启过,这些数据参考价值就低。所以看统计前,先确认一下pg_postmaster_start_time()和stats_reset的时间。
3.2 无用的索引命名特征
除了靠统计信息,我在实际工作中还总结了一套“命名即意图”的检查法。很多团队建索引时命名随意,看到慢查询就随手建一个:
idx_xxx_1、idx_xxx_2这种明显就是试验品;idx_temp_xxx、idx_test_xxx这种是临时建的,线上忘了删;- 同一个字段组合建了多个索引,比如
(a, b)和(a, b, c),功能上前者完全能被后者覆盖; - 索引列上有隐式类型转换或函数操作,比如
WHERE date_column::date = '2024-01-01',这种建在date_column上的普通索引根本用不上,得建表达式索引才行。如果建错了,索引既不会被用到,又白白占空间。
所以冗余索引分两类:一类是完全死掉没人用的,另一类是设计错了根本没法被查询计划器使用的。后者比前者更隐蔽,因为它可能在pg_stat里偶尔有几次扫描,但扫描代价极高。
3.3 谨慎删除:先确认再动手
删索引前一定要做确认,尤其是生产环境。我个人的流程是:
- 从pg_stat_user_indexes捞出来所有idx_scan为0的索引;
- 对每个索引,用
pg_index视图检查它是否同时是唯一约束或主键的一部分——是的话不能直接删,得先处理约束; - 去代码仓库里搜索这个索引名,确认没有代码显式指定它;
- 在测试环境复现一下对应表的查询负载,用
EXPLAIN确认删掉索引后没有关键查询退化成全表扫描; - 如果是大表上的索引,删除操作本身也会产生WAL和锁,要安排在低峰期。
给索引“判死刑”要慎重,但也不要怂到不敢删。实际情况是,很多公司线上有一半以上的索引从来都没被用过,删掉它们不仅释放磁盘空间,还能降低INSERT、UPDATE、DELETE的写入开销。索引不是免费的,每一次写入都要同步维护所有索引。 冗余索引多一个,写入性能就差一分,这个账必须算清楚。
4. 索引膨胀率的计算与监控:数字才是硬道理
4.1 用pgstatindex计算膨胀率
单靠肉眼判断索引是不是膨胀了不靠谱,需要量化指标。PostgreSQL提供了一个扩展叫pgstattuple,核心函数是pgstatindex,可以用来查看单个索引的内部页面统计。
使用前先创建扩展:
sql复制CREATE EXTENSION IF NOT EXISTS pgstattuple;
然后对目标索引做体检:
sql复制SELECT * FROM pgstatindex('idx_order_user_id');
返回结果里几个关键字段:
avg_leaf_density:叶子页面的平均密度,取值范围0-100。这个值越低,说明页面里空闲空间越多,膨胀越严重。leaf_pages:叶子页数量。dead_pages:死亡页面数量(完全空闲、等着被回收的页面)。free_space:页面的总空闲空间字节数。
膨胀率可以用一个粗略的公式估算:
text复制膨胀率 ≈ (1 - avg_leaf_density / 100) × (leaf_pages / total_pages)
正常健康的索引,avg_leaf_density通常在80以上。如果低于60,说明膨胀已经比较明显;低于40,可以认为是重度膨胀,需要尽快处理。
4.2 批量扫描:一张SQL查全库膨胀情况
对库里的索引一个个调pgstatindex太费劲了,我写了一个批量查询脚本,可以一次性列出所有超过阈值的大索引膨胀状况:
sql复制SELECT
t.schemaname,
t.tablename,
t.indexname,
pg_size_pretty(pg_relation_size(t.indexrelid)) AS index_size,
s.avg_leaf_density,
s.leaf_pages + s.dead_pages AS total_pages,
s.dead_pages,
ROUND(100 * s.dead_pages / NULLIF(s.leaf_pages + s.dead_pages, 0), 2) AS dead_page_ratio_percent
FROM pg_stat_user_indexes t
CROSS JOIN LATERAL pgstatindex(t.indexrelid) s
WHERE pg_relation_size(t.indexrelid) > 100 * 1024 * 1024 -- 只看超过100MB的索引
AND s.avg_leaf_density < 70
ORDER BY pg_relation_size(t.indexrelid) DESC;
这里只筛了100MB以上的索引,因为小索引就算膨胀一倍也多占不了多少空间,重建的收益不高。dead_page_ratio_percent这个指标可以理解为“索引中的死亡页面占比”,超过20%就需要高度关注。
4.3 监控频率与告警阈值
索引膨胀不是一天形成的,没必要每分钟监控。我的建议是:
- 每周跑一次上述批量查询,把结果记录到一张监控表里;
- 磁盘使用率超过70%时,额外触发一次索引膨胀检查;
- 告警阈值可以分两级:
avg_leaf_density < 50触发warning,avg_leaf_density < 30触发critical; - 对于超大实例(索引总量超过500GB的),可以改成每三天一轮自动巡检。
监控表可以很简单,记录日期、索引名、大小、膨胀率、是否处理这几个字段就够了。有了历史数据,你还能看出膨胀的增速,预判未来磁盘什么时候会满。
5. 清理实操:从小表到大表的完整重建方案
5.1 安全删除:确认无用后的标准操作
确认某个索引确实没有存在价值后,删除操作本身不复杂:
sql复制DROP INDEX IF EXISTS idx_xxx_1;
但有两个注意点:
第一,DROP INDEX会获取索引上的ACCESS EXCLUSIVE锁。虽然不阻塞SELECT,但会阻塞对应表上的INSERT、UPDATE、DELETE以及并发建索引操作。对于大表,DROP操作本身很快(只是删除元数据),但如果后续要做重建,就要规划窗口。
第二,如果这个索引背后有约束依赖,直接DROP会报错。需要先确认:
sql复制SELECT
i.relname AS index_name,
c.conname AS constraint_name,
c.contype
FROM pg_index ix
JOIN pg_class i ON i.oid = ix.indexrelid
LEFT JOIN pg_constraint c ON c.conindid = ix.indexrelid
WHERE i.relname = 'idx_xxx_1';
如果有约束记录,就不能直接删,得先删除约束或者改成无约束的普通索引。
5.2 重建索引:REINDEX与REINDEX CONCURRENTLY
对于确认要保留但已经膨胀严重的索引,标准做法是重建。PostgreSQL提供了REINDEX命令:
sql复制REINDEX TABLE table_name;
REINDEX INDEX index_name;
但普通REINDEX有一个大坑:它会锁表,阻塞读写。如果是几百GB的大表,重建索引期间表基本处于不可用状态,业务影响非常大。
所以生产环境推荐使用REINDEX CONCURRENTLY:
sql复制REINDEX INDEX CONCURRENTLY idx_order_user_id;
这个命令的原理是先建一个同构的新索引,然后在新旧索引之间做切换,期间不会阻塞读写。代价是:
- 执行时间更长(大概需要普通REINDEX的2-3倍时间);
- 需要额外的临时磁盘空间(大约等于索引大小);
- 执行期间需要消耗额外的CPU和IO资源;
- 如果执行失败,会留下一个无效索引(名字里有
_ccnew后缀),需要手动清理。
我的实操经验是:10GB以内的索引,可以直接REINDEX CONCURRENTLY;超过50GB的索引,建议用pg_repack在线重建整个表,因为单独重建索引时,表本身是健康的,但表也在膨胀,一次性用pg_repack把表和索引都整理一遍更划算。
5.3 pg_repack:大表索引与数据一步到位
pg_repack是目前PostgreSQL社区最流行的在线重建工具。它通过注册一个临时触发器来捕获增量数据,然后创建一个重建后的表副本,在切换时把增量数据补上。整个过程不需要ACCESS EXCLUSIVE锁。
使用方式:
bash复制# 安装扩展到目标数据库
CREATE EXTENSION pg_repack;
# 命令行重建指定表及其所有索引
pg_repack -h localhost -U postgres -d mydb -t public.orders
pg_repack有几个实用参数:
-t指定表名,可以重复使用;-o指定表空间;-k跳过索引重建(只整理表数据);-N不使用超级用户模式(某些托管实例需要);--no-order忽略表原有顺序,加快执行。
在跑pg_repack之前,评估一下磁盘空间:它需要大约表大小两倍的空闲磁盘空间,一个200GB的表,至少得有400GB的余量再开工。空间不够的话,可以选择只重建索引:先DROP再CREATE INDEX CONCURRENTLY。
5.4 重建前后的对比验证
重建完成后,别忘了验证一下效果。我通常做三步验证:
- 空间对比:重建前后记录
pg_relation_size,确认空间确实释放了; - 健康度对比:重新跑一次
pgstatindex,确认avg_leaf_density恢复到80以上; - 性能对比:把之前被这个索引覆盖的慢查询重新跑一遍,确认执行计划没有退化。
有次我在一个客户环境里重建完一个占据12GB的膨胀索引后,磁盘直接空出来7GB,那个查询的响应时间从800ms降到120ms。效果立竿见影,但这种成果要归档记录下来,方便后续做容量规划参考。
6. 自动化:把索引膨胀监控变成“无人值守”的后台任务
6.1 用pg_cron做周期性巡检
手动巡检索引膨胀是一件反人性的事,前三周你可能记得,三个月后绝对忘了。所以要把巡检自动化。PostgreSQL生态里最常用的方案是pg_cron扩展:
sql复制CREATE EXTENSION IF NOT EXISTS pg_cron;
-- 每周日凌晨3点执行一次索引膨胀巡检
SELECT cron.schedule(
'index-bloat-check',
'0 3 * * 0',
$$
INSERT INTO index_bloat_history
SELECT
now(),
t.schemaname,
t.tablename,
t.indexname,
pg_relation_size(t.indexrelid),
s.avg_leaf_density,
s.dead_pages
FROM pg_stat_user_indexes t
CROSS JOIN LATERAL pgstatindex(t.indexrelid) s
WHERE pg_relation_size(t.indexrelid) > 100 * 1024 * 1024;
$$
);
pg_cron是独立于数据库内部的调度器,但需要额外安装,并且对PostgreSQL版本有一定要求。如果你的环境装不了pg_cron,也可以用系统级crontab + psql脚本,效果一样。
6.2 写一个全自动的“膨胀治理”定时任务
巡检只是发现问题,想要无人值守还得自动治理。我的做法是把整个流程串成一个函数,达到阈值就自动REINDEX CONCURRENTLY:
sql复制CREATE OR REPLACE FUNCTION auto_reindex_bloated_indexes(max_leaf_density int DEFAULT 50)
RETURNS TABLE(index_name text, old_size bigint, new_size bigint) AS $$
DECLARE
idx_record RECORD;
BEGIN
FOR idx_record IN
SELECT
t.schemaname || '.' || t.indexname AS full_index_name,
pg_relation_size(t.indexrelid) AS idx_size,
s.avg_leaf_density
FROM pg_stat_user_indexes t
CROSS JOIN LATERAL pgstatindex(t.indexrelid) s
WHERE pg_relation_size(t.indexrelid) > 100 * 1024 * 1024
AND s.avg_leaf_density < max_leaf_density
ORDER BY pg_relation_size(t.indexrelid) DESC
LIMIT 5 -- 每次最多处理5个,避免长时间占用资源
LOOP
RAISE NOTICE 'Rebuilding index: % (density: %, size: %)',
idx_record.full_index_name,
idx_record.avg_leaf_density,
idx_record.idx_size;
EXECUTE format('REINDEX INDEX CONCURRENTLY %s', idx_record.full_index_name);
RETURN QUERY
SELECT
idx_record.full_index_name,
idx_record.idx_size,
pg_relation_size(idx_record.full_index_name::regclass);
END LOOP;
END;
$$ LANGUAGE plpgsql;
调用方式很直接:
sql复制SELECT * FROM auto_reindex_bloated_indexes(50);
这个自动处理函数有几个保护机制很重要:
- 限流:每次最多处理5个索引,防止一次性把所有资源吃光;
- 阈值:默认只有密度低于50的才处理,轻度膨胀不折腾;
- 白名单机制:建议在函数内部维护一个表,用来排除某些特殊索引(比如分区表的主键索引、外部表索引等);
- 大小下限:100MB以下的索引不处理,避免频繁重建小索引产生不必要的WAL压力。
6.3 自动重建的风险边界
这里必须提醒一句:自动化是好东西,但别把所有索引都交给定时任务去重建。有几个场景我不建议全自动:
- 唯一约束/主键索引:重建过程中如果发生冲突,可能导致约束失效或数据异常;
- 正在被长时间事务使用的表:索引重建可能和长事务产生相互阻塞;
- 有外键引用的表:某些外键场景下,重建索引期间表结构变化可能引发意外锁等待;
- 逻辑复制/流复制环境:重建操作会消耗大量WAL,可能在备库产生复制延迟。建议在备库上先做一次验证,确认延迟可控后再跑主库。
折中的方案是:全自动巡检 + 半自动执行。巡检完全自动化,发现问题后发通知给值班DBA确认,确认后再一键执行重建。既保证了效率,又保留了人工判断这个安全网。
7. 实际踩坑记录:重建索引过程中的真·事故与教训
7.1 教训一:REINDEX CONCURRENTLY后残留的无效索引
有一次我在一个核心订单库上跑REINDEX CONCURRENTLY,跑到一半运维那边误操作重启了实例。结果就是:旧索引被切换标记,新索引只建了一半,留了一个叫idx_orders_create_time_ccnew的无效索引在系统表里。
后续查询偶尔报错,提示索引不存在或者索引失效。排查了半天才发现是这个残留物在捣乱。清理方式:
sql复制-- 查找无效索引
SELECT
i.relname AS index_name,
t.relname AS table_name
FROM pg_index ix
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_class t ON t.oid = ix.indrelid
WHERE NOT ix.indisvalid;
-- 确认无效后删除
DROP INDEX IF EXISTS idx_orders_create_time_ccnew;
经验就是:定期检查pg_index里indisvalid=false的索引,发现残留立刻清理,别等它出问题。
7.2 教训二:忽略复制延迟导致的备库落后
另一次在流复制环境里对一张大表做REINDEX CONCURRENTLY,主库看起来毫无压力,但备库延迟一度涨到了30分钟。原因是重建大索引产生的WAL量巨大,备库的apply速度跟不上。
后来吸取教训:在流复制环境重建超过20GB的索引前,先手动检查备库延迟(pg_stat_replication的replay_lag字段),延迟超过30秒就暂缓操作。如果实在着急,可以考虑在低峰期操作,或者临时提升备库性能。
7.3 教训三:VACUUM FULL救不了索引膨胀
早期我踩过一个理解误区:以为VACUUM FULL可以顺带整理索引。后来才发现,VACUUM FULL执行时表中的所有索引会被重建,效率很低。而且它需要ACCESS EXCLUSIVE锁,期间表完全不可用,对大表来说基本等于停机维护。
更关键的是,如果你的目标是“只清索引”,VACUUM FULL是杀鸡用牛刀。它会重写整个表,代价远比单独重建索引高。对索引膨胀,正解是REINDEX CONCURRENTLY或者pg_repack。
8. 配合使用:VACUUM 与 autovacuum 调优
8.1 autovacuum参数对索引膨胀的间接影响
索引膨胀的直接原因是死元组堆积,而死元组堆积多少取决于VACUUM跑得勤不勤。PostgreSQL的autovacuum默认配置,说实话对高并发更新场景不太够用。几个重要参数:
autovacuum_vacuum_scale_factor:默认0.2,意思是表有20%的行被更新/删除后才触发VACUUM。对大表来说,这个阈值太高了。假设一张2亿行的表,4000万行改动才触发,中间窗口期的死元组堆积量非常惊人。autovacuum_vacuum_threshold:默认50,加上上面那个比例值,作为触发阈值。autovacuum_naptime:默认1分钟,检查频率。
我建议对高频更新的大表,单独设置:
sql复制ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.05);
ALTER TABLE orders SET (autovacuum_vacuum_analyze_scale_factor = 0.02);
让VACUUM更频繁地“扫垃圾”,死元组不会长期堆积,索引膨胀的速度自然就慢下来了。
8.2 fillfactor对索引膨胀的抑制作用
前面提过,索引默认fillfactor是90%。如果表的更新模式是“频繁小范围更新同一批行”,可以考虑把fillfactor调低到70-80,给页面留更多空闲空间,减少更新时申请新页面带来的膨胀率。
sql复制CREATE INDEX idx_orders_user_id ON orders (user_id) WITH (fillfactor = 80);
需要注意:fillfactor在索引创建时指定,后续想调整只能重建索引。
8.3 定期VACUUM + 定期重建的组合策略
最优组合其实是:
- 日常:靠autovacuum频繁清理死元组,降低膨胀速度;
- 每周:巡检一次,对膨胀率超标的索引做定向REINDEX CONCURRENTLY;
- 每季度:对膨胀最严重的TOP 10索引做一次彻底重建;
- 大版本升级前:全库VACUUM FULL + REINDEX,把数据文件整理到最优状态。
这套组合拳下来,我在一个日均处理千万级订单的环境里,把索引带来的磁盘增长率从每周8%降到了每周不到1%,效果非常明显。
9. 监控大屏:把这些指标接到你的可视化系统上
最后聊聊指标可视化。纯文字报表看到吐,接入Grafana或类似系统后,很多事情一眼就能看出问题。
我一般会暴露这几个指标到监控系统:
| 指标名称 | 数据来源 | 建议告警阈值 |
|---|---|---|
| 数据库总索引大小 | SELECT pg_size_pretty(SUM(pg_relation_size(indexrelid))) FROM pg_stat_user_indexes | 单周增长超过10% |
| 最大索引大小Top10 | pg_relation_size(indexrelid)排序 | 单索引超过100GB |
| 平均叶子密度 | AVG(pgstatindex.avg_leaf_density) | 低于60 |
| 死亡页面占比 | pgstatindex.dead_pages / (leaf_pages + dead_pages) | 超过20% |
| autovacuum执行次数和时长 | pg_stat_user_tables | 某表VACUUM超过10分钟 |
| 磁盘使用率 | 操作系统级 | 超过80% |
Grafana的SQL数据源配置很简单,直接写上面的SQL语句,定时刷新就行。有了历史曲线图,你就能看清索引的“体重增长曲线”,在它变成胖子之前就安排减脂。
我个人的习惯是,周报里贴一张全库索引总大小趋势图,超过一个月的增长趋势如果明显偏离直线,就说明某个表的工作负载模式可能发生了变化,值得深入看一眼。
结尾就不多展开了,关于索引清理这条线,想说的核心就一句话:索引膨胀是慢刀割肉,平时看不出问题,等磁盘告警再动手就已经被动了。 把监控和定期重建变成自动化任务,一次配置,长期省心。
