折腾索引这件事,我最早是给一个日增几百万订单的表做体检时发现的。当时表才 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 扩展查看索引空页率。这个扩展需要先 CREATE EXTENSION IF NOT EXISTS pgstattuple;,然后对指定索引做体检:
sql复制SELECT * FROM pgstatindex('orders_create_time_idx');
输出里的 avg_leaf_density 如果持续偏低,比如低于 60% 甚至 40%,说明索引页空洞严重,重建价值很大。
第三,通过统计视图观察索引使用效率。
sql复制SELECT
schemaname,
relname,
indexrelname,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC;
如果一个索引的 idx_scan 长期为 0 或者低得可怜,那它可能根本没人用,直接 DROP 比 REINDEX 更合理。而如果 idx_tup_read / idx_scan 的比例异常高,说明每次扫描要读大量版本信息,这时候重建可能带来明显改善。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. REINDEX 命令全解读:五种形式与在线模式
REINDEX 是 PostgreSQL 官方提供的索引重建命令,不是让你 DROP INDEX 再 CREATE INDEX 的变通方案,它有自己的完整语义和适用场景。很多初学者只见过 REINDEX INDEX 一种写法,但其实它有五种使用形式,面向不同单位粒度。
2.1 REINDEX 的五种使用形式:从单索引到整库
我先列一个总表,方便你后面对照:
| 命令形式 | 作用范围 | 是否能带 CONCURRENTLY | 典型场景 |
|---|---|---|---|
REINDEX INDEX |
重建单个索引 | 可以 | 单索引膨胀或损坏 |
REINDEX TABLE |
重建该表上的全部索引 | 可以 | 整张表的索引都膨胀 |
REINDEX SCHEMA |
重建指定 schema 下所有索引 | 可以 | 一个业务域集中维护 |
REINDEX DATABASE |
重建当前库所有索引(含系统目录) | 可以 | 库级大扫除 |
REINDEX SYSTEM |
只重建系统表上的索引 | 可以 | 系统目录损坏时急救 |
每种形式的使用很简单,比如:
sql复制REINDEX INDEX orders_create_time_idx;
REINDEX TABLE orders;
REINDEX SCHEMA public;
REINDEX DATABASE postgres;
REINDEX SYSTEM postgres;
有个细节需要注意:REINDEX DATABASE 和 REINDEX SYSTEM 通常要求在非事务上下文中执行,而且它们在扫描系统表上索引时会对系统目录加比较重的锁。我自己的建议是,除非系统表索引确实出了问题,日常维护不要随便碰 SYSTEM,风险收益不成正比。
另外,命令默认采用会话级锁,所有相关索引会被暂时锁定,阻塞读写。所以生产环境执行前,一定要评估业务窗口,或优先考虑下一小节要讲的在线重建方式。
2.2 CONCURRENTLY 在线重建的原理与代价
REINDEX ... CONCURRENTLY 是 PostgreSQL 9.5 引入、12 版本全面增强的能力。它存在的意义就是解决普通 REINDEX 高锁阻塞问题。普通 REINDEX 会对目标表加 ShareLock,期间写入会被阻塞,读虽然能继续,但如果索引很大,重建持续十几分钟甚至几小时,业务就完全不能写入了。在线重建不是这样,它会边扫描边构建索引副本,期间正常读写都不中断。
原理上,CONCURRENTLY 不是一次性交换,而是分多步执行:
- 创建新索引副本,并在原索引上注册依赖关系。
- 开始一次事务级扫描,把已有数据复制到新索引中。
- 记录扫描期间发生的增量变更,同步到新索引中。
- 在源表上加短暂的锁,完成新旧索引的元数据切换。
- 删除旧索引文件,并清理临时状态。
这样带来的直接好处是业务几乎无感,但代价也很实在:
- 重建期间需要额外一份完整的索引空间,磁盘占用约为原索引的 1~2 倍。如果原索引 100GB,你至少要有 100GB 以上的空闲空间才敢动。
- 不能放在一个事务块里执行,因为它内部会自行提交和推进事务。你如果在一个事务里写
BEGIN; REINDEX INDEX CONCURRENTLY ...; COMMIT;,会直接报错。 - 如果中途失败,系统可能留下一个无效索引,查询不会使用它,但需要人工清理。
- IO 压力和 WAL 量都会明显上升,主从复制环境里需要额外关注延迟。
我用在线重启重建过一个 80GB 的二级索引,跑了大概 40 分钟,期间业务写入基本没感知,只有监控里看到磁盘 IO 有明显持续峰值。这个体验在普通 REINDEX 下是不可能的。
2.3 控制重建过程的资源消耗
执行 REINDEX 前,建议先调整几个关键资源参数,尤其对于大索引,这些参数的差异可能让重建时间从 3 小时变成 40 分钟。
maintenance_work_mem:重建索引时用于排序和构建的内存,建议在会话级别调大,比如 1GB 或 2GB,避免走大量临时文件。max_parallel_maintenance_workers:REINDEX 默认会尝试使用并行 worker 加速构建,调高这个值可以让重建更快,但也要控制不要吃满 CPU。checkpoint_timeout和max_wal_size:在重建大索引时,WAL 增长会很明显,适当调大max_wal_size可以减少频繁检查点带来的 IO 毛刺。
我自己在生产库执行前通常会这么设置:
sql复制SET maintenance_work_mem = '2GB';
SET max_parallel_maintenance_workers = 4;
SET max_wal_size = '16GB';
然后才执行具体命令。注意这些都是会话级设置,不会影响其他连接。
3. 重建索引还是重索引:其实是一个决策问题
PostgreSQL 里"重建索引"并不只有 REINDEX 一条路,你完全可以 DROP INDEX 再 CREATE INDEX。很多 DBA 习惯性用后者,但两者在某些场景下的行为差异很大。搞清楚差异,才能在不同情况下选最优解。
3.1 两者的行为差异:锁、依赖与元数据
先看一个容易被忽略的问题:REINDEX 会保留索引的名字、依赖关系和统计信息上下文,而 DROP + CREATE 本质上是在创建一个全新的对象。
code复制REINDEX INDEX orders_pkey;
-- 等价于重建了 orders_pkey,但索引的定义、约束依赖关系都还在。
DROP INDEX CONCURRENTLY orders_pkey;
CREATE UNIQUE INDEX orders_pkey ON orders (id);
如果是普通二级索引,区别似乎不大。但如果这个索引被用作 REPLICA IDENTITY(逻辑复制里标识行身份),或者被一个约束(如唯一约束、主键)直接引用,直接 DROP 会报错,必须先处理约束关系,再重建,最后重新关联。这一来一回就很容易出错。而 REINDEX 会保留这些关联关系,所以官方语义上更安全。
锁的粒度也有区别。普通 REINDEX 会在整个执行过程中对表加锁;REINDEX CONCURRENTLY 只在切换元数据的瞬间短暂加锁。而 DROP INDEX + CREATE INDEX 分开执行时,DROP 阶段会拿 AccessExclusiveLock 立即锁定索引对应表,CREATE 阶段则只需要避免索引名冲突,不会锁住表太久。如果你把两个操作放在一个事务里执行,那从 DROP 到 CREATE 结束期间,表实际上一直是锁定的。
展开来说:当你要修改索引定义时,必须走 DROP + CREATE。比如你原来建的是一个普通 B-tree 索引,现在想改成 INCLUDE (xxx) 列或调整 fillfactor,REINDEX 做不到,只能 DROP 后按新定义重建。反过来,如果你只是想让现有索引恢复健康,完全没有必要动对象本身,REINDEX 更加安全。
3.2 决策矩阵:什么时候用哪个
我把实际工作中常见的场景整理成一份选择指南:
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 单个索引膨胀,业务不能停 | REINDEX INDEX CONCURRENTLY |
不影响读写,且保留依赖关系 |
| 单个索引膨胀,有维护窗口 | REINDEX INDEX |
更快,锁冲突可接受 |
| 整张表索引集体膨胀 | REINDEX TABLE CONCURRENTLY |
一次处理全部索引,避免多次锁冲突 |
| 需要修改索引定义、转移表空间 | DROP INDEX + CREATE INDEX |
这是定义操作的唯一路径 |
| 系统目录索引异常 | REINDEX SYSTEM CONCURRENTLY |
尽量避免手工 DROP 系统索引 |
| 逻辑复制环境,索引用作 REPLICA IDENTITY | REINDEX INDEX CONCURRENTLY |
DROP+CREATE 会破坏复制身份关联 |
简单来说:没有定义调整需求时,优先 REINDEX;有定义调整需求时,才考虑手动 DROP + CREATE。
3.3 重建后统计信息与查询计划的影响
还有一个很多人忽略的点:索引重建本身不会重新计算表的统计信息。重建后,PostgreSQL 的查询规划器手里拿到的还是旧的统计信息,尤其是通过 pg_class.relpages 计算出来的代价估算值可能已经失真。如果索引膨胀非常严重,规划器可能因为代价估算偏高而拒绝走索引,重建后立刻就会发生变化。
所以在比较大型的 REINDEX 完成后,我通常会在低峰期对表做一次 ANALYZE:
sql复制ANALYZE orders;
这一步可以让查询计划基于最新的索引页数重新评估。我遇到过的情况是,重建完索引后不加 ANALYZE,某些复杂 JOIN 依然走顺序扫描;ANALYZE 一执行,很快就切回索引扫描了。这不是玄学,是代价估算机制在起作用。
4. 实战:一张表索引严重膨胀后的完整修复过程
光说原理还是不够,我写一个我处理过且比较有代表性的案例,帮你完整走一遍"发现、评估、执行、验证"的流程。
4.1 从现场开始:一个膨胀严重的订单表
假设有一张订单表 orders,总量大约 3 亿行,表本身 12GB。主要索引包括主键 orders_pkey、业务查询索引 orders_create_time_idx、orders_user_id_idx。业务特征是高频更新订单状态,同时每晚批量删除已完结订单。半年后,检查发现:
orders_user_id_idx从 2GB 涨到 9GB。orders_create_time_idx从 1.5GB 涨到 7GB。- 主键索引相对稳定,但也有轻微膨胀。
这种格局是典型的"频繁非 HOT 更新 + 批量删除"叠加效果。先确认空页率:
sql复制CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstatindex('orders_user_id_idx');
返回结果里 avg_leaf_density 只有 35% 左右,free_space 占比接近一半,说明索引页有大量空洞。这时候我建议重建,但不能盲建,因为这张表随时有写入流量。
4.2 制定方案:在线重建和窗口评估
我当时的方案是:在业务低峰期(凌晨 2 点)执行 REINDEX INDEX CONCURRENTLY,优先处理膨胀最明显的两个索引,主键索引暂不处理。
先做空间评估。两个索引重建副本需要大概 16GB 额外空间,而所在盘空余 40GB,安全。为了避免意外锁冲突,我给命令加了锁超时设置:
sql复制SET lock_timeout = '5s';
SET statement_timeout = '30min';
REINDEX INDEX CONCURRENTLY orders_user_id_idx;
REINDEX INDEX CONCURRENTLY orders_create_time_idx;
为什么设置 lock_timeout?因为在线重建虽然不会长时间锁表,但在元数据切换阶段会短暂请求锁。如果业务有其他长事务正占着锁,这个请求可能会卡住。设置超时后,如果拿不到锁,命令会直接失败退出,而不是无限等待拖垮后面所有会话。我当时实测,两者分别耗时 18 分钟和 11 分钟,业务监控无感知。
4.3 执行后验证:大小、扫描行为与查询计划
重建完成后,我用一张 SQL 对比了前后差异:
| 索引 | 重建前 | 重建后 |
|---|---|---|
| orders_user_id_idx | 9.2GB | 2.1GB |
| orders_create_time_idx | 7.1GB | 1.7GB |
| avg_leaf_density | 35% | 96% |
然后执行了 ANALYZE orders;,重新统计信息。再看那条慢 SQL 的查询计划,已经从"Seq Scan + Filter"变成了"Bitmap Index Scan",执行时间从 8 秒降到 60 毫秒。这就是索引重建的直观收益。
4.4 如果在线重建失败了怎么办
在线重建不是万无一失,可能会因为磁盘空间不足、并发 DDL 冲突等原因失败。失败后最明显的特征是在视图里出现 indisvalid = false 的索引。
sql复制SELECT
i.indexrelid,
i.indrelid,
i.indisvalid,
c.relname AS index_name
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE i.indisvalid = false;
如果发现存在无效索引,需要尽快清理:
sql复制DROP INDEX CONCURRENTLY orders_user_id_idx;
注意 DROP 加上 CONCURRENTLY 同样是为了避免长时间锁表。清理完成后,可以重新执行 REINDEX。如果失败原因是磁盘满,清理无效索引会立刻释放一部分空间,这往往是应急的关键动作。
5. 索引维护的自动化策略与避坑经验
"重建索引"这件事最怕做成突击战。我见过不少团队,平时从不看索引,等到线上慢查询爆了才火急火燎跑 REINDEX。其实很多时候,一个定期的巡检脚本就能把问题消灭在早期。
5.1 分清 VACUUM 和 REINDEX 的分工,不要乱用
很多人把 VACUUM、VACUUM FULL、REINDEX 混为一谈,这对生产库是危险的。VACUUM 回收表里死元组并更新可见性映射,它会顺带清理索引项但不会压缩索引文件。VACUUM FULL 会重写表并重建索引,效果类似 REINDEX,但它是重量级操作,需要更强的锁,且会把整个表复制一遍,不适合在线执行。REINDEX 则专注于索引本身,不会重写表数据。
日常维护优先级建议是:
- 常规 VACUUM 配合 autovacuum,保持表和索引基础健康。
- 巡检发现索引空页率过高时,排期执行 REINDEX。
- VACUUM FULL 只在表本身严重膨胀、需要整体瘦身时考虑,且必须有长维护窗口。
如果只是因为索引膨胀就去跑 VACUUM FULL 或 REINDEX TABLE 重建所有索引,可能性能恢复不错,但锁和 IO 代价远高于按需精确处理。
5.2 自动化巡检脚本:用数据驱动决策
我长期在运维环境里放一个定时巡检脚本,每周跑一次,把膨胀异常的索引列出来。核心查询思路是:
sql复制SELECT
current_database() AS db_name,
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,
s.idx_scan,
s.idx_tup_read
FROM pg_class t
JOIN pg_index ix ON t.oid = ix.indrelid
JOIN pg_class i ON i.oid = ix.indexrelid
LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.oid
WHERE t.relkind = 'r'
AND t.relname NOT LIKE 'pg_%'
AND pg_relation_size(i.oid) > 100 * 1024 * 1024
ORDER BY pg_relation_size(i.oid) DESC;
这个脚本把大于 100MB 的索引都列出来,我再人工判断哪些需要进一步用 pgstatindex 查看空页率。和 pg_cron 或系统 crontab 配合,完全可以把巡检做到无人值守。注意,pg_stat_user_indexes 的统计在每次重启后会从零开始累计,所以单独看一次快照不一定准确,要看趋势。
5.3 踩过的坑:复制延迟、IO 突刺和事务边界
最后整理几个我实际踩过、也帮别人排查过的坑,这些坑文档里写得很含糊,但现场遇到时很棘手。
第一个坑是主从复制环境下的 WAL 增长。REINDEX CONCURRENTLY 在构建新索引期间会产生大量 WAL,如果备库回放速度跟不上,会出现明显的复制延迟。我在一次重建 100GB 索引时,备库延迟一度涨到 20 分钟。解决办法是尽量避免在复制集群的所有节点同时做类似操作,主库重建完成后,备库回放自然跟上,但监控上要提前预留带宽和磁盘空间。
第二个坑是 IO 突刺。在线重建对 IO 的压力不亚于一次全表扫描,如果数据库和其他高 IO 业务共用同一批物理盘,可能影响全局。我通常会用 nice 或 ionice 降低数据库进程外的辅助进程优先级,但更有效的是把重建安排在业务低谷。如果允许,也可以考虑临时把索引的表空间指向独立盘,重建后再移回来,但这一步要结合具体环境评估。
第三个坑是事务边界,尤其是 REINDEX CONCURRENTLY。它不能在一个事务块里执行,这是很多人容易踩的。同时,PostgreSQL 里 DDL 也不是全部可以回滚,REINDEX CONCURRENTLY 尤其依赖多步提交,所以异常中断后必须检查无效索引。我建议所有自动化脚本里都加上失败清理逻辑,避免无效索引堆积。
第四个坑是 GIN 索引的重建。它和 B-tree 索引的膨胀逻辑不太一样,GIN 有一个 pending list 机制,快速插入时先写 pending list,再批量合并到主索引结构里。REINDEX 会清空 pending list,让索引恢复紧凑,但重建期间搜索性能会有波动。如果表上有大量全文检索或 JSONB 查询,重建前最好确认一下业务接受度。
根据我个人的管理经验,PostgreSQL 索引维护的最大原则就是:能发现问题时不要拖,动手前先量化,重索引要选对模式,做完一定要验证。很多生产事故并不是索引真的修不好,而是 DBA 在错误的时间用了错误的重建方式,把简单的膨胀问题升级成了锁等待或磁盘告警。如果你能把上面这些场景和命令真正跑一遍,对 REINDEX 的把握会有实质提升。
