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_xmin、t_xmax、t_cid、t_ctid等 MVCC 字段,用于判断可见性。 - 页内的元组如果被更新或删除,不会立刻物理移除,而是标记为“死元组”,等待 VACUUM 回收。
3. TOAST:大字段的出路
PostgreSQL 页面只有 8 KB,当一行数据超过约 2 KB(通常是页面大小的四分之一)时,TOAST(The Oversized-Attribute Storage Technique)就会介入。TOAST 将大字段值压缩并切分成小块,存储在单独的系统表 pg_toast 中。原表的行内只留下一个指针(约 18 字节)。
触发条件与策略
- 任何列类型都可能触发 TOAST,但主要面向变长类型(
text、varchar、bytea、jsonb等)。 - 四种存储策略(可通过
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 负责:
- 回收死元组占用的空间,使其能被新元组复用。
- 更新可见性映射(Visibility Map),告诉 PostgreSQL 哪些页面全部可见,从而让仅索引扫描(Index-Only Scan)可以跳过堆表访问。
- 冻结事务 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_tables的n_dead_tup和last_autovacuum。 - 对于频繁更新的表,手动调低
vacuum_cost_delay或使用vacuum的SKIP_LOCKED选项。
5. 表分区:化整为零的管理艺术
表分区将一张逻辑大表拆分成多个物理子表(分区),每个分区是一个独立的堆文件。分区可以带来:
- 查询时分区裁剪:只扫描相关分区,提升查询性能。
- 批量数据加载/删除的便捷性:可以
DROP或TRUNCATE一个分区。 - 独立的索引和存储参数。
支持的分区类型(声明式分区,PostgreSQL 10+ 引入)
- 范围分区:按日期、数字区间分割,例如
PARTITION BY RANGE (created_at)。 - 列表分区:按枚举值列表分割,例如
PARTITION BY LIST (region)。 - 哈希分区:对分区键取哈希,均匀分布数据,适合无明显热点的场景。
分区裁剪与维护
- 执行计划中看到
Append+ 子节点Seq Scan或Index Scan且Filter包含分区条件时,说明裁剪生效。 - 分区键必须是查询条件中的等值或范围条件才能裁剪(函数或类型转换会破坏)。
- 分区维护常用语句:
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