PostgreSQL 存储原理全解:从页面结构到 MVCC 的深度解析

PostgreSQL 存储原理全解:从页面结构到 MVCC 的深度解析

本文核心解答 :PostgreSQL 的数据到底如何落在磁盘上?为什么它能在高性能与高并发下保持 ACID?MVCC 多版本机制怎样用存储代价换取查询效率?WAL 日志又是如何充当数据生命线的?

阅读本文,你将系统掌握 PostgreSQL 存储层的完整知识图谱------这既是 DBA 的进阶必修课,也是理解数据库内部行为不可或缺的权威参考。

一、前言:为什么深入理解存储原理如此重要?

对于 DBA、后端开发者以及数据库内核爱好者来说,PostgreSQL 的存储层是整个系统的基石。不论是进行性能调优、容量规划、故障排查,还是设计高可用的流复制架构,对数据如何在磁盘上布局、多版本如何创建与清理、WAL 如何保障一致性的透彻理解,都会直接决定你解决问题的速度和方案的可靠性。

PostgreSQL 官方文档的"Database Physical Storage"章节以及源代码中 src/include/storage/src/backend/access/heap/ 的注释为本文提供了最权威的依据。本文将以高度结构化的方式,拆解文件布局、页面格式、MVCC 机制、TOAST 技术、WAL 日志以及索引存储的核心细节。通过大量对比表格、字段枚举和场景分析,你可以将本文当作一份系统性的技术手册,随时查阅和引用。

二、宏观数据布局:从 PGDATA 到文件

2.1 PGDATA 目录结构

PostgreSQL 实例的所有数据都存储在一个被称为 PGDATA 的目录中(环境变量 $PGDATA)。典型的初始化后结构如下(基于 PostgreSQL 16):

复制代码
PGDATA/
├── base/ # 用户数据库的实际数据文件
│ ├── 1/ # 数据库 OID(模板库 template1)
│ ├── 13761/ # 自定义数据库 OID
│ │ ├── 16385 # 表或索引对应的文件(relfilenode)
│ │ ├── 16385_vm # 可见性映射文件(Visibility Map)
│ │ └── 16385_fsm # 空闲空间映射文件(Free Space Map)
│ └── ...
├── global/ # 全局系统表(如 pg_database、pg_authid)
├── pg_wal/ # WAL 日志文件(PostgreSQL 10 之后由 pg_xlog 改名)
├── pg_stat/ # 统计信息文件
├── pg_tblspc/ # 表空间符号链接
└── postgresql.conf # 主配置文件

关键实体说明

  • 数据库 OID:每个数据库在集簇中由唯一的 OID 标识,其目录名即为 OID。
  • relfilenode :表或索引的物理文件标识。注意,TRUNCATE 等操作会改变 filenode,而 OID 可能不变。
  • 每个表对应三种文件 :主数据文件(relfilenode)、空闲空间映射文件(_fsm)、可见性映射文件(_vm)。它们协同工作以支持高性能的并发查询和清理。

官方文档第 73.1 节(Database File Layout)中明确写道:"每个表和索引都存储在一个单独的文件中,文件名为 relfilenode......当表或索引超过 1GB 时,会分割成多个段文件,后缀为 .1、.2 等。" 这种分段机制防止了某些文件系统对大文件的支持问题。

2.2 表空间

表空间允许你指定数据库对象的物理存储位置。通过 CREATE TABLESPACE 命令创建,并在 pg_tblspc/ 目录下生成一个指向实际路径的符号链接。从存储原理看,表空间目录下依然遵循 PG_DATA/base 类似的 OID/文件 结构。这一特性在云原生环境(如阿里云 RDS PostgreSQL 的 pg_highio 表空间或 Amazon Aurora PostgreSQL 的分布层)中被广泛应用于分层存储,平衡性能与成本。

三、页面结构:PostgreSQL 的最小 I/O 单元

PostgreSQL 的存储以页面(Page) 为基本单位,默认大小为 8KB(编译时可修改,但实际极少变动)。这种设计决定了许多行为:查询最小读取一个页面,WAL 写入也常以页面为边界,VACUUM 扫描更是逐页进行。

