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% 时考虑重建索引

相关推荐
东方佑13 分钟前
去魅:工具调用、Agent 调用、A2A、MCP、Harness 到底都是什么
服务器·网络·数据库
l1t15 分钟前
DeepSeek总结的ALTER FUNCTION SET work_mem:无需全局更改即可修复 PostgreSQL 磁盘溢出
sql·缓存·postgresql
瑞克-桑切斯41 分钟前
2、 MySQL 主从复制与读写分离
数据库·mysql
收纳过往1 小时前
数据库加索引的时机:慢查询日志说话
android·开发语言·数据库·python·低代码·rxjava
咖啡八杯1 小时前
Redis 工具封装:RedisCache 对 Operations 体系的二次封装
java·数据库·redis·缓存·框架·springboot·若依
PaperData1 小时前
2000-2025年地级市科技支行设立数据
数据库·科技
实泽有之,无泽虚之1 小时前
ORA-12514:TNS:监听程序当前无法识别连接描述符中请求的服务问题原因及解决办法
数据库·oracle
牧瀬クリスだ2 小时前
Redis Hash实战:高效存储对象与购物车
java·数据库·redis·算法·哈希算法
A小码哥2 小时前
【外】水星3号实测 1068 token 每秒靠谱吗
数据库
qq_452396232 小时前
第六篇:《SQL 优化的艺术:从慢查询到毫秒级响应》
数据库·sql