PostgreSQL 索引内部机制与表重建深度解析
一、索引基础概念
1.1 索引与堆表的关系
PostgreSQL 的索引与堆表(heap)是物理分离的两个文件:
堆表文件(base/dboid/relfilenode)
存储实际数据行(tuple),按插入顺序堆放
索引文件(base/dboid/relfilenode_idx)
存储键值 + ctid(物理位置指针)
ctid = (块号, ItemId 序号),指向堆表中对应的 tuple
索引本身不存储行数据,查询时通过索引找到 ctid,再回表(heap fetch)取完整数据。
1.2 索引类型概览
| 类型 | 适用场景 | 数据结构 |
|---|---|---|
| B-Tree | 等值、范围、排序(默认) | 平衡树 |
| Hash | 仅等值查询 | 哈希表 |
| GiST | 几何、全文、自定义 | 通用搜索树 |
| SP-GiST | 非平衡空间数据 | 空间分区树 |
| GIN | 数组、JSONB、全文检索 | 倒排索引 |
| BRIN | 超大表、物理有序列 | 块范围摘要 |
二、B-Tree 索引内部结构
2.1 页面组织
B-Tree 索引文件
│
├─ Page 0:Meta Page(元页)
│ 记录根页块号、树高度、最左叶子页等
│
├─ Page 1:Root Page(根页,初始时也是叶子页)
│
├─ Internal Pages(内部页)
│ 存储键值 + 子页指针,用于导航
│
└─ Leaf Pages(叶子页)
存储键值 + ctid(指向堆表)
叶子页之间通过双向链表连接(支持顺序扫描)
2.2 叶子页结构
Leaf Page
├─ Page Header(lsn, flags, lower, upper, special)
├─ ItemId 数组(行指针,从 lower 向上增长)
├─ 空闲空间
├─ Index Tuples(从 upper 向下增长)
│ 每个 tuple = 键值 + ctid + heap tid(去重模式)
└─ BTPageOpaqueData(special space)
btpo_prev, btpo_next(叶子页双向链表)
btpo_level(0 = 叶子)
btpo_flags(BTP_LEAF, BTP_ROOT 等)
2.3 死亡索引条目(LP_DEAD)
MVCC 下 UPDATE/DELETE 产生死元组,对应的索引条目不会立即删除,而是标记为 LP_DEAD。
VACUUM 负责清理这些死亡索引条目,流程:
- 堆扫描收集死元组 TID
- 对每个索引执行
index_bulk_delete,删除指向这些 TID 的条目 - 更新索引统计(
pg_stat_user_indexes)
三、索引膨胀(Index Bloat)
3.1 膨胀原因
- 频繁 UPDATE:旧版本 tuple 的索引条目变为死亡条目,VACUUM 清理后留下空洞
- 频繁 DELETE:同上
- HOT 更新可以避免索引膨胀(更新列不在索引中时)
- VACUUM 只能复用页内空间,无法收缩索引文件大小
3.2 评估索引膨胀
sql
-- 方法1:使用 pgstattuple
CREATE EXTENSION pgstattuple;
SELECT FROM pgstatindex('mytablepkey');
-- 关注 leaf_fragmentation(叶子页碎片率)和 avg_leaf_density(叶子页填充率)
-- 方法2:估算膨胀比
SELECT
indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relname = 'mytable'
ORDER BY pg_relation_size(indexrelid) DESC;
-- 方法3:pgstattuple 详细信息
SELECT FROM pgstatindex('myindex');
pgstatindex 输出关键字段:
| 字段 | 说明 |
|---|---|
| version | B-Tree 版本 |
| tree_level | 树高度 |
| index_size | 索引总大小(字节) |
| root_block_no | 根页块号 |
| internal_pages | 内部页数量 |
| leaf_pages | 叶子页数量 |
| empty_pages | 空页数量 |
| deleted_pages | 已删除页数量 |
| avg_leaf_density | 叶子页平均填充率(越低越膨胀) |
| leaf_fragmentation | 叶子页碎片率 |
四、索引重建
4.1 REINDEX
sql
-- 重建单个索引(阻塞读写)
REINDEX INDEX myindex;
-- 重建表的所有索引(阻塞读写)
REINDEX TABLE mytable;
-- 重建整个数据库的索引(阻塞读写)
REINDEX DATABASE mydb;
-- 重建系统目录索引
REINDEX SYSTEM mydb;
REINDEX 的问题 :持有 AccessExclusiveLock,阻塞所有读写操作,生产环境不可接受。
4.2 REINDEX CONCURRENTLY(PG 12+)
sql
-- 并发重建,不阻塞读写
REINDEX INDEX CONCURRENTLY myindex;
REINDEX TABLE CONCURRENTLY mytable;
REINDEX DATABASE CONCURRENTLY mydb;
并发重建原理:
- 创建新索引(标记为 invalid)
- 等待所有持有旧索引快照的事务结束
- 新索引追赶增量变更
- 再次等待事务结束
- 将新索引标记为 valid,旧索引标记为 dead
- 等待所有使用旧索引的事务结束
- 删除旧索引
注意事项:
- 执行期间磁盘空间需要容纳两份索引
- 不能在事务块内执行
- 如果中途失败,会留下
INVALID状态的索引,需手动清理
sql
-- 检查无效索引
SELECT indexrelid::regclass, indisvalid
FROM pgindex
WHERE NOT indisvalid;
-- 清理无效索引
DROP INDEX CONCURRENTLY invalid_index_name;
4.3 CREATE INDEX CONCURRENTLY(替代方案)
sql
-- 并发创建新索引
CREATE INDEX CONCURRENTLY new_idx ON mytable(col);
-- 验证新索引可用后,删除旧索引
DROP INDEX CONCURRENTLY old_idx;
-- 重命名(可选)
ALTER INDEX new_idx RENAME TO old_idx;
五、表重建
5.1 为什么需要重建表
普通 VACUUM 只能将死元组空间标记为可复用,但:
- 不会缩小表文件(不归还 OS)
- 表尾部的空页可以被截断,但中间的空洞无法消除
- 长期高频更新/删除后,表文件持续膨胀
5.2 VACUUM FULL
sql
VACUUM FULL mytable;
原理:
- 获取
AccessExclusiveLock(阻塞所有操作) - 创建新的堆文件
- 将所有活跃 tuple 按顺序写入新文件
- 重建所有索引
- 删除旧文件,用新文件替换
缺点:
- 完全阻塞业务
- 需要额外磁盘空间(同时存在新旧两份文件)
- 执行期间产生大量 WAL
5.3 CLUSTER
sql
-- 按索引顺序重建表(物理排序)
CLUSTER mytable USING myindex;
-- 后续可直接执行(使用上次指定的索引)
CLUSTER mytable;
与 VACUUM FULL 的区别:
- CLUSTER 按指定索引的键值顺序重排 tuple,提升该索引的顺序扫描性能
- 同样持有
AccessExclusiveLock,同样阻塞业务 - 适合范围查询频繁的场景(如时间序列数据)
注意:CLUSTER 不是持久化的,后续 INSERT/UPDATE 不会维持顺序,需定期重新执行。
5.4 pg_repack(在线重建,推荐)
pg_repack 是第三方扩展,实现不阻塞业务的在线表重建。
bash
# 安装
apt install postgresql-14-repack
# 或
CREATE EXTENSION pg_repack;
重建表(不阻塞)
pg_repack -d mydb -t mytable
重建表并按索引排序(类似 CLUSTER)
pg_repack -d mydb -t mytable --order-by "created_at"
只重建索引
pg_repack -d mydb -t mytable --only-indexes
重建整个数据库
pg_repack -d mydb
pg_repack 原理:
- 创建日志表,记录重建期间的变更(INSERT/UPDATE/DELETE)
- 创建触发器,将变更写入日志表
- 后台并发复制原表数据到新表
- 追赶日志表中的增量变更
- 短暂加锁(AccessExclusiveLock,仅毫秒级)
- 交换新旧表的 relfilenode(原子操作)
- 删除旧表文件和触发器
优势:
- 重建期间表正常读写
- 最终加锁时间极短(通常 < 1 秒)
- 支持表重建、索引重建、按列排序
限制:
- 表必须有主键或唯一非空索引
- 需要额外磁盘空间(约等于原表大小)
- 不支持
pg_catalog系统表
六、填充因子(fillfactor)
6.1 概念
fillfactor 控制数据页的填充比例,剩余空间预留给 UPDATE 使用。
- 堆表默认:100(填满)
- B-Tree 索引默认:90(预留 10% 给分裂)
6.2 设置 fillfactor
sql
-- 建表时设置
CREATE TABLE mytable (id int, val text)
WITH (fillfactor = 70);
-- 修改已有表(需 VACUUM FULL 或 CLUSTER 才生效)
ALTER TABLE mytable SET (fillfactor = 70);
-- 索引 fillfactor
CREATE INDEX myindex ON mytable(id) WITH (fillfactor = 80);
6.3 fillfactor 与 HOT 更新的关系
较低的 fillfactor 在页内预留空间,使 UPDATE 后的新版本 tuple 更容易留在同一页内,从而触发 HOT 更新,减少索引膨胀。
fillfactor = 70 → 每页预留 30% 空间
→ UPDATE 时新 tuple 大概率在同页
→ 触发 HOT 更新
→ 索引无需更新
→ 减少索引膨胀
对于高频 UPDATE 的表,建议设置 fillfactor = 70~80。
七、索引使用监控
7.1 找出未使用的索引
sql
SELECT
schemaname,
relname AS table_name,
indexrelname AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan AS scans
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conindid = indexrelid
)
ORDER BY pg_relation_size(indexrelid) DESC;
7.2 找出重复索引
sql
SELECT
a.indexrelid::regclass AS index1,
b.indexrelid::regclass AS index2,
a.indrelid::regclass AS table_name
FROM pg_index a
JOIN pg_index b
ON a.indrelid = b.indrelid
AND a.indexrelid < b.indexrelid
AND a.indkey = b.indkey;
7.3 索引命中率
sql
SELECT
relname,
100.0 idxscan / nullif(seq_scan + idx_scan, 0) AS index_hit_rate,
seq_scan,
idx_scan
FROM pg_stat_user_tables
ORDER BY seq_scan DESC;
八、总结
索引膨胀来源:
MVCC 死元组 → 死亡索引条目 → VACUUM 清理但不收缩文件
解决索引膨胀:
├─ 日常:VACUUM 自动清理死亡条目(不收缩)
├─ 重建:REINDEX CONCURRENTLY(不阻塞,PG12+)
└─ 预防:合理设置 fillfactor,利用 HOT 更新减少索引写入
表膨胀解决方案对比:
表膨胀解决方案对比
| 方案 | 阻塞业务 | 归还 OS 空间 | 物理排序 | 额外磁盘 | 适用场景 |
|---|---|---|---|---|---|
| VACUUM | 否 | 否(仅页内复用) | 否 | 无 | 日常维护,死元组清理 |
| VACUUM FULL | 是(全程) | 是 | 否 | 需同等大小 | 小表或维护窗口期 |
| CLUSTER | 是(全程) | 是 | 是(按索引排序) | 需同等大小 | 范围查询频繁的表 |
| REINDEX | 是(全程) | 是(仅索引) | 否 | 需同等索引大小 | 索引膨胀,有维护窗口 |
| REINDEX CONCURRENTLY | 否 | 是(仅索引) | 否 | 需两倍索引大小 | 索引膨胀,无维护窗口(PG12+) |
| pg_repack | 极短(< 1s) | 是 | 可选 | 需同等表大小 | 生产环境在线重建,推荐首选 |
选择建议
- 有维护窗口 + 小表 →
VACUUM FULL - 有维护窗口 + 需要物理排序 →
CLUSTER - 无维护窗口 + 表膨胀 →
pg_repack(需要主键或唯一非空索引) - 无维护窗口 + 索引膨胀 →
REINDEX CONCURRENTLY - 日常自动维护 →
autovacuum(配合合理的 fillfactor 预防膨胀)
生产环境建议:
✓ 优先用 pg_repack 替代 VACUUM FULL / CLUSTER
✓ 高频 UPDATE 表设置 fillfactor = 70~80 利用 HOT
✓ 定期检查未使用索引并清理,减少写放大
✓ 用 REINDEX CONCURRENTLY 替代 REINDEX 重建索引
✓ 监控 avg_leaf_density < 50% 时考虑重建索引