PostgreSQL 存储与索引系列(一):数据在磁盘上如何安放——表、页、TOAST 与 VACUUM

PostgreSQL 存储与索引系列(一):数据在磁盘上如何安放——表、页、TOAST 与 VACUUM

这是系列文章的第一期,专注 PostgreSQL 的存储核心:表与行的组织、磁盘页结构、大字段的 TOAST 技术,以及不可或缺的 VACUUM 机制。后文还会介绍表分区,为下一期索引专题打下坚实基础。

1. 从逻辑表到物理文件

在 PostgreSQL 中,每个用户表都对应着磁盘上的一个或多个文件。默认情况下,一张表的数据存储在以表 OID 命名的文件中(位于 $PGDATA/base/<数据库 OID>)。当表或索引超过 1GB 时,PostgreSQL 会自动分割为 文件.1文件.2 等,绕开某些文件系统的限制。

每个表中的数据是无序存储的“堆”(Heap):新插入的行通常追加到文件末尾,更新行时可能放置在同一页(如果空间够),也可能移动到新页并留下“转发指针”。这种设计让写入很快,但带来了后续的清理负担——这就是 VACUUM 存在的理由。

2. 页结构:PostgreSQL 的磁盘基本单位

PostgreSQL 磁盘 I/O 的最小单元是(Page),默认大小为 8 KB(编译时可选)。页既是读写单位,也是内存缓存的单位(shared_buffers 中缓存的就是页)。一张表的所有页组成一个大小可变的数组,每个页有自己的页号(block number)。

一个表页的内部布局如下(从头部到尾部分配):

+-----------------------------+  ← 页起点
| PageHeaderData (24 bytes)   |  页元数据:页校验和、空闲空间起始、特殊区域起点等
+-----------------------------+
| ItemIdData (数组)            |  4 字节/条,指向具体数据的偏移和长度
| (指向行指针,从页尾逆向增长)  |
+-----------------------------+
| 空闲空间(未使用区域)         |
+-----------------------------+
| 实际行数据(从页尾正向填充)    |
+-----------------------------+
| Special Space (可选)         |  索引访问方法专用,如 GiST 需要保存信息
+-----------------------------+

关键点:

  • ItemId 数组从页头之后开始,采用“从两头向中间”方式:行指针从页头向后增长,实际元组从页尾向前增长。这样空闲空间始终是连续的一段。
  • 每个元组头部包含 t_xmint_xmaxt_cidt_ctid 等 MVCC 字段,用于判断可见性。
  • 页内的元组如果被更新或删除,不会立刻物理移除,而是标记为“死元组”,等待 VACUUM 回收。

3. TOAST:大字段的出路

PostgreSQL 页面只有 8 KB,当一行数据超过约 2 KB(通常是页面大小的四分之一)时,TOAST(The Oversized-Attribute Storage Technique)就会介入。TOAST 将大字段值压缩并切分成小块,存储在单独的系统表 pg_toast 中。原表的行内只留下一个指针(约 18 字节)。

触发条件与策略

  • 任何列类型都可能触发 TOAST,但主要面向变长类型(textvarcharbyteajsonb 等)。
  • 四种存储策略(可通过 ALTER TABLE ALTER COLUMN SET STORAGE 调整):
    PLAIN:禁用 TOAST,强制行内存储(要求列不超过页大小)。
  • EXTENDED:允许压缩和行外存储(默认策略)。
  • EXTERNAL:允许行外存储,但不压缩。适合部分无法接受压缩代价的场景(如某些加密数据)。
  • MAIN:优先压缩,尽量留在行内,最后才移至行外。

TOAST 表的内部结构

每个拥有大字段的表会关联一个 pg_toast.pg_toast_<OID> 辅助表,该表包含三个列:

  • chunk_id:标识属于哪个大字段值。
  • chunk_seq:切块序号。
  • chunk_data:实际的数据块(长度 ~2 KB)。

查询时如果只需 SELECT 非 TOAST 列,PostgreSQL 会避免读取 TOAST 表,提升性能。

4. VACUUM:对抗膨胀的清洁工

