PostgreSQL 存储引擎内幕(上):Heap Page 与 Buffer Pool
这篇文章的价值:当你执行 SELECT * FROM users WHERE id = 123 时,数据是怎么从磁盘到达你面前的?这篇文章追踪这条路径的最底层:8KB 的 Page 内部长什么样、Buffer Pool 如何管理内存中的页面缓存、顺序扫描如何一页一页地读取数据、以及 HOT Chain 如何优化更新操作。理解这些,你才能真正理解 VACUUM 为什么必要、为什么跨主机容器通信比同主机慢、以及 PG 和 MySQL 在存储层的根本差异。
全局视角:从文件到 Tuple
PostgreSQL 的存储是一个四层结构。理解这四层的嵌套关系,是理解后面所有内容的基础。
┌─────────────────────────────────────────────────────────┐
│ Relation (表/索引) │
│ │
│ 数据目录: $PGDATA/base/{dbOid}/{relFileNumber} │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Segment 0 │ │ Segment 1 │ │ Segment 2 │ ... │
│ │ (1 GB) │ │ (1 GB) │ │ (1 GB) │ │
│ └─────┬────┘ └──────────┘ └──────────┘ │
│ │ │
│ ▼ │
│ ┌────────┬────────┬────────┬────────┬─────┐ │
│ │ Page 0 │ Page 1 │ Page 2 │ Page 3 │ ... │ 每个 8KB │
│ └────┬───┘ └────────┘ └────────┘ └────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ PageHeader │ ItemId[] │ Tuples[] │ 一个 Page 的内部 │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
| 层 | 大小 | 说明 |
|---|---|---|
| Relation | 不限 | 一张表或一个索引。在磁盘上对应一个或多个文件 |
| Segment | 1 GB | 当文件超过 1GB 时自动分段(relFileNumber, relFileNumber.1, ...) |
| Page (Block) | 8 KB | 存储的基本单位。磁盘上叫 Block,读入内存叫 Page |
| Tuple | 可变 | 一行数据。包含行头 + 用户数据 + 对齐填充 |
单表容量上限怎么算?Page 大小 (8KB) × 最大 Block 数 (2^32) = 32 TB。这是硬编码的上限。
每个 Relation 在磁盘上还有几个"分支文件"(Fork):
| Fork | 编号 | 文件后缀 | 内容 |
|---|---|---|---|
| Main | 0 | (无后缀) | 主数据文件,存放实际的行数据 |
| FSM | 1 | _fsm | Free Space Map,记录每个 Page 的可用空间 |
| VM | 2 | _vm | Visibility Map,标记哪些 Page 上所有 Tuple 都可见(VACUUM 用) |
| Init | 3 | _init | 初始化分支,用于 unlogged 表的崩溃恢复 |
Page 内部:8KB 的微型世界
这是整篇文章最核心的一张图。每个 Page 是一个 8192 字节的固定大小块,内部结构如下:
0 字节 8192 字节
┌──────────────────────────────────────────────────────────────┐
│ PageHeaderData (24 字节) │
│ pd_lsn │ pd_checksum │ pd_flags │ pd_lower │ pd_upper │... │
├──────────────────────────────────────────────────────────────┤
│ ItemId[1] │ ItemId[2] │ ItemId[3] │ ... │ ItemId[N] │ │
│ (4字节) (4字节) (4字节) (4字节) │
│ ↓ pd_lower │
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┤
│ │
│ 空 闲 空 间 │
│ │
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┤
│ ↑ pd_upper │
│ Tuple N │ ... │ Tuple 3 │ Tuple 2 │ Tuple 1 │
│ │
├──────────────────────────────────────────────────────────────┤
│ Special Space ↑pd_special│
└──────────────────────────────────────────────────────────────┘
← ItemId 数组从左向右增长 Tuple 数据从右向左增长 →
两者相向生长,中间是空闲空间。
这个"两端相向增长"的设计非常经典——和操作系统里堆栈相向增长是同一个思路。好处是:不需要预先知道 ItemId 和 Tuple 各占多少空间,空闲区域被两者共享。
PageHeaderData:页面的元数据
| 字段 | 大小 | 含义 |
|---|---|---|
pd_lsn | 8 字节 | 最后修改该 Page 的 WAL 记录的 LSN。决定了恢复时是否需要重放 |
pd_checksum | 2 字节 | 页面校验和,检测存储层数据损坏 |
pd_flags | 2 字节 | 标志位(是否有空闲空间、是否全部可见等) |
pd_lower | 2 字节 | 指向空闲空间的起始位置(ItemId 数组的末尾) |
pd_upper | 2 字节 | 指向空闲空间的结束位置(Tuple 数据的起始) |
pd_special | 2 字节 | Special Space 的起始偏移(索引页面使用) |
pd_upper - pd_lower = 当前 Page 的可用空间。INSERT 新行时,PG 检查这个值够不够放下新 Tuple + 一个新 ItemId。
ItemId:间接寻址层
ItemId 是一个 4 字节的结构,包含三个信息:
- offset(15 位):Tuple 在 Page 内的字节偏移
- len(15 位):Tuple 的长度
- flags(2 位):状态(正常 / 已删除 / 重定向)
为什么要多这一层间接?因为 VACUUM 需要整理 Page 内的碎片。整理后 Tuple 的物理位置可能改变,但 ItemId 的槽位号(OffsetNumber)不变。外部引用(索引条目、其他行的外键)总是通过 (BlockNumber, OffsetNumber) 来定位一行——这就是 TID(Tuple Identifier)。
TID = (BlockNumber, OffsetNumber)
( 页号 , 槽位号 )
例如 TID = (5, 3) 表示:
第 5 个 Block 的 ItemId[3] 指向的 Tuple
索引条目: age=25 → TID(5, 3)
│
▼
Page 5: ItemId[3] = {offset: 6800, len: 120, flags: NORMAL}
│
▼
偏移 6800 处: HeapTupleHeader + 用户数据
这个间接层让 VACUUM 可以自由移动 Tuple 的物理位置,只要更新对应的 ItemId 就行,不需要修改任何索引。
TID、ItemId、Tuple:三者的关系
这三个概念经常被混淆,我们把它们的关系彻底理清。一句话总结:TID 是地址,ItemId 是门牌号到房间号的映射表,Tuple 是房间里的人。
| 概念 | 是什么 | 存在哪里 | 是否稳定 |
|---|---|---|---|
| TID | 逻辑地址 (BlockNumber, OffsetNumber) | 索引条目中、其他行的引用中 | 稳定——VACUUM 不会改变它 |
| ItemId | Page 内的间接指针 (offset, len, flags) | Page 头部之后的 ItemId 数组中 | 槽位号稳定,但内部的 offset 可以变 |
| Tuple | 实际的行数据 (行头 + 用户数据) | Page 尾部的 Tuple 区域中 | 不稳定——VACUUM 整理后物理位置可以变 |
关键洞察:TID 中的 OffsetNumber 不是字节偏移,而是 ItemId 数组的下标(从 1 开始)。真正的字节偏移藏在 ItemId 内部。这个间接层是整个设计的精髓。
一次完整的行定位过程:
外部持有: TID = (5, 3)
│ │
│ └─ OffsetNumber = 3 (ItemId 数组的第 3 个槽位)
└──── BlockNumber = 5 (第 5 个 Page)
步骤 1: 用 BlockNumber 找到 Page
┌─────────────────────────────────────────────────────────────┐
│ Page 5 │
│ │
│ 步骤 2: 用 OffsetNumber 在 ItemId 数组中查找 │
│ │
│ ItemId[1] ItemId[2] ItemId[3] │
│ off:7100 off:6900 off:6700 ◄── 就是这个 │
│ len:120 len:80 len:150 │
│ flags:NORM flags:DEAD flags:NORM │
│ │ │
│ │ 步骤 3: 用 offset 跳到 Tuple │
│ ▼ │
│ ┌──────────────────────┐ │
│ 偏移 6700 处 ──► │ HeapTupleHeader │ │
│ │ t_xmin = 100 │ │
│ │ t_xmax = 0 │ │
│ │ t_ctid = (5, 3) │ ← 指向自己=最新版│
│ ├──────────────────────┤ │
│ │ 用户数据 │ │
│ │ id=42 │ │
│ │ name='Alice' │ │
│ │ age=25 │ │
│ └──────────────────────┘ │
│ ◄──── 150 字节 ────► │
└─────────────────────────────────────────────────────────────┘
理解了这三层关系,就能理解以下行为:
- VACUUM 整理碎片:移动 Tuple 的物理位置(比如从偏移 6700 搬到 7000),只需更新 ItemId[3] 的 offset 字段。TID (5, 3) 不变,所有索引条目不需要修改
- DELETE 一行:不会立即删除 Tuple 数据,而是把 ItemId 的 flags 标记为 DEAD。空间在 VACUUM 时才真正回收
- HOT UPDATE:新旧版本都在同一个 Page 里,通过 Tuple 行头的
t_ctid串成链。索引条目仍然指向旧版本的 TID,沿链遍历找到新版本 - 索引扫描回表:索引给出 TID → 读 Page → 用 OffsetNumber 找到 ItemId → 用 ItemId 的 offset 找到 Tuple → 做 MVCC 可见性检查
一个常见的误解是"TID 就是行的物理地址"。不是的——TID 是逻辑地址,中间隔了 ItemId 这层间接。正是这层间接,让 PG 可以在不修改任何外部引用的情况下移动行的物理位置。
HeapTupleHeader:行头
每个 Tuple 的前 23 字节是行头,记录了 MVCC 需要的所有信息:
| 字段 | 含义 |
|---|---|
t_xmin | 插入该行的事务 ID——"谁创建了我" |
t_xmax | 删除/更新该行的事务 ID——"谁杀死了我"(0 表示还活着) |
t_cid | 命令序号,同一事务内的操作顺序 |
t_ctid | 当前版本的 TID(如果被更新,指向新版本 → 形成版本链) |
t_infomask | 标志位(是否提交、是否有 NULL 列、是否有 TOAST 等) |
t_hoff | 用户数据的起始偏移(跳过 NULL 位图) |
关键点:PG 的 MVCC 信息直接存在行头里,和用户数据在同一个 Page 上。这意味着旧版本的行不会消失——它们就堆在 Heap 里,直到 VACUUM 清理。这是 PG 和 MySQL InnoDB 最大的区别之一,后面会详细对比。
Buffer Pool:内存中的页面缓存
磁盘上的 Block 不会被直接访问。所有读写都经过 Buffer Pool——PostgreSQL 在共享内存中维护的一块页面缓存。
┌──────────────────────────────┐
│ Backend Process │
│ (你的 SQL 查询进程) │
└──────────┬───────────────────┘
│ 请求 Page (5, 0)
│ = 第5块, 主数据文件
▼
┌───────────────────────────────────────────────────────────┐
│ Shared Buffer Pool │
│ (shared_buffers 参数) │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Buffer Table (哈希表) │ │
│ │ │ │
│ │ BufferTag → Buffer ID │ │
│ │ {spc, db, rel, fork, block} → slot 编号 │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────┬────────┬────────┬────────┬────────┐ │
│ │ Slot 0 │ Slot 1 │ Slot 2 │ Slot 3 │ ... │ Buffer │
│ │ 8KB │ 8KB │ 8KB │ 8KB │ │ 数组 │
│ └────────┘ └────────┘ └────────┘ └────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Buffer Descriptors (描述符数组) │ │
│ │ 每个 slot 对应一个描述符: │ │
│ │ - tag: 这个 slot 缓存的是哪个 page │ │
│ │ - state: pin count + usage count + dirty flag │ │
│ │ - buf_id: 对应的 slot 编号 │ │
│ └──────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘
│
│ 缓存未命中 → 从磁盘读取
▼
┌──────────────────────┐
│ 磁盘文件 │
│ base/12345/67890 │
└──────────────────────┘
BufferTag:五元组定位
仅凭一个 BufferTag,就能确定数据在磁盘上的精确位置,不需要查系统表:
typedef struct buftag {
Oid spcOid; // 表空间 OID
Oid dbOid; // 数据库 OID
RelFileNumber relNumber; // 文件编号
ForkNumber forkNum; // 分支 (0=主数据, 1=FSM, 2=VM)
BlockNumber blockNum; // 块号
} BufferTag;
// 例:{spcOid=1663, dbOid=12345, relNumber=67890, forkNum=0, blockNum=5}
// → $PGDATA/base/12345/67890 的第 5 个 8KB 块
这个设计的自包含性很重要:后台刷脏页的进程可能在页面对应的事务还没提交时就开始工作,它不需要也不应该依赖系统表来定位数据。
共享 Buffer vs 本地 Buffer
| Shared Buffer | Local Buffer | |
|---|---|---|
| 用途 | 普通表 | 临时表、unlogged 表 |
| 可见性 | 所有后端进程共享 | 仅当前进程可见 |
| Buffer ID | > 0(buffer - 1) | < 0(-buffer - 1) |
| 需要锁 | 是(多进程并发) | 否(单进程独占) |
| 参数 | shared_buffers | temp_buffers |
生产环境一般把 shared_buffers 设为物理内存的 25%。PG 依赖操作系统的 Page Cache 做第二层缓存——数据可能不在 Buffer Pool 里,但还在 OS 缓存里,读取仍然很快。
时钟替换算法
Buffer Pool 满了需要淘汰页面时,PG 使用时钟扫描(Clock Sweep)算法。每个 buffer descriptor 维护一个 usage_count:
- 页面被访问时,
usage_count++(上限 5) - 时钟指针扫到一个 buffer 时:如果
usage_count > 0,减 1 并跳过;如果usage_count == 0,淘汰该页面 - 如果页面是脏页(dirty),淘汰前需要先刷盘
- 如果页面被 pin 住(有进程正在使用),跳过
时钟指针 →
┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
│ 3 │ │ 1 │ │ 0 │ │ 2 │ │ 0 │ │ 1 │ │ 0 │ │ 4 │
└───┘ └───┘ └─┬─┘ └───┘ └─┬─┘ └───┘ └─┬─┘ └───┘
│ │ │
减到0 减到0 减到0
淘汰! 淘汰! 淘汰!
usage_count 越高 → 越"热" → 越难被淘汰
这比 LRU 更适合数据库场景:一次全表扫描不会把热数据全部挤出缓存(因为扫描只给每个页面 +1,而热点页面的 usage_count 可能是 5)。
一次顺序扫描的完整 I/O 路径
当你执行 SELECT * FROM users,executor 调用 ExecSeqScan,最终走到存储层的 heap_getnextslot。我们追踪这条完整路径:
ExecSeqScan
│
▼
ExecScanExtended(SeqNext, SeqRecheck)
│
▼
SeqNext
│ 首次调用: table_beginscan() 初始化扫描描述符
│ 后续调用:
▼
table_scan_getnextslot
│ → heapam_methods.scan_getnextslot
▼
heap_getnextslot
│
├─── Page Mode (默认):
│ heapgettup_pagemode
│ 1. heap_fetch_next_buffer → 读取下一个 Page 到 Buffer Pool
│ 2. heap_prepare_pagescan → 一次性判断整页所有 Tuple 的可见性
│ 3. 构建 rs_vistuples[] 数组(可见 Tuple 的 OffsetNumber 列表)
│ 4. 循环遍历 rs_vistuples[],逐个返回 Tuple
│
└─── Tuple Mode:
heapgettup
逐个读取 Tuple,每个都单独做可见性判断
Page Mode 是默认策略,性能更好:一次获取整页的可见性结果,避免反复加锁。
heap_fetch_next_buffer:预读机制
顺序扫描不是一页一页读的——PG 有一个流式预读(Read Stream)机制:
- 维护一个
distance参数控制预读深度 - 连续的块会被合并成一个大 I/O 请求(I/O 合并)
- 快速路径:如果只有一个 pinned buffer 且预读距离为 1,直接读下一块,跳过复杂逻辑
- 每读一个 Page 会检查一次中断请求(
CHECK_FOR_INTERRUPTS),防止长扫描无法被取消
Page 内部的遍历
拿到一个 Page 后,具体怎么读里面的 Tuple:
// heapgettup_pagemode 的核心循环(简化)
page = BufferGetPage(scan->rs_cbuf); // 拿到 Page 指针
for (i = 0; i < scan->rs_ntuples; i++) {
OffsetNumber lineoff = scan->rs_vistuples[i]; // 可见 Tuple 的槽位号
ItemId lpp = PageGetItemId(page, lineoff); // 从 ItemId 数组拿到指针
tuple->t_data = (HeapTupleHeader) PageGetItem(page, lpp); // 定位 Tuple 数据
tuple->t_len = ItemIdGetLength(lpp); // Tuple 长度
ItemPointerSetOffsetNumber(&tuple->t_self, lineoff); // 设置 TID
// 检查扫描条件(如果有 scan key)
if (key != NULL && !HeapKeyTest(tuple, ...))
continue;
return; // 返回这个 Tuple 给上层
}
注意 TID 在扫描时被"重建"到 tuple->t_self 里——它是 (BlockNumber, OffsetNumber) 的组合,写入时确定,扫描时从当前位置推导出来供上层使用。
HOT Chain:在 Page 内完成更新
PG 的 UPDATE 实际上是 INSERT 新版本 + DELETE 旧版本。这意味着每次 UPDATE 都会:创建新 Tuple、分配新 ItemId、更新所有指向该行的索引。代价很大。
HOT(Heap Only Tuple)是一个关键优化:如果 UPDATE 不修改任何索引列,并且新 Tuple 能放在同一个 Page 里,PG 会创建一个 HOT 链,不更新索引。
UPDATE users SET name = 'Alice2' WHERE id = 123;
UPDATE users SET name = 'Alice3' WHERE id = 123;
索引条目: (id=123) → TID(5, 1) 只有一个索引条目!
│
▼
Page 5:
┌────────────────────────────────────────────────────────┐
│ ItemId[1] ItemId[2] ItemId[3] │
│ offset→A offset→B offset→C │
│ flags: REDIRECT flags: NORMAL flags: NORMAL │
├────────────────────────────────────────────────────────┤
│ │
│ Tuple C (最新) ← Tuple B (旧) ← Tuple A (最旧) │
│ name='Alice3' name='Alice2' name='Alice' │
│ t_xmax=0 t_xmax=103 t_xmax=102 │
│ t_ctid=(5,3) t_ctid=(5,3) t_ctid=(5,2) │
│ │
└────────────────────────────────────────────────────────┘
索引扫描时:
1. 索引给出 TID(5, 1)
2. 读 ItemId[1],发现是 REDIRECT → 跟着走
3. 沿 t_ctid 链遍历:A → B → C
4. 对每个版本做 MVCC 可见性检查
5. 返回对当前事务可见的版本
HOT 的三个条件:
- UPDATE 不修改任何有索引的列(否则索引条目必须更新)
- 新 Tuple 能放在同一个 Page 里(否则无法用 Page 内指针串联)
- 同一行的 HOT 链不能跨 Page
HOT 的收益:
- 减少索引膨胀:一个索引条目可以覆盖多次更新
- 提高 UPDATE 性能:省去索引更新的 I/O
- 简化 VACUUM:Page 内的 HOT 链可以在 Page 级别快速清理(Pruning)
索引扫描走 HOT 链的逻辑在 heap_hot_search_buffer 函数里——从索引给出的 TID 开始,沿着 t_ctid 一直遍历到链尾,对每个版本做可见性判断。
PG vs InnoDB:两种存储哲学
最后对比 PostgreSQL 和 MySQL InnoDB 在存储层的根本设计差异:
| PostgreSQL (Heap) | MySQL InnoDB (Clustered Index) | |
|---|---|---|
| 数据组织 | Heap:行按插入顺序堆放,无序 | 聚簇索引:行按主键顺序存储在 B+ Tree 叶节点 |
| UPDATE 方式 | 追加新版本到 Heap(多版本共存) | 原地更新,旧版本移到 Undo Log |
| 旧版本在哪 | 和新版本混在同一个 Heap Page 里 | 独立的 Undo Log 空间 |
| 膨胀问题 | 严重——旧版本不清理就一直占空间 | 较轻——Undo Log 可以独立回收 |
| VACUUM 需求 | 必须——否则表和索引持续膨胀 | 不需要等价机制(Purge 线程自动清理 Undo) |
| 主键查找 | 需要索引扫描 + 回表(TID → Heap Page) | 直接在 B+ Tree 上找到数据(索引即数据) |
| 二级索引回表 | 二级索引 → TID → 一次 Heap I/O | 二级索引 → 主键 → 再走一次聚簇索引 |
| 写放大 | HOT 优化减少索引写入 | Change Buffer 延迟二级索引更新 |
两种哲学的核心取舍:
- PG 选择了简单的写入路径:追加永远比原地更新简单、安全。代价是需要 VACUUM 来回收空间,运维复杂度更高
- InnoDB 选择了紧凑的存储:原地更新 + Undo Log 避免了膨胀。代价是 Undo Log 的管理逻辑更复杂,二级索引回表要走两次 B+ Tree
总结
几个值得记住的关键直觉:
- Page 是一切的基础:8KB 固定大小,ItemId 数组和 Tuple 数据相向增长。理解了 Page 内部布局,就理解了 PG 为什么这样设计 TID、为什么 VACUUM 可以在 Page 级别做碎片整理
- ItemId 是间接层:外部通过 (BlockNumber, OffsetNumber) 引用一行,VACUUM 可以在不修改索引的情况下移动 Tuple
- Buffer Pool 是所有 I/O 的中间层:BufferTag 五元组自包含定位、时钟替换算法比 LRU 更适合数据库负载、预读机制减少随机 I/O
- HOT Chain 是 PG 对"追加式更新"代价的补偿:不修改索引列的更新可以在 Page 内完成,不触发索引更新
- MVCC 信息存在行头里:这是 PG 和 InnoDB 最根本的差异——旧版本不在 Undo Log 而在 Heap 本身,直接导致了膨胀问题和 VACUUM 的必要性
下一篇,我们进入 MVCC 和 VACUUM 的世界:快照如何决定可见性、事务隔离级别的实现、以及为什么 VACUUM 是 PG DBA 最重要的日常工作。