Sparkle CodesSparkle
项目 / 数据库

PostgreSQL 索引基础架构

x
xpx
Dec 25, 2024
Editorial Insight
#PostgreSQL#底层原理#数据库#索引

PostgreSQL 索引基础架构

理解 PostgreSQL 索引,首先要理解它的体系架构。PostgreSQL 把索引实现抽象成 Index Access Method,这是一个可扩展的框架,不仅内核自带了多种索引类型,还支持通过扩展接入新的索引能力。

从体系结构看:索引是"访问方法"

PostgreSQL 把索引实现抽象成 Index Access Method。B-tree、GiST、SP-GiST、GIN、BRIN、Hash 都是不同的 access method;每一种都有自己如何建索引、如何扫描、如何维护页结构的实现。

扫描阶段,访问方法负责根据 scan keys 找出匹配的 TID 集合;而真正去 heap 取行、做 MVCC 可见性判断,并不由索引方法负责。

这种设计让 PostgreSQL 的索引体系具有高度可扩展性:pgvector、PostGIS 等扩展正是通过实现新的 access method 或 operator class 来接入索引机制的。

二级索引,不是聚簇索引

这点很关键。PostgreSQL 所有普通索引都是 secondary indexes:索引和表分开存储,索引项里记录的是键值 + TID,而不是"数据本身按索引顺序物理组织"。

因此:

  • 普通 Index Scan:索引定位 + 回表
  • Index Only Scan:只有当查询列都在索引里,且 heap 页被标记为 all-visible 时,才可能不回表

这和 InnoDB 的主键聚簇索引思维很不一样。PostgreSQL 的 CLUSTER 只是一次性按某索引重写表,不会持续保持聚簇。

索引的底层数据结构

B-tree:默认、最常用、最重要

结构

PostgreSQL 默认索引是 B-tree,本质是多路平衡树,适合可排序数据。它支持等值、范围、排序相关查询,是最通用的索引类型。

页级组织

B-tree 由 root / internal / leaf pages 组成。真正指向 heap 行的条目主要在 leaf page。内部页保存分隔键,负责导航。B-tree 页按键有序,便于区间扫描和 ORDER BY 利用。

2026 视角下值得重点理解的实现细节

PostgreSQL 近几年 B-tree 的关键优化,不只是"树结构",而是 MVCC 下如何控制版本膨胀:

底向上删除(bottom-up index deletion)

在 MVCC 下,一条逻辑行更新后,旧版本和新版本可能都对应索引项。B-tree 本身并不理解"这是同一逻辑行的多个版本",它只看到多个独立索引元组,因此会出现 version churn。从 PostgreSQL 14 起,B-tree 增强了 bottom-up deletion:当叶子页将要 split 时,会尝试有针对性地清理旧版本索引项,尽量避免页分裂并降低扫描时要穿过的旧版本数量。

去重(deduplication)

对于重复键值很多的索引,PostgreSQL 会把多个相同键值的叶子项合并成一个 posting list tuple:键值只存一次,后面跟一个排好序的 TID 数组。这会显著减少索引体积,降低 I/O,并改善查询延迟。该特性默认开启。

dedup 不是无条件可用

INCLUDE 索引不能使用 dedup;某些类型或排序规则下也不能安全 dedup,比如非确定性排序规则下的文本、numeric、jsonb、浮点数等。PostgreSQL 通过 operator class 的 equalimage 支持函数判断"相等是否意味着镜像等价",从而决定 dedup 是否安全。

结论

PostgreSQL 的 B-tree 不只是"普通平衡树",而是一个被 MVCC、VACUUM、页分裂、去重、HOT 更新共同塑造的工程化 B-tree。真正的性能差异,很多时候出在这些维护机制,而不是"树高"本身。

Hash:哈希桶结构,专做等值

Hash 索引是持久化、可崩溃恢复的 on-disk hash index。它只支持 单列、只支持 =,也不支持唯一性约束。每个索引项只保存 4 字节哈希值,不保存原始列值,因此对长值字段可能比 B-tree 更小。它的叶子等价物是 bucket pages,理论上做大表等值查找时可以直接定位 bucket,少掉 B-tree 的树下降过程。