3.1 页面内部布局

每个数据页面由以下几部分组成(源码定义在 src/include/storage/bufpage.h):

部分 位置 说明
PageHeaderData 页面头部(前 24 字节) 包含 LSN、校验和、标志位、空闲空间起点和终点等元信息
ItemIdData 数组 紧接头部,向页面中部增长 每个 ItemId 指向一个真实元组(Tuple),占用 4 字节,存储偏移量和状态标志
Free Space 中间连续区域 元组插入时从这里分配,更新可能使旧元组废弃,重新纳入空闲空间
Tuple(项/元组) 从页面尾部向前增长 实际行数据,包括元组头和用户列数据
Special Space 页面最末尾(仅索引页使用) 存放索引特有的控制信息(如 B-tree 的同级兄弟指针)

页面结构示意(ASCII 艺术):

复制代码
+-------------------+
| PageHeaderData | ← pd_lower
+-------------------+
| ItemId 1 |
| ItemId 2 |
| ... | ← pd_upper 向下增长
| (Free Space) |
+-------------------+
| Tuple N |
| ... |
| Tuple 2 |
| Tuple 1 | ← pd_special (仅索引页)
+-------------------+

3.2 PageHeaderData 关键字段

PageHeaderData 结构(模拟 C 结构,摘自 bufpage.h):

  • pd_lsn (8 字节):页面最后一次修改对应的 WAL LSN。崩溃恢复时需要以此判断页面是否已写入。
  • pd_checksum (2 字节):页面校验和,用于检测磁盘损坏。可通过 initdb -kwal_checksum 参数启用。
  • pd_flags (2 字节):标志位,如是否包含有效数据、是否有空闲行指针等。
  • pd_lower (2 字节):空闲空间起始偏移量,即下一个 ItemId 插入的位置。
  • pd_upper (2 字节):空闲空间终止偏移量,即最后一个元组的起始位置。
  • pd_special (2 字节):索引页中特殊空间的起始位置(对堆表而言该字段为页面总大小)。

pd_lowerpd_upper 之间的差值就是可用空闲空间。插入一个元组时,先检查空间是否足够,然后从 pd_upper 处向前分配 Tuple 存储空间,并在 pd_lower 处增加一个 ItemId 记录。

3.3 元组(Tuple/HeapTupleHeader)格式

堆表(Heap Table)中的每一行称为一个元组。元组由两部分组成:元组头(HeapTupleHeader)用户数据 。元组头固定部分约 23 字节,包含如下关键字段(源码 src/include/access/htup_details.h):

  • t_xmin:插入此元组的事务 ID(XID)。用于判断创建该版本的事务是否已提交。
  • t_xmax:删除或更新此元组的事务 ID(0 表示未删除)。核心 MVCC 字段。
  • t_cid:命令 ID(CID),在一个事务内自增,标识同一事务中多个操作的顺序。
  • t_ctid:当前元组的物理位置(页面号+项偏移),或更新后新元组的 ctid(相当于指针)。
  • t_infomask2t_infomask:大量标志位,记录属性个数、是否有空值、是否有可变长度字段、HOT 更新信息等。
  • t_hoff:用户数据在元组中的偏移量(因为可能有 NULL 位图等前置信息)。

为什么理解这些字段对开发者至关重要?

因为当你在排查版本可见性问题、分析 VACUUM 为何无法回收死元组、或通过 pageinspect 诊断表膨胀时,完全依赖对 t_xmint_xmaxt_infomask 标志位的正确解读。本文提供的详细字段枚举,可直接作为日常运维和内核学习的速查手册。

四、MVCC 多版本并发控制:存储层的精髓

PostgreSQL 通过 MVCC(Multi-Version Concurrency Control)实现读写不冲突,这直接体现在存储层面:更新一行并不是原地修改,而是插入一个新版本的元组,并将旧版本标记为过期 。这就解释了为什么 PostgreSQL 需要频繁的 VACUUM 操作来回收空间。

