上一篇我们理清了实例、数据库、模式和表空间的关系。这一篇,我们把视角下沉到磁盘文件的最底层:一个 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 策略来避免无谓的压缩开销?欢迎在评论区聊聊。
