PostgreSQL 架构原理第二期:存储引擎深度解析 —— 堆表、元组结构与 TOAST 机制

0 阅读10分钟

PostgreSQL 架构原理第二期:存储引擎深度解析 —— 堆表、元组结构与 TOAST 机制

引言

在上一期中,我们从整体架构入手,探讨了 PostgreSQL 的进程模型、共享内存、查询处理全流程、WAL 以及缓冲区管理器。理解这些上层机制后,一个自然的问题是:数据最终是如何在磁盘上组织存储的?PostgreSQL 的存储引擎以“堆表 + 索引 + TOAST”的组合方式,实现了高效的按行存取、可变长度字段支持以及 MVCC 所需的多版本管理。

本文将深入数据文件的内部世界,详细讲解以下内容:

  1. 逻辑结构与物理文件的映射关系
  2. 堆表文件的组成:主文件、FSM、VM
  3. 页面(Page)的内部布局
  4. 元组(Tuple)的存储格式与关键字段
  5. TOAST 机制的触发条件与工作原理
  6. 索引的基本存储结构(以 B-tree 为例)

一、逻辑结构到物理文件的映射

PostgreSQL 中,一个数据库实例包含多个数据库,一个数据库包含多个模式,模式中包含表、索引等对象。每个表(或索引)在物理上对应着一个或多个磁盘文件。

1.1 OID 与 relfilenode

每个数据库对象都有一个唯一的对象标识符(Object Identifier, OID),存储在其系统表 pg_class 中。对于表和索引,pg_class.relfilenode 字段指定了磁盘上使用的文件名。绝大多数情况下 relfilenode = OID,但执行 TRUNCATEREINDEX 或某些 VACUUM FULL 操作时,relfilenode 会发生变化,从而创建一个新文件。

1.2 表空间与路径

表空间(tablespace)允许将不同的数据库对象存储在不同的文件系统位置上。每个表空间目录下包含以数据库 OID 命名的子目录,数据库子目录下存放着以 relfilenode 命名的文件。默认表空间 pg_default 使用数据目录下的 base 目录。

1.3 超过 1GB 的文件分段

为了避免某些操作系统对单个文件大小的限制(如 2GB),PostgreSQL 将大于 1GB 的文件自动分割成多个段(segment)。主文件名为 relfilenode,后续段命名为 relfilenode.1relfilenode.2,依此类推。段大小可在编译时通过 --with-segsize 修改,默认 1GB。


二、堆表的相关文件

对于一个普通的堆表(heap table),通常会伴随三个文件:

文件后缀含义
主数据文件,存储实际的行数据
_fsm空闲空间映射(Free Space Map)
_vm可见性映射(Visibility Map)

2.1 空闲空间映射(FSM)

FSM 记录了每个数据页内部的空闲空间大小。当需要插入新行时,PostgreSQL 不需要扫描所有页面来寻找空闲空间,而是快速查询 FSM 找到足够空间的页面。FSM 本身使用一棵三层的树结构组织,存储在专用的 _fsm 文件中。

2.2 可见性映射(VM)

VM 标记哪些页面中全部元组对所有当前事务都是可见的(即没有任何需要清理的死元组)。VACUUM 操作可以跳过这些页面,显著提高清理效率。同时,仅索引扫描(Index-Only Scan) 依赖 VM 来判断是否可以直接使用索引元组而不回表访问堆元组。VM 以位图形式存储,每个位代表一个页面。


三、页面内部结构

PostgreSQL 磁盘数据的基本单位是 页面(Page),默认大小为 8KB。一个页面可以分为几个逻辑区域:

+---------------------------+  <-- 页头
| PageHeaderData (24 bytes)  |
+---------------------------+  <-- 行指针起始
| ItemIdData (4 bytes each)  |  指向实际元组的偏移量和长度
| ...                         |
+---------------------------+  <-- 空闲空间
| 未使用空间                  |
| ...                         |
+---------------------------+  <-- 元组数据起始
| 元组数据 (HeapTuple)        |  从页面尾部向前填充
| ...                         |
+---------------------------+  <-- 特殊空间 (可选)

