AI时代首先数据库-PostgreSQL实战笔记 | 23 PostgreSQL数据库文件系统与数据页结构

上一篇我们理清了实例、数据库、模式和表空间的关系。这一篇,我们把视角下沉到磁盘文件的最底层:一个 8KB 的数据页里,到底装了什么? 一行数据是如何在页内组织的?当一个字段大到 8KB 装不下时,PostgreSQL 又用什么机制把它"挪走",同时保证查询性能不被拖垮?

一、页:PostgreSQL 磁盘 I/O 的基本单位

PostgreSQL 中,表和索引都存储为固定大小的页(Page)组成的数组 ,默认每页 8KB,这个大小在编译服务器时确定。一个表的所有页在逻辑上是等价的,任何一行数据都可以存放在任意一页中。

8KB 的选择是一个工程折中。早期 PostgreSQL 社区讨论中就指出,4--8KB 在多数数据库系统中是一个合理的默认值。更大的页可以提升顺序访问效率,但会带来几个副作用:索引完全在内存时,更大的页反而因 CPU 开销增加而降低性能;8KB 页在 4KB 内存页的操作系统上会占用两个可能不相邻的内存页,影响 I/O 效率。8KB 是在通用工作负载下被验证为"够用且不过度"的平衡点。

二、页的五个组成部分

每个 8KB 页由五部分构成,从前往后依次是:

组成部分 作用 大小/特征
PageHeaderData 页头,记录 LSN、校验和、空闲空间指针等 24 字节
ItemIdData(行指针) 指向页内每个元组实际位置的 (偏移, 长度) 数组 每条 4 字节
Free space 未分配的空闲区域 从行指针区末尾和元组区开头相向增长
Items(元组) 实际的行数据 从页尾向前存放
Special space 索引访问方法专用数据,普通表为空 索引专用

这个布局的关键设计在于:行指针从页头之后向前增长,元组数据从页尾之后向前增长,两者之间的 Free space 是它们"争夺"的区域。新元组到来时,分配一个新的行指针(消耗页头侧空间),然后把元组数据写到 Free space 的尾部(消耗页尾侧空间)。

页头中的三个指针------pd_lower、pd_upper、pd_special------分别标记空闲空间的起点、终点和 special 区域的起点。pd_lower == pd_upper 意味着页已满。

行指针的存在,让页内元组可以移动,而外部引用不需要改变。 索引中存储的是行指针(ItemId),而不是元组的物理地址。当 VACUUM 或页内整理(pruning)移动了元组的位置时,只需要更新行指针中的偏移量,索引完全不受影响。这是 PostgreSQL 页结构支撑 MVCC 和 VACUUM 的关键设计之一。

三、TOAST:当一行装不下 8KB 时

PostgreSQL 的行不能跨页。如果一行数据(尤其是 text、bytea、jsonb 这类变长字段)大到一页放不下,必须有一种机制把它"变小"或"挪走"。这就是 TOAST(The Oversized-Attribute Storage Technique)。

工作流程:先压缩,再行外

TOAST 的处理是透明的,应用层完全感知不到。当一行数据过大时,PostgreSQL 按顺序尝试:

第一步,压缩。 对可 TOAST 的变长字段,使用 pglz 或 lz4 算法进行压缩。如果压缩后整行能装进 8KB 页,问题解决。

第二步,行外存储。 如果压缩后仍然超限,就把大字段的值拆分并存储到一个独立的 TOAST 表 中,主表的行里只保留一个 TOAST 指针(约 18 字节),指向 TOAST 表中的位置。TOAST 表本身也是普通的 PostgreSQL 表,它的行同样受 8KB 页限制,所以大值会被拆分成多个 TOAST 行(chunk)存放。

四种存储策略

每个可 TOAST 的列都有一个存储策略,决定压缩和行外存储的行为:

策略 行为 适用场景
EXTENDED(默认) 先压缩,压缩后仍大则行外存储 绝大多数变长字段,兼顾空间和 I/O
EXTERNAL 不压缩,直接行外存储 已压缩的数据(图片、gzip 文件),避免无谓的 CPU 开销
MAIN 优先压缩并留在主表,万不得已才行外 频繁访问、压缩后通常能装进一页的字段
PLAIN 不压缩、不行外,必须装进主表 定长类型(整数、时间戳),不可用于变长类型

EXTERNAL 有一个被低估的优势:由于不压缩,对 text/bytea 列的子串操作(substring、left、right)可以只从 TOAST 表中读取所需的部分 ,而不是解压整个值。对于大文本字段的"只取前 N 个字符"这类查询,EXTERNAL 可能比默认的 EXTENDED 更快。

四、TOAST 对查询性能的双面影响

正面:让主表"瘦身",提升缓存效率

TOAST 设计的核心理由,是让执行器的大部分工作在主表上完成,大字段的值只在最终发送结果集时才被"拉取"出来。

官方文档给出了一个直观的数据:一张包含典型 HTML 页面和 URL 的表,启用 TOAST 后,主表只占整个数据(含 TOAST 表)的约 10% 。这意味着,主表的行更小,同样大小的 shared_buffers 可以缓存更多行,排序操作也更可能在内存中完成,而不是溢写到磁盘。