4.1 版本创建与可见性规则

当一个事务执行 UPDATE 时:

  1. 原元组的 t_xmax 被设置为当前事务的 XID,表示该版本已被"删除"。
  2. 在同一页面(优选)或另一页面中插入一个新元组,其 t_xmin = 当前事务 XID。
  3. 新元组的 t_ctid 指向自身,同时旧元组的 t_ctid 改为指向新元组(形成版本链)。

可见性检查(极简核心规则):

  • 如果 t_xmin 对应的事务已提交,且 t_xmin < 当前事务的 XID 可见范围(快照 xminxmax),且不满足 以下条件:t_xmin 是在当前事务开始之后提交(RC 隔离级下需重新判断),则该版本可见。
  • 同时必须满足 t_xmax 为 0 或对应回滚/未提交事务。

PostgreSQL 的快照机制(Snapshot)会记录活动事务列表、最小活跃 XID 和最大完成 XID。这些规则全都基于存储的元组头字段计算,没有任何额外的 undo 段(与 Oracle 不同)。这种"多版本就地存储"使得 PostgreSQL 在某些只读场景下无需回滚段开销,但带来了表膨胀问题。

4.2 VACUUM 与 HOT 优化

因为旧版本依然占据页面空间,PostgreSQL 必须通过 VACUUM 或自动清理(Autovacuum)将这些"死元组"回收,供后续插入使用。单纯的 VACUUM 不收缩表文件,它只是在页面内标记空闲空间并记录到 FSM 中;如果要返回磁盘空间给操作系统,需要 VACUUM FULL,但后者会排他锁表并重写整个表。

HOT(Heap-Only Tuple)更新 是 PostgreSQL 对 MVCC 存储的重大优化:

  • 如果更新没有涉及任何索引列,并且新元组能够放入原页面,系统可以创建一个 HOT 元组。
  • HOT 元组不插入新索引条目,而是通过旧索引条目指向原元组,再由 t_ctid 链找到新版本。这极大减少了索引维护开销和存储增长。
  • 需要页面内的 ItemId 设置 HOT_UPDATEDHEAP_HOT_UPDATED 标志。pageinspect 扩展的 heap_page_items() 函数可以直接观测这些标志。

4.3 对比:PostgreSQL MVCC 与 MySQL InnoDB 的 Undo 模型

为了更好地理解不同数据库的设计取舍,这里提供一个结构化对比:

特性 PostgreSQL MySQL InnoDB
旧版本存储位置 堆表中,与当前版本混合 独立的 undo 表空间
清理机制 VACUUM 标记死元组,空间可复用 Purge 线程定期清理 undo 日志
回滚实现 读取旧版本快照,无需应用 undo 通过 undo 日志逆向操作
长事务影响 导致表严重膨胀,死元组不能回收 导致 undo log 膨胀,可能撑爆 undo 表空间
关键优势 没有 undo 竞争,回滚极快 历史版本不污染主表,空间可主动控制

此对比表可清晰展示两种实现路径对存储和运维的不同影响,是学习多版本并发控制时的重要参考。

五、空间管理的左膀右臂:FSM 与 VM

5.1 空闲空间映射(FSM)

为了避免插入操作线性扫描大量页面寻找空闲空间,PostgreSQL 为每个表维护了一个 FSM 文件 (后缀 _fsm)。FSM 内部使用一棵紧凑的二叉树结构,叶子节点对应每个数据页面的可用空间比例(通过 pgstattuple 扩展可以查询)。代码中定义为 256 级精度(MaxFSMRequestSize 相关宏)。

插入新元组时,PostgreSQL 会查询 FSM,快速定位到有足够空间存放当前行的页面,并尝试插入。如果页面已满或空闲空间不足,则分配新页面扩展表文件。同时,在更新发生后或 VACUUM 清理后,页面的空闲空间值会更新到 FSM 中。需要注意,FSM 并不保证 100% 准确,它是一种启发式结构,偶尔的错过不影响正确性,只会轻微降低插入性能。

5.2 可见性映射(VM)

