页面(Page)通常也被称为堆页面、页、块等,是 PostgreSQL 在物理磁盘上存储数据的基本单位。

PostgreSQL 的页面有着固定的大小和格式,每当 PostgreSQL 需要读取或写入数据时,PostgreSQL 会先将整个页面读入会话间共享的缓存中,再进行操作。然后根据需要将有修改的缓存页面写回磁盘。

页面的大小通常为 8KB,在某种程度上可以配置页面大小(最大 32 kB),但只能在编译时进行配置(./configure –with-blocksize),但通常没有人这样做。一旦编译并启动,实例只能处理大小相同的页面,因此无法创建支持不同页面大小的表空间。

表、索引、TOAST 数据等都存储在页面中。用于存储这些数据的页面有着共同的格式,仅有项(Item)内部的格式不同。页面的格式如下:

  • 页头 :包含页面的元数据,描述页面的类型、大小、校验和等信息。
  • 项指针 :指向页面中存储的项起始地址的指针,每个指针占用四个字节,包含元组从页面开始的偏移量、元组长度、定义元组状态的若干比特位。
  • 空闲空间 :页面中未使用的空间,通常用于存储新的项。
  • :存储实际数据的区域,包含一个或多个项,每个项都有自己的格式和结构。
  • 特殊空间 :根据页面数据类型的不同会存储一些特殊信息,如 TOAST 数据、索引等。在存储表的数据时特殊空间大小为0。

为什么需要项指针而不是直接存储项的起始地址?

项指针通常用于索引和 TOAST 数据等。由于 PostgreSQL 使用多版本并发控制(MVCC)来处理并发事务,因此在同一页面中可能会有多个版本的项。在对页面进行修改和清理时项的起始地址可能会发生变化。 在这种情况下,如果在索引中直接存储项的起始地址,那么每次移动项时都需要更新位于另一个页面中索引存储的地址,这会导致频繁的 I/O 操作和性能下降。 因此 PostgreSQL 引入了项指针,以增加一层间接引用的代价来避免因为移动项而导致的索引更新。

空闲空间与碎片化

在 PostgreSQL 中,页面的空闲空间一定是连续的。当页面中有项被删除或更新时,PostgreSQL 会将该项标记为已删除,但并不会立即释放该项所占用的空间。一方面是为了让未完成的事务仍然可以访问该项,避免数据不一致;另一方面是为了避免空间碎片化。 当页面中已使用的空间比例大于 fillfactor 存储参数(默认值为 100%)时,PostgreSQL 会在页面中执行 页剪枝(Page Pruning) 操作,将已删除的项从页面中移除,并压缩(注意是 Compact 而不是 Compress)仍然存活的项。 页剪枝后页面的空闲空间仍然是连续的。

页面中的项通常使用版本化的元组来存储数据。

行(Row)、元组(Tuple)、项(Item)

行(Row)是用户视角下的逻辑概念,表示表中一条完整的记录,包含所有用户定义的字段值。例如,用户通过 SELECT 查询到的结果就是“行”的集合。

元组(Tuple)是 PostgreSQL 存储引擎中的物理概念,表示数据在磁盘或内存中的实际存储形式。每个元组对应表中的一行数据,但包含额外的系统信息(如 xmin、xmax 等 MVCC 字段)。

项(Item)是 PostgreSQL 存储介质抽象中页面内部的数据结构。

由于 PostgreSQL 使用 MVCC 来处理并发事务,因此每个行可能会有多个版本(即多个元组),每个版本对应一个不同的事务 ID。 用户看到的“行”实际上是通过 MVCC 机制从多个可能的元组中选择的一个版本。这些元组则以项的形式存储在页面中。

PostgreSQL 的索引只会增加,不会删除,因此索引页面不需要版本化。 在这种情况下,索引中存有所有版本的元组。和扫描整表一样通过元组自身的元数据来判断元组的有效性。