一个直观的查询差异:

sql 复制代码
-- 快:不需要读取 TOAST 数据
SELECT id, length(content) FROM articles WHERE id = 1;

-- 慢:必须从 TOAST 表读取并解压完整内容
SELECT content FROM articles WHERE id = 1;

length() 可以在不解压完整值的情况下计算长度,而 SELECT content 必须执行 de-TOAST (读取 TOAST 数据、解压、拼装)。如果查询只涉及 WHERE、ORDER BY 或小字段的过滤,大字段不会被触碰,TOAST 带来的是纯收益。

负面:de-TOAST 的 I/O 与 CPU 代价

一旦查询确实需要完整的大字段值,TOAST 的代价就会显现。社区工具的实测数据指出:获取 TOASTed 值需要额外的 I/O,比行内值慢 2--10 倍 ,同时 TOAST 开销可能让表的存储空间增加 20--40%。

更隐蔽的风险是 TOAST 表的膨胀和 OID 竞争 。TOAST 表频繁插入、更新、删除后,可能产生碎片,导致 OID 分配延迟,进而拖慢 INSERT 性能,极端情况下甚至因 OID 耗尽而引发停机。在一个真实案例中,某服务将完整的 API 请求/响应日志存入 JSONB 列,6 个月积累了 500GB 的 TOAST 数据,备份时间从 2 小时膨胀到 8 小时,查询延迟从 50ms 升到 300ms。

五、一个容易忽略的细节:TOAST 不会被 pg_dump 导出为独立数据

逻辑备份时,pg_dump 导出的 TOAST 数据是附着在主表行上的,不会作为独立的 TOAST 表数据出现。这意味着,如果你只盯着主表的数据量做容量规划,TOAST 表的实际磁盘占用和 I/O 成本会被严重低估。要准确评估,需要直接查询 TOAST 表的大小:

sql 复制代码
SELECT pg_size_pretty(pg_total_relation_size(reltoastrelid)) AS toast_size
FROM pg_class
WHERE relname = 'your_table';

六、总结

主题 核心结论
页大小 8KB 是通用工作负载下的工程折中,兼顾索引缓存、OS 内存页对齐和 I/O
页布局 页头 + 行指针 + 空闲空间 + 元组 + special;行指针让元组移动不影响索引
TOAST 触发 行超过约 8KB 时,先压缩,仍超限则行外存储到独立 TOAST 表
存储策略 EXTENDED 默认;EXTERNAL 适合已压缩数据和大字段子串操作;MAIN 尽量留在主表
性能收益 主表瘦身,更多行进入缓存,排序更少溢盘;不触碰大字段的查询不受 de-TOAST 影响
性能代价 读取完整大字段需额外 I/O(2--10 倍慢),TOAST 表膨胀和 OID 竞争是隐性风险

一句话:8KB 的页是 PostgreSQL 磁盘 I/O 的"原子单位",而 TOAST 是让大字段不撑破这个原子单位的"溢出阀"。它的收益来自"主表变瘦、缓存变多",它的代价藏在"de-TOAST 的 I/O 和 TOAST 表的隐性膨胀"里。只查询小字段时,TOAST 是你的朋友;每次都要拉取完整大字段时,TOAST 就是你的债主。

下一篇,我们进入 WAL 日志与崩溃恢复原理:为什么 PostgreSQL 必须"先写日志再写数据"?一次 COMMIT 背后,磁盘上到底发生了什么?


思考题:

你知道自己库里的 TOAST 表占了多大空间吗?有没有因为 SELECT * 或频繁读取大字段,导致查询延迟被 de-TOAST 拖慢的经历?另外,你有没有对某些已压缩的字段(如图片、gzip 数据)使用 EXTERNAL 策略来避免无谓的压缩开销?欢迎在评论区聊聊。

相关推荐
启观川1 小时前
[windows] windows下将文件夹映射为磁盘
windows·笔记
忡黑梨1 小时前
PostgreSQL 14 源码编译安装实战:RHEL 8 离线环境从零到可用
数据库·postgresql
LMT流氓兔1 小时前
第五单元——第10课:用自动化测试验证工具逻辑
数据库·ai·oracle·langchain
小小AK1 小时前
生产入库单同步实战:MySQL 到金蝶云星空的带料委外场景拆解
数据库·mysql
凤凰院凶涛QAQ1 小时前
《MySQL内核拆解:从SQL到InnoDB》(一) : 数据库基础&库操作&数据类型
数据库·sql·mysql
360智汇云1 小时前
隔离VPC标准路径:出公网链路的设计与实现
android·服务器·数据库
紫金桥软件1 小时前
紫金桥国产组态软件:安全稳定护航工业现场
数据库·自动化·跨平台·scada·组态软件
ljt27249606611 小时前
Python笔记(九)--asyncio
笔记·python
qq_436962181 小时前
金蝶云星空数据分析怎么做?
java·数据库·数据分析