VM 文件 (后缀 _vm)为每个页面维护两个比特位:

  • 位 1:该页面所有元组是否对当前及未来所有事务都可见(即不存在任何过期版本)。
  • 位 2:该页面是否在 VACUUM 冻结(freeze)过程中完全处理过。

可见性映射的作用十分强大:

  • VACUUM 能够跳过那些全部可见的页面,大幅减少扫描开销。这正是 Index-Only Scan 的基石:如果 VM 标明页面全可见,索引扫描便无需回表读取元组,直接返回索引中的数据。
  • VACUUM 的 freeze 操作可以利用 VM 跳过不需要冻结的页面。

可见性映射文件很小(每个页面 2 bit),但性能提升显著。VACUUM VERBOSE 命令的输出中可以看到跳过的页面数。

六、TOAST 技术:大字段的行外存储

PostgreSQL 规定一个元组不能跨越多个页面(即一行数据必须小于约 8KB)。面对超长的 TEXTBYTEAJSONB 字段,PostgreSQL 采用 TOAST(The Oversized-Attribute Storage Technique) 机制进行行外存储。

6.1 TOAST 策略

每个可变长度列都可以设置存储策略(ALTER TABLE ... ALTER COLUMN SET STORAGE):

  • PLAIN:不允许行外存储或压缩(用于短数据)。
  • EXTENDED(默认):先尝试压缩,若仍太大则移出行外。
  • EXTERNAL:不允许压缩,但允许行外存储(适合已压缩的媒体数据)。
  • MAIN:优先压缩,不允许行外存储,除非没有其他方式使行小于页面。

当列值被 TOAST 时,原元组中仅保留一个 TOAST 指针(varatt_external),包含 TOAST 表的 OID 和其内部标识。读取时,通过指针从附属的 TOAST 表中按需检索。

6.2 TOAST 表结构

每个有 TOAST 列的表都会伴随一个关联的 TOAST 表,通常名为 pg_toast.pg_toast_<relOID>。TOAST 表会将大字段切分成若干 2KB 左右的块(chunk),以普通行的形式存储。索引建立在 chunk_idchunk_seq 上,支持按顺序快速重组。

这一机制意味着查询 SELECT * 时如果不需要大字段,PostgreSQL 几乎不访问 TOAST 表,从而节省了极大的 I/O。这也是为什么建议查询中只选取需要的列,避免触碰 TOAST。各大云厂商的迁移服务(如阿里云 DTS、AWS DMS)在评估大对象时也会特别检查 TOAST 设置。

七、WAL 日志:预写式日志的物理实现

Write-Ahead Logging 是 PostgreSQL 保障持久性与原子性的核心:任何对数据文件的修改,必须先记录 WAL 日志并刷入磁盘,然后才能写入数据页面。这保证了崩溃后能够重放 WAL 恢复至一致状态。

7.1 WAL 记录类型与结构

WAL 文件存储在 pg_wal/ 目录下,默认每个 16MB(编译时设定)。WAL 记录由一系列 资源管理器(RMGR) 生成,如 HeapBtreeTransactionStorage 等。每条记录由通用头部(XLogRecord)加特定数据构成。

头部关键字段:

  • xl_rmid:资源管理器 ID。
  • xl_info:标志,指示是否是备份块等。
  • xl_tot_len:记录总长。
  • xl_prev:上一条记录的 LSN,形成链条。

LSN(Log Sequence Number) 是一个 64 位无符号整数,唯一标识 WAL 流中的一个位置。它由段号与段内偏移组成,公式:LSN = (segment_number * 16MB) + offset

WAL 的一个典型优化是 整页写入(Full-Page Writes) :在检查点之后的第一次页面修改,会将整个 8KB 页面内容写入 WAL。这可以防止部分写失效,但增大了 WAL 量。参数 full_page_writes 默认为开,极少数对文件系统原子写入有信心的场景(如 ZFS)可以关闭,但通常建议保持开启。

7.2 WAL Writer 与提交行为

