折腾索引这件事,我最早是给一个日增几百万订单的表做体检时发现的。当时表才 12GB,可它的一个二级索引已经涨到 19GB,查询却越来越慢,慢日志里全是索引扫描异常。后来我用了 REINDEX 重建索引,索引直接缩回 3GB,那条原本跑 8 秒的 SQL 回头 50 毫秒出结果。老实说,PostgreSQL 的索引维护,尤其是重建与重索引操作,绝大多数项目跑到死都不一定用得上一次,但只要用上,提前弄懂 REINDEX 的机制和坑,能让你少走很多弯路。
这篇文章我会从索引为什么需要维护讲起,到 REINDEX 的具体用法、和手动重建的对比,再给出一套完整的实战修复过程和自动化巡检思路。适合刚接触 PostgreSQL 的运维,也适合已经在生产环境见过索引膨胀、但一直没理清处理逻辑的 DBA。
1. 为什么要折腾索引:膨胀、损坏与查询计划失控
在做任何 REINDEX 操作之前,你得先回答一个问题:索引到底为什么需要重建?很多人一看到索引空间大了就慌,上来就 REINDEX,这样反而容易把数据库搞出问题。我先讲清楚背后的机制,你才好判断什么时候该动手。
1.1 索引膨胀是怎么来的:死元组与可见性机制
PostgreSQL 的 MVCC 机制是根本原因。它在处理一条 UPDATE 时,并不会原地修改老数据,而是插入新版本,并把老版本标记为死元组。DELETE 同理,只是标记删除。表里的死元组可以由 VACUUM 回收,但索引面临的情况不完全一样。
这里有个容易混淆的知识点:VACUUM 也会扫描索引并移除指向死元组的索引项,但它只做逻辑上的删除,通常不会把索引页面里的空间压缩合并回物理文件。你可以想象成一个书架上经常抽掉书,但书挡永远不撤,每本新书又可能插在任意位置。时间一长,书架的空位越来越碎,找一本书要跨过一堆空档。索引膨胀的本质就是这样:索引项被拆除后,页面空间留下大量空洞,文件越涨越大,扫描成本越来越高。
加速膨胀的常见操作包括:
- 高频 UPDATE 一个非索引列且没有走 HOT 更新(Heap-Only Tuple Update)的路径,尤其是表上没有足够的空闲空间或更新列上有索引时。
- 批量 DELETE 大量行,比如定期清理过期数据。
- 频繁插入随机数据后再次删除,例如维护一个含有大量过期记录的临时状态表。
- 使用 GIN/GiST 等复杂索引时,pending list 合并机制也可能导致额外的页膨胀。
我在上一家公司遇到过最典型的场景:用户表上有 7 个索引,业务每次登录都会更新 last_login_at 字段,而这个字段本身没有索引,照道理应该走 HOT 更新。但那个表因为频繁大批量导入,每个页面都塞得很满,HOT 更新经常失败,系统只能退化成普通索引更新,结果 5 个二级索引轮番膨胀,半年时间索引总大小变成了表数据的 5 倍。
1.2 索引还会损坏:崩溃恢复与硬件因素
膨胀还不是最可怕的,至少它还能用,只是慢。索引真正"坏掉"的时候,查询会直接报错,比如 ERROR: index "orders_pkey" contains unexpected zero page at offset X,或者 ERROR: invalid page in block N of relation "xxx_idx"。这种问题通常来自几类原因:
- 服务器突然崩溃,尤其是系统崩溃或数据库进程被 kill -9 强杀时,部分索引页面的 WAL 日志可能没有完整刷新。
- 底层磁盘出现坏道、掉电导致页面损坏,尤其是没有采用足够冗余的存储方案时。
- 内存或硬件层面的偶发错误,导致写入文件的数据和数据库内部校验不一致。
- 复制链路异常时,备库某些索引重建或回放过程中产生的逻辑不一致。
我自己的经验是,遇到"索引损坏"报错,先别急着想业务问题,优先检查磁盘健康状态和系统日志。索引损坏往往是底层存储的预警信号,只重建索引而不管根因,几周后很可能又坏一遍。
1.3 怎么判断索引需要处理
判断索引要不要重建,或者说是不是已经膨胀到该处理的程度,不应该靠感觉,建议用数据说话。我常用的三套检查方式:
第一,直接看索引的大小和表的比例。如果单个索引大小接近甚至超过表本身,通常已经很不正常。
sql复制SELECT
t.relname AS table_name,
i.relname AS index_name,
pg_size_pretty(pg_relation_size(t.oid)) AS table_size,
pg_size_pretty(pg_relation_size(i.oid)) AS index_size
FROM pg_class t
JOIN pg_index ix ON t.oid = ix.indrelid
JOIN pg_class i ON i.oid = ix.indexrelid
WHERE t.relkind = 'r'
ORDER BY pg_relation_size(i.oid) DESC
LIMIT 30;
第二,用 pgstattuple 扩展查看索引
