底层存储模型与 TOAST 机制
校招考察频度:☆ 中频(底层机制·大厂底层推导) 一句话速记:物理三件套(主数据、FSM与VM),8K 页内双向对冲长;槽位向下数据向上走,超两千字节 TOAST 压缩切片藏。
PostgreSQL 的物理存储架构以“堆表(Heap)+ 8KB 页结构 + TOAST 扩展”为基石。在校招面试中,如果能清晰画出 8KB 数据页的内存布局,并讲透为什么元组不能跨页以及 TOAST 的四种策略,能给面试官留下极深的科班功底印象。
一、堆表的物理文件三件套
在 PostgreSQL 的数据目录(base/<database_oid>/)下,每张数据表在物理磁盘上并不是单一的大文件,而是由至少三个文件协同组成的(以关系对象 OID 命名,如 16384):
| 文件后缀 | 文件全称 | 核心职责 | 访问与维护机制 |
|---|---|---|---|
| 无后缀主文件 | Relation Data File | 存放实际的行数据元组(Tuple)。当单文件超过 1GB 时,自动切分为 16384.1、16384.2 等分段。 | 读写通过 shared_buffers 缓存,按 8KB 块进行 I/O。 |
_fsm | Free Space Map(空闲空间映射) | 以多叉树形式记录每个 8KB 数据页中剩余可用空闲空间的大小。 | 当执行 INSERT 时,引擎直接查 FSM 快速找到哪个已有页装得下,避免每次都追加新页或顺序翻找。由 VACUUM 维护。 |
_vm | Visibility Map(可见性映射) | 每个数据页用 2 个 bit 记录状态: ① 该页是否全部元组可见(All-Visible); ② 该页是否全部元组已冻结(All-Frozen)。 | 两大神效:① 支撑 Index-Only Scan(标记为 All-Visible 的页读索引免回表);② 让 VACUUM 跳过全可见页,大幅缩短垃圾回收耗时。 |
二、8KB 数据页(Page)的底层二进制布局
PostgreSQL 规定:数据页是磁盘与内存之间 I/O 交换的最小原子物理单位,默认固定为 8192 字节(8KB)。
一个标准数据页在内存与磁盘上的物理布局采用了极具工程智慧的**“双向对冲生长”**结构:
8KB 数据页物理内存布局(双向对冲生长)
1. 核心组成部分拆解
- PageHeaderData(页头,固定 24 字节):
pd_lsn(8 字节):最近一次修改该数据页的 WAL 日志序列号(LSN)。在崩溃恢复(Crash Recovery)时,引擎拿数据页的pd_lsn与 WAL 记录的 LSN 对比,只重放比当前页更年轻的修改;pd_checksum(2 字节):页面校验和,防止磁盘静默损坏;pd_lower(2 字节):指向行指针数组(ItemId)的末尾偏移量(未分配空间的起点);pd_upper(2 字节):指向最新写入元组的起始偏移量(未分配空间的终点)。
- ItemIdData(行指针数组 / 槽位):
- 每个槽位占用 4 字节;
- 记录元组在当前页内的起始偏移量(Offset)与长度(Length);
- CTID 的第 2 个数字指的就是这个槽位下标!例如
ctid = (10, 3)表示第 10 号数据块中的第 3 个 ItemId 指针。
- 元组真实数据区(Tuple Data):
- 包含元组头(
xmin,xmax,t_cid,t_ctid,t_infomask, NULL 位图)及用户真实列数据; - 逆向生长特性:新写入的元组从页的最底部(8192 字节处)开始向上填充,而 ItemId 指针从顶部向下增长;两者在中间的
Free Space相向而行。当pd_lower == pd_upper时,说明当前页已被填满。
- 包含元组头(
三、TOAST 机制(超限属性存储技术)
1. 为什么必须有 TOAST?
- 物理铁律:PostgreSQL 严禁一条元组跨越两个数据页存储!一条元组的大小必须严格限制在单个 8KB 数据页内部。
- 痛点:若业务表中包含大文本(
TEXT)、JSON(JSONB)、长二进制数据(BYTEA),单个字段动辄几十 KB 甚至几 MB,单页根本装不下。 - 解法:TOAST(The Oversized-Attribute Storage Technique)!
2. 触发阈值与工作流程
- 触发阈值:当一行元组的总大小超过约 2KB(即
TOAST_TUPLE_THRESHOLD,通常为页大小的 1/4)时,TOAST 机制被强制唤醒:
TOAST 执行四部曲:
1. 评估超长字段:检查哪些字段属于可压缩或变长类型 (varlena);
2. 就地压缩:使用 LZ4 或 pglz 算法对字段数据进行高效无损压缩;
3. 外存切片:若压缩后仍然超标,系统将超长大字段切分成多个约 2KB 的数据块 (Chunks);
4. 写入独立 TOAST 表:切片被存入专用的附属 TOAST 物理表 (如 pg_toast_16384) 中;
5. 主表留指针:主表元组中该字段的原位置只留下一个 18 字节的 toast_pointer。TOAST 外存切片机制
3. TOAST 的四大存储策略(必背)
开发者可以通过 ALTER TABLE 表名 ALTER COLUMN 列名 SET STORAGE 策略; 手动配置:
| 策略模式 | 允许压缩? | 允许移入外部 TOAST 表? | 典型适用场景 |
|---|---|---|---|
EXTENDED(系统默认) | ✅ 优先压缩 | ✅ 仍超标则外存 | 大多数 TEXT、VARCHAR、JSONB 默认首选。 |
MAIN | ✅ 极力压缩 | ⚠️ 仅当全行仍超标时外存 | 适合频繁读取的中等大小文本,尽量保留在主表加速查询。 |
EXTERNAL | ❌ 禁止压缩 | ✅ 直接移入外部表 | 性能优化点:原本就已被压缩的多媒体数据(如 JPG 图片、MP3、GZIP 压缩包)。禁止压缩可以避免浪费大量 CPU 算力进行无用功压缩尝试。 |
PLAIN | ❌ 禁止压缩 | ❌ 严禁移入外部表 | 仅用于基础定长类型(如 INT, BIGINT, DATE)。若行超标直接报错。 |
4. TOAST 对查询性能的影响(面试加分深度)
- 显著正向收益:当执行
SELECT id, name FROM users;时,引擎只扫描紧凑的主表数据页,完全跳过体积庞大的 TOAST 表。这使得单个主表数据页能容纳更多普通元组,大幅提升内存命中率与全表扫描吞吐。 - 潜在性能陷阱:若业务代码随意写
SELECT *,把 TOAST 的超长字段也读出来,引擎将被迫通过索引去附属 TOAST 表把所有 2KB 的切片拼装回来并解压缩,产生严重的磁盘随机 I/O 与 CPU 开销。因此高并发下严禁无脑SELECT *!