PostgreSQL 存储引擎内幕(上):Heap Page 与 Buffer Pool

Coin Cabinet, designed by Charles Percier, made by Martin-Guillaume Biennais, ca. 1809-19
Charles Percier 设计,《钱币柜》, 约 1809-19 — 打开柜门,44 个固定大小的抽屉整齐排列,每一格存放不同的钱币。这和 PostgreSQL 的存储结构异曲同工:文件是柜子,Page 是抽屉,Tuple 是里面的钱币
PostgreSQL 存储引擎内幕
上:Heap Page 与 Buffer Pool · 中:索引扫描与 MVCC · 下:WAL、Checkpoint 与持久性
这篇文章的价值:当你执行 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不限一张表或一个索引。在磁盘上对应一个或多个文件
Segment1 GB当文件超过 1GB 时自动分段(relFileNumber, relFileNumber.1, ...)
Page (Block)8 KB存储的基本单位。磁盘上叫 Block,读入内存叫 Page
Tuple可变一行数据。包含行头 + 用户数据 + 对齐填充

单表容量上限怎么算?Page 大小 (8KB) × 最大 Block 数 (2^32) = 32 TB。这是硬编码的上限。

每个 Relation 在磁盘上还有几个"分支文件"(Fork):

Fork编号文件后缀内容
Main0(无后缀)主数据文件,存放实际的行数据
FSM1_fsmFree Space Map,记录每个 Page 的可用空间
VM2_vmVisibility Map,标记哪些 Page 上所有 Tuple 都可见(VACUUM 用)
Init3_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_lsn8 字节最后修改该 Page 的 WAL 记录的 LSN。决定了恢复时是否需要重放
pd_checksum2 字节页面校验和,检测存储层数据损坏
pd_flags2 字节标志位(是否有空闲空间、是否全部可见等)
pd_lower2 字节指向空闲空间的起始位置(ItemId 数组的末尾)
pd_upper2 字节指向空闲空间的结束位置(Tuple 数据的起始)
pd_special2 字节Special Space 的起始偏移(索引页面使用)

pd_upper - pd_lower = 当前 Page 的可用空间。INSERT 新行时,PG 检查这个值够不够放下新 Tuple + 一个新 ItemId。

ItemId:间接寻址层

ItemId 是一个 4 字节的结构,包含三个信息:

为什么要多这一层间接?因为 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 不会改变它
ItemIdPage 内的间接指针 (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 字节 ────►                      │
  └─────────────────────────────────────────────────────────────┘

理解了这三层关系,就能理解以下行为:

一个常见的误解是"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 BufferLocal Buffer
用途普通表临时表、unlogged 表
可见性所有后端进程共享仅当前进程可见
Buffer ID> 0(buffer - 1)< 0(-buffer - 1)
需要锁是(多进程并发)否(单进程独占)
参数shared_bufferstemp_buffers

生产环境一般把 shared_buffers 设为物理内存的 25%。PG 依赖操作系统的 Page Cache 做第二层缓存——数据可能不在 Buffer Pool 里,但还在 OS 缓存里,读取仍然很快。

时钟替换算法

Buffer Pool 满了需要淘汰页面时,PG 使用时钟扫描(Clock Sweep)算法。每个 buffer descriptor 维护一个 usage_count

  时钟指针 →

  ┌───┐  ┌───┐  ┌───┐  ┌───┐  ┌───┐  ┌───┐  ┌───┐  ┌───┐
  │ 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)机制:

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 的三个条件:

HOT 的收益:

索引扫描走 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 延迟二级索引更新

两种哲学的核心取舍:

总结

几个值得记住的关键直觉:

下一篇,我们进入 MVCC 和 VACUUM 的世界:快照如何决定可见性、事务隔离级别的实现、以及为什么 VACUUM 是 PG DBA 最重要的日常工作。