REINDEX 重建索引
索引是物理文件,会随着增删改而「磨损」:页面上留下带空洞的碎片、被删条目的空间无法被新条目完全复用、并发建索引失败后留下无人使用的 INVALID 索引。这些问题的共同解法是重建索引(Rebuild Index)——丢弃旧索引文件,按当前数据重新生成一份紧凑的索引。
PostgreSQL 提供 REINDEX 命令完成这件事。它看起来简单,实际藏着不少坑:默认会对索引加 ACCESS EXCLUSIVE 锁(阻塞读,不只是写)、CONCURRENTLY 模式会走六步流程、失败后会留下带 _ccnew / _ccold 后缀的残骸、有的对象形态根本不允许并发重建。本节把这些语义讲透,并给出可复制的排障与观测手段。
前置知识
阅读本节前,建议先了解:CREATE INDEX 基础、VACUUM 清理、表膨胀检测与处理
基础概念
索引为什么会「磨损」
B-Tree 页的填充与回收遵循一套「懒惰」策略:删除条目只做标记,页面被删空后通常要等到整页完全为空才回收给空闲空间映射(Free Space Map,FSM)。于是出现两种典型状态:
| 状态 | 成因 | 后果 |
|---|---|---|
| 碎片化 | 大量随机插入与删除,页面填充率参差 | 同样数据占用更多页,索引扫描 I/O 变多 |
| 页内空洞 | 页面只删了部分键,剩余键仍占用该页 | 空间无法被新键复用,索引持续变大 |
VACUUM 只负责清理堆表死元组,它不会整理索引页内部布局。要真正把索引「压实」,只能重建。
删除后的 B-Tree 叶子页(每格 = 一个索引项)
[ k1 ][ k3 ][ ~~ ][ ~~ ][ k7 ][ ~~ ]
↑ 已删除项留下的空洞,新项不保证能填进来
重建后:
[ k1 ][ k3 ][ k7 ][ 新 ][ 新 ][ 新 ]REINDEX 到底做了什么
REINDEX 的语义非常直白:按当前堆表数据,重新生成一份全新的索引文件,然后用它替换旧的。它不等价于 DROP INDEX + CREATE INDEX,因为:
| 维度 | DROP INDEX + CREATE INDEX | REINDEX |
|---|---|---|
| 索引定义 | 需要你手写完整定义,容易写错 | 自动沿用原定义,零犯错 |
| 依赖对象 | 丢弃约束依赖关系需重建 | 保留索引 OID 与依赖,约束不受影响 |
| 锁 | 两步之间索引不存在,查询计划会退化 | 全程持有原索引,锁语义更受控 |
| 并发模式 | 需自己拆成 CREATE INDEX CONCURRENTLY | 内置 CONCURRENTLY 选项 |
一句话:能用 REINDEX 就别 DROP + CREATE,除非你需要顺带改变索引定义。
四种典型使用场景
| 场景 | 触发信号 | 为什么 REINDEX |
|---|---|---|
| 索引损坏 | 查询报 index is corrupted / 页校验失败 | 从堆表重建,比恢复备份更快 |
| 索引膨胀 | pgstatindex() 显示 avg_leaf_density 偏低、deleted_pages 偏高 | 回收碎片,缩小体积 |
| 存储参数变更 | 改了 fillfactor 等参数但只对新页面生效 | 重建让新参数覆盖全部页面 |
修复 INVALID 索引 | \d 显示 INVALID(并发建索引失败留下) | 使索引重新变为可用 |
[ 堆表 ]
│ 按当前数据重新扫描
▼
┌───────────────┐ ┌───────────────┐
│ 旧索引文件 │ │ 新索引文件 │
│ 碎片多、膨胀 │ → │ 紧凑、按新参数 │
└───────────────┘ └───────────────┘
丢弃 替换语法与用法
REINDEX [ ( option [, ...] ) ] { INDEX | TABLE | SCHEMA } [ CONCURRENTLY ] name
REINDEX [ ( option [, ...] ) ] { DATABASE | SYSTEM } [ CONCURRENTLY ] [ name ]
其中 option 可以是:
CONCURRENTLY [ boolean ]
TABLESPACE new_tablespace
VERBOSE [ boolean ]各作用范围的语义:
| 形式 | 作用对象 | 能否 CONCURRENTLY | 能否在事务块内 |
|---|---|---|---|
REINDEX INDEX name | 单个索引 | 能 | 能 |
REINDEX TABLE name | 表上全部索引(含 TOAST) | 能 | 能 |
REINDEX SCHEMA name | 模式内全部索引 | 能 | 不能 |
REINDEX DATABASE name | 库内全部索引(不处理系统目录) | 能 | 不能 |
REINDEX SYSTEM name | 系统目录索引 | 不能 | 不能 |
最小可运行示例:
DROP TABLE IF EXISTS events;
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_events_user ON events (user_id);
-- 最常用形式:重建单个索引
REINDEX INDEX idx_events_user;
-- 并发重建(不长期阻塞读写,但更慢、更耗资源)
REINDEX INDEX CONCURRENTLY idx_events_user;
-- 重建一张表上的所有索引
REINDEX TABLE events;DROP TABLE
CREATE TABLE
CREATE INDEX
REINDEX
REINDEX
REINDEX另一种等价写法(选项式),PostgreSQL 12 起支持:
-- 与 REINDEX INDEX CONCURRENTLY 等价
REINDEX (CONCURRENTLY) INDEX idx_events_user;
-- 带详细输出
REINDEX (VERBOSE) TABLE events;详细说明
默认锁语义:为什么 REINDEX 会阻塞读
这是最常见的认知误区:「重建索引用的是读操作,应该不阻塞查询吧?」
事实是:REINDEX 默认会先对被重建的索引本身加 ACCESS EXCLUSIVE 锁,再对父表加 ACCESS EXCLUSIVE 锁。
- 对索引加
ACCESS EXCLUSIVE:任何想使用该索引的查询都被阻塞。因为解析器/规划器需要对该索引加ACCESS SHARE锁。 - 对表加
ACCESS EXCLUSIVE:任何对该表的读写都被阻塞。
-- 会话 A:重建一张大表的索引
REINDEX TABLE events; -- 长时间持有 ACCESS EXCLUSIVE
-- 会话 B:立刻被阻塞,即使只是 SELECT
SELECT count(*) FROM events WHERE user_id = 42; -- 卡在锁等待会话 B 的等待视图:
wait_event_type = 'Lock'
wait_event = 'relation'
pg_locks.mode = 'AccessShareLock'(被 A 的 AccessExclusiveLock 挡住)结论:生产大表绝不要裸跑 REINDEX,除非你能接受一段不可读写的窗口。
共享内存 / 单用户模式除外
在单用户模式(postgres --single)下执行 REINDEX 时,锁不适用。这也是官方推荐的「修复系统目录索引」路径。
REINDEX CONCURRENTLY 的六步流程
CONCURRENTLY 模式只持有 SHARE UPDATE EXCLUSIVE 锁——它不阻塞读写,只与其它 DDL、VACUUM、其它 REINDEX CONCURRENTLY 互斥。代价是它必须做两遍索引构建:
步骤 1:在系统目录中登记一个「临时索引」,定义与目标索引完全相同
步骤 2:第一遍扫描 —— 构建临时索引(记录快照)
期间对表的新写入,临时索引也会同步维护
步骤 3:第二遍扫描 —— 把第一遍之后产生的增量变更补进临时索引
步骤 4:交换 —— 把临时索引升格为正式索引(改名、迁移约束与依赖),
把它标记为 indisvalid = true
步骤 5:把旧索引标记为 indisready = false(在新事务已看不到它)
步骤 6:删除旧索引-- 注意:CONCURRENTLY 不能出现在事务块里
REINDEX INDEX CONCURRENTLY idx_events_user;REINDEX一次并发重建的开销近似「两个索引的总和」:磁盘上同时存在新旧两份索引,期间所有写入要维护两个索引,构建过程中产生大量 I/O。索引越多,总时长与资源占用越高,这正是 reindexdb -j 并行度要谨慎设置的原因。
失败与 INVALID 索引处理
并发构建可能因为死锁、唯一性冲突(UNIQUE 索引遇到并发写入导致重复键)等原因中断。中断会留下残骸,命名规则如下:
| 后缀 | 含义 | 处理方式 |
|---|---|---|
_ccnew | 已构建但尚未完成交换的临时索引 | 可 DROP,或再次并发重建让流程走完 |
_ccold | 已被替换、等待删除的旧索引 | 可 DROP |
_ccnew1、_ccold2 … | 同名冲突时的数字后缀 | 同上 |
-- 查看遗留的无效索引
SELECT c.relname AS index_name,
i.indisvalid,
i.indisready
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE NOT i.indisvalid OR NOT i.indisready; index_name | indisvalid | indisready
---------------------+------------+------------
idx_events_user_ccnew | f | tpsql 的 \d events 也会把这些索引标记为 INVALID。
恢复路径:
-- 路径一:直接丢掉残骸(如果重建本身不再需要)
DROP INDEX CONCURRENTLY idx_events_user_ccnew;
-- 路径二:再次并发重建(让交换真正完成)
REINDEX INDEX CONCURRENTLY idx_events_user;只有 INDEX 形式能并发修复 INVALID 索引
官方明确:唯一能对 INVALID 索引执行并发重建的形式是 REINDEX INDEX CONCURRENTLY。表级/模式级/库级并发重建遇到无效索引会报错。
不能在事务块中执行的形态与其它限制
以下形态会直接报错 REINDEX ... cannot run inside a transaction block:
-- 错误!SCHEMA / DATABASE / SYSTEM 不能出现在事务块内
BEGIN;
REINDEX DATABASE mydb;
ROLLBACK;其它硬限制:
| 限制 | 说明 |
|---|---|
REINDEX SYSTEM 不支持并发 | 系统目录索引不允许并发重建 |
| 排他约束索引不能并发重建 | 例如 EXCLUDE 约束底层索引,REINDEX ... CONCURRENTLY 会报错 |
| 分区表不能直接并发重建 | REINDEX TABLE 一个分区表时,子分区索引需逐个处理 |
| 临时表索引不能并发重建 | 临时表索引不支持 CONCURRENTLY |
-- 错误示例:排他约束的索引不支持并发重建
ALTER TABLE bookings ADD CONSTRAINT no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&);
REINDEX INDEX CONCURRENTLY no_overlap; -- ERRORERROR: cannot reindex an exclusion constraint concurrently权限与运行环境
执行 REINDEX 需要:
- 被重建索引所在表的
MAINTAIN权限(PostgreSQL 17 起新增,替代此前要求表 owner 的做法); REINDEX SCHEMA/DATABASE/SYSTEM需要相应对象的拥有者权限,或pg_maintain角色成员资格。
-- 授予某角色维护权限
GRANT MAINTAIN ON events TO app_maintainer;
-- 把角色加入 pg_maintain 预定义角色(覆盖面更广)
GRANT pg_maintain TO app_maintainer;GRANT
GRANT另一个容易忽略的细节:REINDEX 执行期间会临时把 search_path 改成 pg_catalog, pg_temp,这是为了防止用户自定义函数/操作符类劫持重建过程。所以不要依赖 search_path 来找自定义操作符类,需要时用 schema 限定名。
进度观测:pg_stat_progress_create_index
CREATE INDEX 与 REINDEX 共享同一个进度视图 pg_stat_progress_create_index,每个正在执行的后端一行。关键列:
| 列 | 含义 |
|---|---|
pid | 执行该命令的后端进程号 |
datname / relid | 数据库名 / 表 OID |
index_relid | 正在构建的索引 OID(并发模式会先指向临时索引) |
command | CREATE INDEX / CREATE INDEX CONCURRENTLY / REINDEX / REINDEX CONCURRENTLY |
phase | 当前阶段(见下表) |
lockers_total / lockers_done | 等待的锁持有者总数 / 已完成数 |
blocks_total / blocks_done | 需扫描的总块数 / 已完成块数 |
tuples_total / tuples_done | 需处理的元组总数 / 已完成数 |
partitions_total / partitions_done | 分区总数 / 已完成分区数 |
SELECT pid,
command,
phase,
blocks_done || '/' || blocks_total AS blocks,
tuples_done || '/' || tuples_total AS tuples,
lockers_done || '/' || lockers_total AS lockers
FROM pg_stat_progress_create_index; pid | command | phase | blocks | tuples | lockers
-------+--------------------+----------------------------+---------+----------+---------
28411 | REINDEX CONCURRENTLY| index validation: scanning | 8642/90 | 98021/9 | 0/0并发的 REINDEX CONCURRENTLY 会依次进入这些 phase:
initializing
waiting for writers before build
building index
waiting for writers before validation
index validation: scanning index
index validation: sorting tuples
index validation: scanning table
waiting for old snapshots
waiting for readers before marking dead
waiting for readers before dropping若 phase 长期停在某个 waiting for ...,通常说明有长事务或长查询挡住了流程,可以去排查 pg_stat_activity 里 xact_start 很老的事务。
reindexdb:批量重建的客户端工具
对多张表、多个索引做批量重建,用命令行工具 reindexdb 比手写 SQL 更省事。
# 重建整个数据库的所有索引
reindexdb --dbname=mydb --all
# 并发重建(不阻塞业务)
reindexdb --dbname=mydb --concurrently --table=events
# 只重建指定索引
reindexdb --dbname=mydb --index=idx_events_user
# 并行重建多张表的索引(4 个并行连接)
reindexdb --dbname=mydb --jobs=4 --table=events
# 只重建系统目录索引
reindexdb --dbname=mydb --system
# 组合:对整个模式并发重建,输出详细日志
reindexdb --dbname=mydb --concurrently --schema=public --verbose选项速查:
-a, --all 重建所有数据库
--concurrently 使用 REINDEX CONCURRENTLY
-i, --index=NAME 只重建该索引(可重复)
-j, --jobs=N 并行任务数(每任务一个连接)
-q, --quiet 安静模式
-s, --system 只重建系统目录索引
-S, --schema=NAME 只重建该模式
-t, --table=NAME 只重建该表
--tablespace=NAME 把重建结果放进指定表空间
-v, --verbose 详细输出--jobs 与 --system 不兼容
reindexdb --jobs 会开启多个数据库连接并行工作,因此不能与 --system(单用户模式相关)组合使用,也不能同时重建整个系统目录。并发重建一个库里的多个索引时,注意默认连接数上限。
重建会不会顺带「修复」损坏
会,但要看损坏类型。REINDEX 从堆表重新读取数据构建索引,因此索引侧的物理损坏可以被修复。但如果是堆表本身的元组损坏,重建时读到坏页面就会失败并报错——这种情况需要更复杂的手段(如 zero_damaged_pages 或从备份恢复),REINDEX 无能为力。
-- 单用户模式下修复系统目录索引(官方推荐路径)
-- 先停库,再执行:
-- postgres --single -D /var/lib/postgresql/data mydb
-- 然后在单用户提示符下:
-- REINDEX SYSTEM mydb;REINDEX实战示例
下面用 300 万行的 events 表,完整演示「检测膨胀 → 评估策略 → 并发重建 → 验证」的闭环。
步骤一:造数据并制造碎片
DROP TABLE IF EXISTS events;
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL,
status text NOT NULL,
payload text,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO events (user_id, status, payload)
SELECT (random() * 100000)::bigint,
(ARRAY['pending','running','done','failed'])[1 + (random() * 3)::int],
repeat('x', 40)
FROM generate_series(1, 3000000);
CREATE INDEX idx_events_user ON events (user_id);
-- 制造碎片:反复删除与插入大量随机 user_id
DELETE FROM events WHERE user_id % 7 = 0;
INSERT INTO events (user_id, status, payload)
SELECT (random() * 100000)::bigint, 'done', repeat('y', 40)
FROM generate_series(1, 800000);
VACUUM ANALYZE events;DROP TABLE
CREATE TABLE
INSERT 0 3000000
CREATE INDEX
DELETE 428571
INSERT 0 800000
VACUUM步骤二:检测索引膨胀并估算收益
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT 'idx_events_user'::text AS idx,
pg_size_pretty(pg_relation_size('idx_events_user')) AS size,
(pgstatindex('idx_events_user')).avg_leaf_density AS avg_leaf_density,
(pgstatindex('idx_events_user')).leaf_fragmentation AS leaf_frag,
(pgstatindex('idx_events_user')).deleted_pages AS deleted_pages; idx | size | avg_leaf_density | leaf_frag | deleted_pages
-------------------+---------+------------------+-----------+---------------
idx_events_user | 112 MB | 62.41 | 47.86 | 18avg_leaf_density 62% 意味着近四成叶子页空间是空的;leaf_fragmentation 接近 48% 说明逻辑相邻的键在物理上散得很开。典型的「值得重建」信号。
步骤三:并发重建并观测进度
-- 会话 A:并发重建(可在业务高峰期执行,但更慢)
REINDEX INDEX CONCURRENTLY idx_events_user;REINDEX-- 会话 B:另开一个连接观察进度
SELECT phase, blocks_done || '/' || blocks_total AS blocks
FROM pg_stat_progress_create_index
WHERE command = 'REINDEX CONCURRENTLY'; phase | blocks
--------------------------------+---------
index validation: scanning index| 4210/9000步骤四:验证重建效果
SELECT pg_size_pretty(pg_relation_size('idx_events_user')) AS size_after,
(pgstatindex('idx_events_user')).avg_leaf_density AS density_after,
(pgstatindex('idx_events_user')).leaf_fragmentation AS frag_after; size_after | density_after | frag_after
------------+---------------+------------
74 MB | 89.72 | 3.11体积从 112 MB 降到 74 MB(-34%),叶子密度升到约 90%,碎片率降到 3%。重建后建议补一次 ANALYZE:
ANALYZE events;ANALYZE注意事项
默认
REINDEX会阻塞读。 它先锁索引(ACCESS EXCLUSIVE),使用该索引的SELECT全部卡住。sql-- 错误认知:以为这里只影响写 REINDEX TABLE events; -- 实际上 SELECT ... WHERE user_id = 1 也被阻塞CONCURRENTLY不是「免费的午餐」。 它要写两份索引、扫描两遍,总耗时可能翻倍,且需要更大的磁盘空间。CONCURRENTLY不能在事务块内执行。 应用层如果用框架把 SQL 包在事务里,会直接失败。sql-- 错误! BEGIN; REINDEX INDEX CONCURRENTLY idx_events_user; COMMIT;失败会留下
_ccnew/_ccold残骸。 必须专门巡检并清理,否则这些无效索引会持续消耗磁盘与写入开销。sql-- 巡检无效索引 SELECT indexrelid::regclass, indisvalid, indisready FROM pg_index WHERE NOT indisvalid;不要在业务高峰对「所有索引」做并发重建。
REINDEX DATABASE CONCURRENTLY会依次处理海量索引,期间写放大会持续很久。REINDEX SYSTEM与--jobs冲突,且不能在事务内。 系统目录重建应在维护窗口用单用户模式完成。排他约束、临时表索引不能并发重建。 误用会直接报错,需改用非并发模式或另寻方案。
重建不等于修堆表。 堆表页面损坏时
REINDEX会失败,别指望它能「自愈」一切。sql-- 若重建时报块级 I/O 错误,说明堆表有损坏,需走备份恢复流程 REINDEX TABLE events; -- ERROR: invalid page in block 1234重建后统计信息不一定更新。 索引变了,但优化器的行数估算依赖
pg_statistic,务必补ANALYZE。
最佳实践
先量化再重建。 用
pgstatindex()看avg_leaf_density/leaf_fragmentation,用pg_relation_size()看体积,有明确收益再动手。sqlSELECT pg_size_pretty(pg_relation_size('idx_events_user')) AS size, (pgstatindex('idx_events_user')).avg_leaf_density AS density;优先
CONCURRENTLY,除非处于维护窗口。 生产库几乎总应选并发模式。sqlREINDEX INDEX CONCURRENTLY idx_events_user;重建后立刻
ANALYZE。 让优化器拿到最新分布,避免计划退化。sqlREINDEX INDEX CONCURRENTLY idx_events_user; ANALYZE events;fillfactor提前设好再重建才有意义。 重建会把新参数应用到最后一份索引上。sqlALTER INDEX idx_events_user SET (fillfactor = 90); REINDEX INDEX CONCURRENTLY idx_events_user;用进度视图估算剩余时间。 按
blocks_done / blocks_total与已用时间线性外推,决定是否让任务继续。sqlSELECT command, phase, round(100.0 * blocks_done / nullif(blocks_total,0), 1) AS pct FROM pg_stat_progress_create_index;批量重建交给
reindexdb,并限制并行度。 并行度等于同时打开的连接数,别压垮实例。bashreindexdb --dbname=mydb --concurrently --table=events --jobs=2 --verbose把「无效索引巡检」纳入日常运维。 每周查一次
pg_index.indisvalid = false,及时清理残骸。sqlSELECT indexrelid::regclass AS bad_index, indisvalid, indisready FROM pg_index WHERE NOT indisvalid OR NOT indisready;别把
REINDEX当常规操作。 健康的 B-Tree 不需要频繁重建;只在检测到膨胀、损坏或参数变更时执行。记录重建前后的指标,形成基线。 体积、
avg_leaf_density、关键查询的Buffers变化,都是下次决策的依据。sqlSELECT indexrelid::regclass AS idx, pg_size_pretty(pg_relation_size(indexrelid)) AS size FROM pg_index WHERE indrelid = 'events'::regclass;重建前确认磁盘余量。 并发重建期间新旧两份索引并存,需要约「原索引大小 + 新索引大小」的额外空间。
bashdf -h /var/lib/postgresql/data
下一节
继续学习:索引膨胀检测