MVCC 架构下,更新/删除操作会留下旧版本(死元组),如果不清理,表会无限膨胀。VACUUM 负责:

  1. 回收死元组占用的空间,使其能被新元组复用。
  2. 更新可见性映射(Visibility Map),告诉 PostgreSQL 哪些页面全部可见,从而让仅索引扫描(Index-Only Scan)可以跳过堆表访问。
  3. 冻结事务 ID,防止事务 ID 回卷导致数据丢失。

两种 VACUUM 模式

  • 标准 VACUUM:并发执行,仅清除死元组、整理空闲空间映射(FSM),不压缩表。空闲空间被记录到每个表的 _fsm 文件中供后续插入使用。
  • VACUUM FULL:排他锁重写整个表,彻底整理碎片并压缩到最小,代价高,适合膨胀严重且可以接受长时间锁表的场景。

关键优化:Visibility Map (VM) 与 Index-Only Scan

每个表还有一个 _vm 文件,每个页在 VM 中用 2 个比特表示:

  • all_visible:该页所有元组对所有事务都可见。
  • all_frozen:该页所有元组的事务 ID 都已冻结(早期版本只有一个标志)。

当 VM 标记某页 all_visible 后,VACUUM 可以跳过该页;更重要的是,仅索引扫描可以结合 VM 省去回表——先通过索引拿到 ctid,再检查对应页是否在 VM 中标记,若是则直接使用索引数据,否则仍需回表判断可见性。

Autovacuum 调优要点(简要)

  • 避免表膨胀:设置合适的 autovacuum_vacuum_scale_factor(大表用比例)和 autovacuum_vacuum_threshold(小表用绝对值)。
  • 监控 pg_stat_all_tablesn_dead_tuplast_autovacuum
  • 对于频繁更新的表,手动调低 vacuum_cost_delay 或使用 vacuumSKIP_LOCKED 选项。

5. 表分区:化整为零的管理艺术

表分区将一张逻辑大表拆分成多个物理子表(分区),每个分区是一个独立的堆文件。分区可以带来:

  • 查询时分区裁剪:只扫描相关分区,提升查询性能。
  • 批量数据加载/删除的便捷性:可以 DROPTRUNCATE 一个分区。
  • 独立的索引和存储参数。

支持的分区类型(声明式分区,PostgreSQL 10+ 引入)

  1. 范围分区:按日期、数字区间分割,例如 PARTITION BY RANGE (created_at)
  2. 列表分区:按枚举值列表分割,例如 PARTITION BY LIST (region)
  3. 哈希分区:对分区键取哈希,均匀分布数据,适合无明显热点的场景。

分区裁剪与维护

  • 执行计划中看到 Append + 子节点 Seq ScanIndex ScanFilter 包含分区条件时,说明裁剪生效。
  • 分区键必须是查询条件中的等值或范围条件才能裁剪(函数或类型转换会破坏)。
  • 分区维护常用语句:
    CREATE TABLE .. PARTITION OF .. FOR VALUES FROM .. TO ..
  • ALTER TABLE .. ATTACH/DETACH PARTITION
  • 对分区单独执行 VACUUM、ANALYZE 或 REINDEX,减少大表锁影响。

预告:第二期——索引的魔法

第一期我们搭建了存储地基:知道了数据如何摆放在页面中,如何用 TOAST 处理大字段,以及 VACUUM 如何维持表健康。下一期将正式进入 索引专题,逐一拆解:

  • B-tree:万能钥匙,支持等值、范围、排序。
  • Hash:紧急等值查询场景。
  • GiST / SP-GiST:地理、全文、几何等复杂类型。
  • GIN:倒排索引,专为数组、全文、JSON 设计。
  • BRIN:块范围索引,节约空间的大表利器。
  • 部分索引、表达式索引等高级技巧。

敬请期待。

思考题:为什么 VACUUM 不能彻底消除表膨胀?Hint:关联“空闲空间映射”与“碎片化”。

如果你有任何疑问或想深入某个小节,欢迎留言——我会在下一期的附录中讨论。

No comments yet