数据库跑得越久,索引膨胀的问题就越明显。同样的查询,半年前走索引只要20毫秒,现在却要200毫秒,而数据量其实只涨了一点点,这种情况下大概率是索引膨胀在作怪。本文围绕PostgreSQL,把索引膨胀的产生机制、监控指标以及pgstattuple和pgstattuple2这两个官方扩展的具体用法讲清楚,并附上可以直接拿去用的查询模板。

索引膨胀是怎么产生的
PostgreSQL的MVCC机制决定了UPDATE和DELETE不会原地修改数据,而是留下死元组(dead tuple)。这些死元组由VACUUM负责回收,但回收有一个时间差,在此期间索引条目也会指向这些无效数据。更麻烦的是,B-tree索引的页面一旦分配就不会自动归还给操作系统,即使页面里的条目被清空了,索引文件的大小依然不变,这就是膨胀的直接来源。
除了死元组,还有一个更隐蔽的原因:B-tree索引的节点分裂。当索引页写满时会发生页分裂,原页面一分为二,两个页面各占约一半空间。如果后续数据大量删除或者更新模式频繁变化,这些页面就长期处于稀疏状态。索引扫描仍然要遍历这些半空的页面,IO次数上去了,查询自然变慢。典型的高危场景包括:大量UPDATE同一批行的表、频繁DELETE的历史数据表、长事务阻塞VACUUM导致死元组堆积的库。
膨胀带来的危害不只是慢。索引文件变大意味着更多磁盘占用,shared_buffers里缓存的 有效数据密度下降,缓存命中率跟着下滑;同时每次VACUUM和备份都要处理这些无用的页面,维护窗口被拉长。所以说膨胀监控应该纳入日常巡检,而不是等出问题才想起来。
监控索引膨胀的核心指标
第一个要看的指标是索引与表的大小比值。正常情况下,二级索引大小占表大小的比例是比较稳定的,如果某个索引突然膨胀到比表还大,或者比值持续爬升,就需要警惕了。查询很简单:
SELECT
schemaname,
relname AS table_name,
indexrelname AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
pg_size_pretty(pg_relation_size(relid)) AS table_size,
round(100.0 * pg_relation_size(indexrelid) / pg_relation_size(relid), 1) AS ratio
FROM pg_stat_user_indexes
WHERE pg_relation_size(relid) > 0
ORDER BY ratio DESC;第二个指标是扫描方式的比例。通过pg_stat_user_tables里的seq_scan和idx_scan可以判断索引是否还在被使用,结合pg_stat_user_indexes的idx_scan还能找出从未被使用的僵尸索引。一个从来没被扫描过的索引,膨胀了也不知道,删掉反而是最优解。
第三个指标是估算膨胀率。可以通过pgstattuple扩展做精确分析,也可以用社区流传的基于pgstats的估算SQL。两者各有取舍:估算SQL速度快、对生产无压力,但误差可能达到几个百分点;pgstattuple需要全表扫描,结果精确但代价高,适合在维护窗口对重点表做抽查。
pgstattuple与pgstattuple2的安装与使用
这两个扩展都在PostgreSQL的contrib包里,安装方式一样:
-- root用户先确认contrib已安装,然后连接数据库执行 CREATE EXTENSION pgstattuple; -- pgstattuple2 是增强版,支持并行采样,适合大表 CREATE EXTENSION pgstattuple2;
两者最核心的函数都是pgstattuple,对表调用时输出包括table_len(表总大小)、tuple_count(活元组数)、tuple_len(活元组占用字节)、dead_tuple_count(死元组数)、free_space(页面空闲空间)等。死元组比例的计算公式是dead_tuple_len / table_len,一般超过20%就该安排VACUUM或者重建了。
针对索引则要用pgstatindex函数,这才是诊断索引膨胀的主力工具。以B-tree索引为例:
SELECT * FROM pgstatindex('public.orders_order_date_idx');输出字段里重点关注这几个:leaf_fragmentation表示叶子页碎片率,越高说明页面物理顺序越乱;leaf_fill_factor是叶子页实际填充度,健康索引一般在70%到90%之间,低于50%基本可以判定膨胀;avg_leaf_density与fill_factor含义接近,二者结合看更稳妥。另外index_size和root_block_no可以辅助判断索引层级是否加深。
pgstattuple2提供了pgstattuple2_parallel和pgstattuple2_sampled等函数,支持并行和采样模式。采样模式通过参数控制扫描比例,比如只扫5%的页面就给出估算结果,对TB级大表非常友好:
-- 采样10%的页面,速度快,结果为近似值
SELECT * FROM pgstattuple2_sampled('orders', 10);
-- 并行全量分析,需PostgreSQL 9.6以上
SELECT * FROM pgstattuple2_parallel('orders', 4); -- 4个worker需要提醒的是,无论哪个版本,全量模式都会给表加ACCESS SHARE锁并触发完整的顺序IO,大表在业务高峰期千万别跑。采样模式虽然干扰小,但结果波动较大,建议多次采样取中位数。另外,pgstattuple系列函数统计的是表层面的死元组,索引层面的稀疏化程度必须用pgstatindex看,两者不要混淆。
定位到膨胀后的处理方案对比
确认索引膨胀之后,处理方式有三种主流选择。最直接的是REINDEX INDEX 索引名,重建过程会持有锁,阻断写入,只适合维护窗口或从库。PostgreSQL 12以上支持REINDEX INDEX CONCURRENTLY,重建期间不阻塞读写,代价是耗时更长且中途失败会留下带后缀的无效索引,需要手动清理。
如果希望连表的膨胀一起处理,VACUUM FULL同样有效但锁表严重,生产环境更推荐第三方工具pg_repack。它基于逻辑复制原理在线重整表和索引,全程只短暂持有锁。选择时的基本原则是:小表直接REINDEX,大表且不能停写用CONCURRENTLY或pg_repack,极端情况下也可以考虑新建索引再切换的方式,即先CREATE INDEX CONCURRENTLY建一个新索引,确认无误后删旧改名。
最后建议把膨胀检查做成定时任务,比如每周维护窗口对核心表跑一遍采样分析,把leaf_fragmentation和填充度结果落到监控表里,画出趋势曲线。膨胀是渐进的过程,趋势比单点数值更有参考价值,等查询明显变慢才处理,往往已经错过了最佳时机。
索引膨胀pgstattuplePostgreSQL监控修改时间:2026-09-04 00:54:59