3.1 页头 PageHeaderData

页头固定 24 字节(不含可选的特殊空间),包含以下重要字段:

  • pd_lsn:本页面最后一次修改对应的 WAL 日志的 LSN。
  • pd_checksum:页面的校验和(如果启用)。
  • pd_flags:标志位,如是否包含空行指针、是否全部可见等。
  • pd_lower:行指针区域末尾的偏移量(即空闲空间开始处)。
  • pd_upper:元组数据区域起始的偏移量(即空闲空间结束处)。
  • pd_special:特殊空间起始偏移量(例如 B-tree 索引页面用于存放右兄弟指针)。
  • pd_pagesize_version:页面大小及版本信息。
  • pd_prune_xid:本页面中需要清理的最早旧事务 ID,用于提示 VACUUM

行指针(ItemIdData,4 字节)包含元组在页面内的偏移量、长度以及一些标志位。行指针数组从 pd_lower增长,元组数据从 pd_upper增长。当两者相遇时,页面空间耗尽。

3.2 特殊空间

只有某些访问方法需要特殊空间。对于堆表,特殊空间大小为 0;对于 B-tree 索引页面,特殊空间存储了页面级别信息(如右兄弟页号)。


四、元组(Tuple)的存储格式

堆表中的每一行对应一个元组。元组包含两部分:元组头用户数据

4.1 元组头结构 HeapTupleHeaderData

元组头长度固定为 23 字节(通常对齐到 24 字节),关键字段如下:

字段类型含义
t_xminTransactionId插入该元组的事务 ID(XID)
t_xmaxTransactionId删除或更新该元组的事务 ID(0 表示未删除)
t_cidCommandId在当前事务内插入/删除该元组的命令序号
t_ctidItemPointerData当前元组自身的位置(页面号 + 行指针索引),若被更新则指向新版本元组
t_infomaskuint16标志位,包含 XID 有效性、元组是否具有空属性、是否有变长字段、是否已提交/已中止等
t_infomask2uint16第二组标志位,以及当前元组所拥有的列数(hev_natts
t_hoffuint8用户数据相对于元组起始位置的偏移量(即元组头的实际长度,可能包含位图和对齐填充)
t_bits[]可选空值位图,每个列对应 1 位

4.2 用户数据部分

紧接在元组头之后,存储各列的实际数值。根据列的数据类型和对齐要求,PostgreSQL 会进行适当填充,以保证整型、浮点型等能够自然对齐。

对于 变长类型(如 varchartextbytea),PostgreSQL 使用特殊的格式:

  • 长度 ≤ 127 字节时:使用 1 字节长度前缀,数据紧随其后。
  • 长度 > 127 字节时:使用 4 字节长度前缀(最高位置 1 表示扩展模式),数据单独存储。
  • 极长的数据(TOAST)会触发外部存储。

4.3 元组与 MVCC

PostgreSQL 采用 多版本并发控制(MVCC),即 UPDATE 操作不直接修改原有元组,而是插入一个新版本元组,并修改旧元组的 t_xmaxt_ctid 指向新版本。DELETE 操作仅设置 t_xmax。旧版本元组仍然留在页面中,直到被 VACUUM 清除。

元组头上的 t_infomask 中的 HEAP_XMIN_COMMITTED / HEAP_XMIN_ABORTED 等位用于快速判断元组的可见性,避免每次查询都去查找事务提交状态。


五、TOAST 机制:处理超大字段

5.1 触发条件

PostgreSQL 的页面大小固定为 8KB,但一个元组不能跨多个页面存储(B-tree 索引不允许跨页,堆表也不行)。因此,单个元组的总长度必须小于 8KB(实际稍小,因为页头和行指针占用空间)。当元组大小超过约 2KB 时,TOAST(The Oversized-Attribute Storage Technique)就会介入。

默认情况下,变长类型(textvarcharbyteajsonb 等)以及 复合类型 可以使用 TOAST。用户也可以通过 ALTER TABLE ... SET STORAGE 显式控制存储策略:

  • PLAIN:禁止 TOAST
  • EXTENDED:允许压缩和外部存储(默认)
  • EXTERNAL:允许外部存储但不压缩
  • MAIN:优先压缩,尽量不外部存储

5.2 工作原理

当插入或更新一行导致元组过大时,PostgreSQL 会:

  1. 压缩:如果策略允许压缩,对列数据使用 LZ 压缩算法(轻量级,快速解压)。
  2. 分块外部存储:如果压缩后仍然超过阈值(约 2KB),则将数据切分成多个 TOAST 块(默认一个块与页面大小相同,实际存储时每个块占一个页面),每个块作为一个独立元组存入一个专门的 TOAST 表 中。

TOAST 表与主表关联:主表为 mytable 时,其 TOAST 表名为 pg_toast.pg_toast_<主表 OID>。TOAST 表自己也有 _fsm_vm 文件。

主表元组中的 TOAST 列会被替换为一个指针(varatt_external 结构),该指针包含:

  • TOAST 表的 OID
  • TOAST 块起始页号
  • 块长度等信息

查询时,如果只选择非 TOAST 列,PostgreSQL 可以完全跳过 TOAST 表;如果必须读取 TOAST 列,则按需从 TOAST 表中读取块,必要时自动解压。

5.3 TOAST 的优势

  • 避免跨页存储,保证堆表元组始终能容纳于一个页面内。
  • 节省 I/O:不需要读取整个大字段即可访问小字段。
  • 透明压缩:对应用层无感知,同时减少磁盘空间和 I/O 量。

六、索引存储概览(以 B-tree 为例)

虽然本系列未来会有专门章节介绍索引,这里先简要说明 B-tree 索引的存储特点,以与堆表对比。

6.1 B-tree 索引文件

B-tree 索引同样使用一个主文件(以及可能的 FSM 和 VM 文件),内部也划分为 8KB 页面。但与堆表不同,索引页面没有行指针数组,而是由一系列 索引元组(IndexTuple) 组成。每个索引元组包含:

  • 索引键值(一个或多个列的值)
  • 对应的堆元组的 TID(页号 + 行指针)

B-tree 页面结构分为 元数据页(通常第 0 页)、内部页叶子页。内部页存储指针和键值范围,叶子页存储索引元组并指向堆元组。

6.2 唯一索引与非唯一索引

  • 唯一索引:索引元组的键值在整个索引中唯一。
  • 非唯一索引:PostgreSQL 在内部将 TID 作为键值的后缀,使每个元组在逻辑上唯一,便于实现查重和并发控制。

6.3 与堆表的协作

当执行索引扫描时,PostgreSQL 首先在 B-tree 中查找到满足条件的索引元组,获得 TID 后再去堆表中抓取完整的堆元组(需要检查可见性)。如果 VM 标记了对应堆页面全部可见,则可以省略可见性检查,这也是 仅索引扫描 的前提。


总结与预告

本期我们深入探讨了 PostgreSQL 的存储层,梳理了从逻辑对象到物理文件的映射、堆表关联的三种文件(主文件、FSM、VM),并详细分析了页面和元组的内部布局,尤其是 MVCC 相关的头字段。然后介绍了 TOAST 机制如何优雅地处理超大字段,最后简述了 B-tree 索引的基本存储方式。

理解存储层有助于推断 I/O 行为、估算表和索引大小、诊断性能问题(例如高膨胀率可能是因为 VACUUM 不及时)。下一期我们将正式进入 事务与并发控制,系统讲解 PostgreSQL 的 MVCC 实现细节、事务快照、锁机制以及隔离级别,敬请期待。


思考题

  1. 对于频繁执行 UPDATE 的表,为什么 pg_class.relfilenode 可能不随 VACUUM FULL 改变?而 TRUNCATE 一定会改变?
  2. 一个元组被 UPDATE 十次且不执行 VACUUM,那么物理磁盘上最多会存在多少个该行的版本?
  3. TOAST 存储为什么优先压缩而不是直接外部存储?在哪种场景下应该使用 EXTERNAL 策略?