Postgres 索引膨胀排查手记:从 40GB 到 6GB
一张 2000 万行的表,索引占了 40GB。记录一次完整的膨胀排查过程:怎么确认、怎么安全重建、怎么防止再次发生。
告警是磁盘使用率 87% 触发的。表本身不大 —— 2000 万行,数据约 12GB。但它的索引加起来 40GB。
索引比数据大三倍多,基本可以确定是膨胀。
先确认,别急着动手
Postgres 的 MVCC 决定了索引膨胀是必然的:更新一行会产生新的索引项,旧项要等 VACUUM 确认没有事务能看见才会被回收。如果 autovacuum 因为长事务或者频率不够而被拖延,索引就会持续长胖。
先看整体情况:
select
relname,
pg_size_pretty(pg_relation_size(oid)) as table_size,
pg_size_pretty(pg_indexes_size(oid)) as index_size,
n_live_tup,
n_dead_tup,
round(n_dead_tup::numeric / nullif(n_live_tup, 0) * 100, 1) as dead_pct,
last_autovacuum
from pg_stat_user_tables
where relname = 'orders'
order by pg_indexes_size(oid) desc;
结果:
relname | table_size | index_size | n_live_tup | n_dead_tup | dead_pct | last_autovacuum
---------+------------+------------+------------+------------+----------+-----------------
orders | 12 GB | 40 GB | 20134882 | 18420993 | 91.5 | 6 days ago
死元组比例 91.5%,而 autovacuum 六天没跑过。到这里原因已经很清楚了 ——有长事务一直持有 snapshot,autovacuum 永远清理不掉。
排查长事务:
select pid, state, now() - xact_start as duration, query
from pg_stat_activity
where xact_start is not null
order by duration desc
limit 5;
找到了:一个 BI 报表连接开着事务跑了四天(客户端忘记 commit)。
单索引级别的确认
pg_stat_user_tables 只能告诉你表级别的死元组数。要精确知道哪个索引胖了,用 pgstattuple:
create extension if not exists pgstattuple;
select * from pgstattuple('orders_customer_id_idx');
table_len | tuple_count | tuple_len | dead_tuple_count | dead_tuple_len | free_space
-----------+-------------+-----------+------------------+----------------+------------
8589934592 | 20134882 | 4832371680 | 18420993 | 4421038320 | 1200000000
dead_tuple_len 占了 51%,这个索引的实际有效数据只有 4.8GB。
重建:必须用 CONCURRENTLY
最直接的处理方式是重建索引。但普通的 REINDEX 会持有排他锁,在线上环境等于整表不可写。
从 Postgres 12 开始,REINDEX INDEX CONCURRENTLY 可以在不阻塞写入的情况下重建:
reindex index concurrently orders_customer_id_idx;
注意几个约束:
- 不能在事务块里执行
- 需要额外的磁盘空间(同时存在新旧两份索引)
- 失败会留下一个
INVALID状态的索引,需要手动drop后重试
批量处理时我写了个循环,一次一个:
do $$
declare
idx record;
begin
for idx in
select indexrelid::regclass::text as name
from pg_index
where indrelid = 'orders'::regclass
and pg_relation_size(indexrelid) > 1024 * 1024 * 1024
loop
execute format('reindex index concurrently %s', idx.name);
raise notice 'rebuilt %', idx.name;
end loop;
end $$;
重建后索引从 40GB 降到 6GB,磁盘使用率回到 61%。
防止复发
重建只是止血。根因是长事务,所以三件事必须做:
1. 给长事务告警。 我们定的阈值是 30 分钟:
select pid, now() - xact_start as duration, left(query, 80)
from pg_stat_activity
where state <> 'idle'
and now() - xact_start > interval '30 minutes';
2. 给报表库单独的连接。 BI 查询不该跑在业务主库上,这个建议我说了很多年,每次有人觉得「就查一下没事」。
3. 调 autovacuum 的触发阈值。 默认的 0.2 对大表太迟钝了:
alter table orders set (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 10000,
autovacuum_analyze_scale_factor = 0.01
);
对大表,按比例触发意味着要有 400 万死元组才开始清理 —— 太晚了。改成固定阈值 + 小比例,让清理更早、更平缓地发生。
复盘
这次事故的教训不是「索引会膨胀」这个常识,而是: 监控指标选错了。 我们一直在看磁盘使用率,而不是死元组比例。前者是症状,后者才是可以直接告警的原因。
现在 dead_pct > 20% 会直接触发 P2。