PostgreSQL

关注公众号 jb51net

关闭
首页 > 数据库 > PostgreSQL > PostgreSQL 存储原理

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

作者:睡不醒男孩030823

PostgreSQL存储原理深度解析,涵盖MVCC、TOAST、WAL日志等索引存储机制,助你掌握PostgreSQL高性能与高并发的底层逻辑,本文介绍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.1PGDATA目录结构

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 # 主配置文件

关键实体说明

官方文档第 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_lowerpd_upper 之间的差值就是可用空闲空间。插入一个元组时,先检查空间是否足够,然后从 pd_upper 处向前分配 Tuple 存储空间,并在 pd_lower 处增加一个 ItemId 记录。

3.3 元组(Tuple/HeapTupleHeader)格式

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

为什么理解这些字段对开发者至关重要?
因为当你在排查版本可见性问题、分析 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 改为指向新元组(形成版本链)。

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

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

4.2 VACUUM 与 HOT 优化

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

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

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

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

特性PostgreSQLMySQL 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)为每个页面维护两个比特位:

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

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

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

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

6.1 TOAST 策略

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

当列值被 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)加特定数据构成。

头部关键字段:

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 参数决定同步级别:

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

7.3 与 MySQL Redo Log 的对比

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

八、索引存储概览

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

其他索引如 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

到此这篇关于PostgreSQL 存储原理全解:从页面结构到 MVCC 的深度解析的文章就介绍到这了,更多相关PostgreSQL 存储原理内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文