但 Hash 也有明显限制:

  • 只支持等值,不支持范围
  • 扫描是 lossy 的,因为只存 hash,仍需 recheck
  • 只支持单列,不能 unique

因此生产里 Hash 并不常见,很多场景 B-tree 已足够好。

GiST:广义搜索树

GiST 是 Generalized Search Tree,是一个 balanced, tree-structured 的通用框架。可以把它理解成"索引模板":B-tree、R-tree 风格的逻辑都可以在 GiST 框架下实现。它非常适合几何、范围、全文某些场景、最近邻等复杂搜索。

GiST 的核心思想不是"存精确键值导航",而是节点存某种概括/包围信息,扫描时逐层裁剪搜索空间。因此它常常可能返回"候选",再去 recheck。也正因为如此,GiST 很适合"相似性""空间关系""区间重叠"这类不能简单线性排序的问题。

SP-GiST:空间划分树

SP-GiST 是 space-partitioned GiST。它支持 quad-tree、k-d tree、radix tree/trie 这种"不断把搜索空间切分"的非平衡结构。它不是按"平衡树包围盒"思路工作,而是按 partitioning rule 把空间或前缀空间反复切分。对"天然可分区"的数据(如前缀、点集、某些几何分布),匹配得好时会非常快。

它的意义在于:有些数据更适合"分区树"而不是"平衡树"。比如 trie 风格前缀检索,就比硬塞进 B-tree 更自然。

GIN:倒排索引

GIN 是 PostgreSQL 里理解数组、JSONB、全文检索时最关键的索引之一。它的模型是:

  • item:整条被索引值(比如一个数组、一个 jsonb、一个 tsvector)
  • key:item 里的元素
  • 索引存的是 (key, posting list) 对

内部上,GIN "外层"是一个基于 key 的 B-tree;叶子项要么直接带一个小的 TID 列表(posting list),要么指向一个保存大量 TID 的 posting tree。

所以 GIN 最适合的问题不是"列值本身排序",而是:

  • 这个数组里是否包含某元素
  • 这个 JSON 文档里是否有某键/值路径
  • 这本质是典型倒排索引问题。

详见 GIN 索引详解。

BRIN:块范围摘要索引

BRIN 不是"按行建立精确索引",而是按 block range 建摘要。一个 BRIN entry 代表一段 heap page 范围,例如默认每 128 个块一个范围。它保存的是该范围的摘要信息,如:

  • min/max
  • inclusion
  • bloom
  • minmax-multi

因此 BRIN 非常小,维护成本很低,但天然是 lossy:它只能告诉你"这段块范围可能有匹配",然后再去扫描这段 heap 范围。

什么时候 BRIN 爆发式有效

当表的物理存储顺序和列值有较强相关性时,比如:

  • 时间递增写入的日志表
  • 自增 id 与物理顺序接近一致
  • 大型 append-only 事实表

这时 BRIN 只用极小代价就能排除绝大多数不相关块。

什么时候 BRIN 没用

若列值和物理页分布完全随机,BRIN 的 min/max 范围会高度重叠,过滤力会很弱。此时它会变成"便宜但不太准的块过滤器"。

PostgreSQL 到底有哪些索引

内置访问方法

截至 PostgreSQL 18 当前文档,核心索引类型是:

  • B-tree
  • Hash
  • GiST
  • SP-GiST
  • GIN
  • BRIN

官方扩展里常见的

  • bloom:基于 Bloom filter 的 access method,空间效率高,但有假阳性,必须回表 recheck
  • btree_gist:让很多普通数据类型以 GiST 风格参与索引,常用于 exclusion constraints
  • btree_gin:让很多普通类型以 GIN 风格参与组合索引场景

从 SQL 功能形态看

这些不是新的 access method,而是"索引定义方式":

  • 单列索引
  • 多列索引
  • 唯一索引
  • 部分索引(partial index)
  • 表达式索引(expression index)
  • 覆盖索引 / INCLUDE
  • 并发创建索引 CREATE INDEX CONCURRENTLY

下一步

理解了基础架构后,可以继续阅读 PostgreSQL 索引性能优化原理 了解索引如何优化性能,以及 PostgreSQL 索引体系总览 了解整体框架。

BACK TO BLOG
The End of Interaction