PostgreSQL 索引内部机制与表重建深度解析

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 负责清理这些死亡索引条目,流程:

  1. 堆扫描收集死元组 TID
  2. 对每个索引执行 index_bulk_delete,删除指向这些 TID 的条目
  3. 更新索引统计(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;

并发重建原理:

  1. 创建新索引(标记为 invalid)
  2. 等待所有持有旧索引快照的事务结束
  3. 新索引追赶增量变更
  4. 再次等待事务结束
  5. 将新索引标记为 valid,旧索引标记为 dead
  6. 等待所有使用旧索引的事务结束
  7. 删除旧索引

注意事项:

  • 执行期间磁盘空间需要容纳两份索引
  • 不能在事务块内执行
  • 如果中途失败,会留下 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;

原理:

  1. 获取 AccessExclusiveLock(阻塞所有操作)
  2. 创建新的堆文件
  3. 将所有活跃 tuple 按顺序写入新文件
  4. 重建所有索引
  5. 删除旧文件,用新文件替换

缺点:

  • 完全阻塞业务
  • 需要额外磁盘空间(同时存在新旧两份文件)
  • 执行期间产生大量 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 原理:

  1. 创建日志表,记录重建期间的变更(INSERT/UPDATE/DELETE)
  2. 创建触发器,将变更写入日志表
  3. 后台并发复制原表数据到新表
  4. 追赶日志表中的增量变更
  5. 短暂加锁(AccessExclusiveLock,仅毫秒级)
  6. 交换新旧表的 relfilenode(原子操作)
  7. 删除旧表文件和触发器

优势:

  • 重建期间表正常读写
  • 最终加锁时间极短(通常 < 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% 时考虑重建索引

相关推荐
Omics Pro3 分钟前
研究证实AI虚拟细胞可用于药物靶点发现
数据库·人工智能·算法·机器学习·自然语言处理
好奇的菜鸟7 分钟前
GORM 入门(二):从 database/sql 平滑过渡到 GORM
数据库·sql
长春的KAKA20 分钟前
GitLab 项目页面报 500 错误的完整修复记录:PostgreSQL 数据库损坏、统计表缺失及数据库迁移
数据库·postgresql·gitlab
这个DBA有点耶33 分钟前
关系数据库管理系统选型指南:四步决策框架与主流产品技术路线对比
数据库·sql·程序人生·mysql·database·dba
jason.zeng@150220741 分钟前
(七)「固化 Rest 接口 + Text-to-SQL 灵活查询」双模式 Agent 架构教程
数据库·python·sql·ai·架构·langchain·ai编程
我不会起名字3221 小时前
MVCC 快照读为什么读不到刚提交的数据:ReadView 的 4 条可见性规则
数据库·mysql·innodb·mvcc·事务隔离
2501_933670792 小时前
2027届数据类专业投用户增长岗:A/B实验、指标和SQL准备思路
数据库·sql
用户337922545682 小时前
Sirchmunk 深度解析(一):一个无需向量数据库的自进化搜索引擎架构设计
数据库
染指11102 小时前
139.Agent-多Agent框架-Skills渐进式加载文档
数据库·人工智能·langchain·agents
骑着蜗牛撵大象3272 小时前
SpringBoot+Vue3 企业智能体侧挂架构:独立服务、独立数据库与主线零侵入落地
数据库·spring boot·架构·vue·springboot·事件驱动·服务拆分