WAL 写入并非每条都立即 fsync,而是由 WAL Writer 进程周期性地将 WAL 缓冲区(wal_buffers)写入磁盘。事务提交时,根据 synchronous_commit 参数决定同步级别:

  • on(默认):等待 WAL 刷盘才返回客户端,保证不丢数据。
  • remote_write / remote_apply:用于流复制环境,返回时机不同。
  • off:不等待 WAL 刷盘,性能最高但可能丢失部分已提交事务。

崩溃恢复时,从最后一个检查点的 REDO 点开始顺序重放 WAL 记录,直到最新已刷盘的记录。因为全页写入的存在,恢复可以直接用副本页覆盖损坏页,无需逐条应用历史 B+树分裂。

7.3 与 MySQL Redo Log 的对比

维度 PostgreSQL WAL MySQL InnoDB Redo Log
实现 资源管理器 + XLogRecord 物理/逻辑结合的 redo log
文件大小 16MB 固定段,可切换 固定容量循环写(如两个 1GB 文件)
全页写入 默认开启,保证写安全 通过 doublewrite buffer 解决部分写
归档 archive_command,直接拷贝 WAL 段 需通过二进制日志与 redo 协同

八、索引存储概览

索引同样是存储在磁盘上的物理关系(relfilenode),其页面结构除了通用的 PageHeaderData 外,末尾的 Special Space 存放了索引特有的元数据。以最常见的 B-tree 索引为例:

  • BTPageOpaqueData 存储了页面类型(叶节点、内部节点、根节点)、左右兄弟指针、页面所在层级等。
  • 内部节点条目由索引键 + 子节点页面号组成;叶子节点条目是索引键 + 堆表 ctid(TID)。
  • 插入时可能导致页面分裂,分裂过程会写大量 WAL 以保证可靠性。

其他索引如 GiST、GIN、BRIN 和 Hash 都有各自特殊的页面内存储规则,但均遵守页面的基本布局规范。若你计划深入学习特定索引类型,建议分别阅读官方文档的对应章节,并结合 pageinspect 扩展进行观测。

九、PostgreSQL 16/17 与未来存储特性

根据 PostgreSQL 16 和 17 的发布说明(Release Notes),存储层面引入了若干值得关注的改进:

  1. WAL 压缩(wal_compression = zstd :PostgreSQL 15 开始支持 pglzlz4,而 16 增加了 zstd 算法,可大幅减少全页写入带来的 WAL 膨胀。这一特性在云环境存储成本敏感的场景下非常实用。
  2. 增量备份 API :从 PostgreSQL 17 开始,增强了 pg_basebackup 的增量备份能力,通过比较 LSN 实现更细粒度的变更抓取,这直接影响了存储备份策略和云原生备份设计。
  3. 页面级校验和默认启用:较新版本在部署时更倾向于默认开启校验和,提升数据完整性。

这些新特性的加入进一步丰富了 PostgreSQL 存储层的可配置性,也让相关技术文档需要及时更新------本文正是基于这些最新变化撰写。

十、总结:构建系统化知识的价值

本文从文件布局、页面结构、MVCC 多版本机制、TOAST 行外存储、WAL 日志到索引存储,完整覆盖了 PostgreSQL 存储原理的核心知识簇。通过大量枚举实体字段、对比表格和源码定位,你不仅能够理解数据在磁盘上的真实形态,还能将这些理论应用到日常的 SQL 优化、表膨胀诊断、备份策略制定等实际场景中。

建议读者将本文收藏,并在遇到存储相关问题时反复查阅。若想进一步深入,可以结合 pageinspectpgstattuple 扩展进行手动验证,或阅读 PostgreSQL 官方源代码中的 src/backend/access/heap/ 目录下的实现。持续的实践和源码阅读,是成为 PostgreSQL 技术专家的必经之路。

延伸思考:如果你希望进一步掌握 VACUUM 的调度参数优化,或 WAL 级别的恢复实战,请保持关注。本文将成为你构建完整 PostgreSQL 知识体系的一块重要拼图。

postgreSQL高可用管理推荐:https://www.csudata.com/clup/manual