土法炼钢兴趣小组的算法知识备份

【PG 内核】页面布局与元组格式:PG 如何把一行数据塞进 8KB

文章导航

分类入口
databasekernel
标签入口
#postgresql#pg-kernel#page-layout#heap-tuple#toast#pageinspect#ctid#xmin#xmax#t-infomask#storage

目录

页面布局与元组格式:PG 如何把一行数据塞进 8KB

如果你在 PG 里执行 SELECT * FROM t WHERE id = 1,你觉得 PG 去哪找这一行?不是去”行存储文件”——PG 没有这个东西。PG 的最小 I/O 单位是 8KB 页面(Page),所有数据(heap table、index、WAL、TOAST)都以页面格式存在文件系统中。

理解页面和元组的物理布局,是理解 VACUUM(它扫什么?)、Buffer Manager(它缓存什么?)、Index Scan(它怎么定位行?)和 MVCC 可见性判断(xmin/xmax 存在哪里?)的共同前提。本文从 pageinspect 扩展的视角出发,逐字节拆解 8KB 页面里塞了什么。


一、页面的三层结构

PG 的每个 heap 表文件(默认 1GB 一个 segment)由连续的 8KB 页面组成。每个页面的内部结构分三层:

┌─────────────────────────────────────────────┐
│              PageHeaderData                  │ ← 固定 24 字节,页面元数据
├─────────────────────────────────────────────┤
│       ItemId 数组(行指针数组)               │ ← 每项 4 字节,指向实际 tuple 位置
├─────────────────────────────────────────────┤
│                                             │
│              空闲空间                        │
│                                             │
├─────────────────────────────────────────────┤
│       Tuple N │ ... │ Tuple 2 │ Tuple 1     │ ← 实际数据,从页面底部向上增长
├─────────────────────────────────────────────┤
│           Special Space                     │ ← 索引页特有(B-Tree 的 BTPageOpaque 等)
└─────────────────────────────────────────────┘

ItemId 数组从页面上方向下增长,Tuple 数据从页面底部向上增长。中间是空闲空间。这个双向增长的设计让插入新行时不需要移动已有数据——只需在底部添加 tuple、在顶部添加 ItemId。

PageHeaderData

// src/include/storage/bufpage.h
typedef struct PageHeaderData {
    PageXLogRecPtr  pd_lsn;       // 8 bytes:最后修改此页的 WAL LSN
    uint16          pd_checksum;  // 2 bytes:页面校验和(data checksums 开启时有效)
    uint16          pd_flags;     // 2 bytes:标志位(PD_HAS_FREE_LINES 等)
    LocationIndex   pd_lower;     // 2 bytes:空闲空间起始位置(ItemId 数组末尾 + 1)
    LocationIndex   pd_upper;     // 2 bytes:空闲空间结束位置(Tuple 数据起始 - 1)
    LocationIndex   pd_special;   // 2 bytes:special space 起始位置
    uint16          pd_pagesize_version;  // 2 bytes:页面大小(低 8 位)和版本(高 8 位)
    TransactionId   pd_prune_xid; // 4 bytes:最近一次 HOT prune 的事务 ID
} PageHeaderData;
// 刚好 24 bytes
```text

关键字段:
- `pd_lower`:指向 ItemId 数组末尾。空闲空间从 `pd_lower` 开始。
- `pd_upper`:指向 Tuple 数据末尾。空闲空间到 `pd_upper` 结束。
- 可用空间 = `pd_upper - pd_lower`。
- `pd_lsn`:最后一次修改此页的 WAL LSN。crash recovery 时,如果此页的 LSN 比 WAL record 的 LSN 旧,说明此页需要重放 WAL。

### ItemId:指向 Tuple 的指针

```c
// src/include/storage/itemid.h
typedef struct ItemIdData {
    unsigned    lp_off:15;   // tuple 在页内的字节偏移(最大 2^15 = 32768,刚好 8 个 8KB)
    unsigned    lp_flags:2;  // LP_UNUSED(0) / LP_NORMAL(1) / LP_REDIRECT(2) / LP_DEAD(3)
    unsigned    lp_len:15;   // tuple 的字节长度
} ItemIdData;
// 4 bytes

lp_flags 的四种状态:

状态 含义 何时出现
LP_UNUSED 此 slot 空闲 初始状态,或被 VACUUM 回收后
LP_NORMAL 正常 tuple 正常行数据
LP_REDIRECT HOT 更新后的重定向 HOT (Heap-Only Tuple) 链——指向链中的第一个 tuple
LP_DEAD 死 tuple VACUUM 标记为不再需要,但还没被回收 slot

LP_REDIRECTLP_DEAD 是 HOT 更新的核心机制——我们会在第 8 章详细讨论,现在只需知道它们存在。


二、Heap Tuple 的物理格式

Heap Tuple(堆元组)是 PG 中一行数据的物理表示。它的结构分为两部分:TupleHeader(固定字段 + null bitmap + 可选的 OID)和 Tuple Data(各列的值)。

// src/include/access/htup_details.h
typedef struct HeapTupleHeaderData {
    // 事务与可见性字段(23 bytes)
    TransactionId   t_xmin;         // 4 bytes:创建此 tuple 的事务 ID
    TransactionId   t_xmax;         // 4 bytes:删除/更新此 tuple 的事务 ID,0 表示活跃
    CommandId       t_cid;          // 4 bytes:同一事务内的命令序号(用于当前事务的可见性)
    TransactionId   t_xvac;         // 4 bytes:最近一次移动此 tuple 的 VACUUM FULL 事务 ID
    ItemPointerData t_ctid;         // 6 bytes:(block_number, offset) — 指向此 tuple 或新版本

    // 信息掩码(4 bytes)
    uint16          t_infomask2;    // 2 bytes:属性数量和 flag bits
    uint16          t_infomask;     // 2 bytes:可见性 flag bits

    // 可选:OID(4 bytes,仅当表创建时指定了 WITH OIDS)
    Oid             t_oid;          // 4 bytes:对象 ID(PG 12+ 不再默认创建)

    // Null bitmap — 每列一位,对齐到 byte
    //   bits8[0] = 0x01 表示第一列为 NULL
    //   长度 = (列数 + 7) / 8 bytes
} HeapTupleHeaderData;
// Header 固定部分:23 bytes(无 OID 时)
```bash

### t_ctid:版本链的关键

`t_ctid` 是 `(block_number, page_offset)` 的 pair,表示"这个 tuple 的新版本在哪里"

- 如果行从未被更新:`t_ctid` 指向自身(当前 block 和 offset)
- 如果行被更新了(且新版本在同一页面内):`t_ctid` 指向新版本的 `(block, offset)`
- 如果行被更新到另一个页面:`t_ctid` 指向新页面的新 offset

这就是 PG 的版本链——从旧版本沿着 `t_ctid` 链可以找到最新版本,不需要 undo log。

Page 0, offset 5(旧版本) xmax = 101 (事务 101 UPDATE 了这行) t_ctid = (0, 12) ──→ Page 0, offset 12(新版本) xmin = 101 t_ctid = (0, 12) ← 指向自身(最新版本)


### t_infomask 和 t_infomask2:可见性判定的关键

这两个 16-bit 字段携带了 MVCC 可见性判断所需的所有 flag:

```c
// src/include/access/htup_details.h
// t_infomask 的关键 flag:
#define HEAP_XMIN_COMMITTED     0x0100  // xmin 事务已提交
#define HEAP_XMIN_INVALID       0x0200  // xmin 事务已回滚
#define HEAP_XMAX_COMMITTED     0x0400  // xmax 事务已提交
#define HEAP_XMAX_INVALID       0x0800  // xmax 事务已回滚
#define HEAP_HASNULL            0x0001  // tuple 包含 NULL 值
#define HEAP_HASVARWIDTH        0x0002  // tuple 包含变长字段
#define HEAP_HASEXTERNAL        0x0004  // tuple 包含外部存储的 toasted 值
#define HEAP_HOT_UPDATED        0x2000  // 此 tuple 是 HOT 更新的旧版本
#define HEAP_ONLY_TUPLE         0x8000  // 此 tuple 是 HOT-only tuple

注意 HEAP_XMIN_COMMITTEDHEAP_XMIN_INVALIDHEAP_XMAX_COMMITTEDHEAP_XMAX_INVALID —— 这些就是 hint bit。它们的存在是为了避免每次都去查 CLOG(提交日志)确认事务状态。一旦第一次判定 xmin=101 已提交,就把 HEAP_XMIN_COMMITTED 写入 tuple header;后续读取直接看 flag,不再查 CLOG。hint bit 的写入时机和竞争是第 3 章的主题。

Tuple Data:列值的顺序排列

Header 之后是实际的列值。PG 按列的定义顺序存储:

[Header][Null Bitmap][Column 1 Value][Column 2 Value]...

对齐规则:PG 按列的 typalign(在 pg_type 目录中定义)对齐数据: - c (char):1-byte 对齐 - s (short):2-byte 对齐 - i (int):4-byte 对齐 - d (double):8-byte 对齐

一个包含 (int, bigint, text) 列的 tuple 实际布局:

[HeapTupleHeader (23B)][Null Bitmap (1B, 3 cols)][padding 到 4-byte][int value (4B)][bigint value (8B)][text length (4B varattrib)][text data...]

三、TOAST:大字段的外存策略

如果一行数据超过 8KB 页面怎么办?PG 默认页面大小是 8KB,但一行不能跨页面存储——这是 PG 的硬约束。TOAST(The Oversized-Attribute Storage Technique)是 PG 对大字段的解决方案。

触发条件

当一行(header + 所有列值)在压缩前超过约 2KB(TOAST_TUPLE_THRESHOLD = 约 1/4 页面)时,TOAST 触发。压缩后的值如果仍然超过阈值,则被外存(out-of-line)——存到关联的 TOAST 表中。

四种存储策略

每列在 pg_attribute.attstorage 中有一个存储策略(CREATE TABLE 时通过 ALTER COLUMN SET STORAGE 设置):

策略 含义 适用场景
PLAIN 禁止压缩和外存 固定长度类型(integer, boolean, uuid 等)
EXTENDED(默认) 先压缩,压缩后仍超标则外存 text, jsonb, bytea 等大字段
EXTERNAL 不压缩,但允许外存 已压缩的数据(图片、视频),再压缩无用
MAIN 优先压缩,但尽量不外存 中等大小的字段,希望 inline 保持性能

TOAST 表的物理结构

每个需要 TOAST 的表都有一个关联的 TOAST 表,命名为 pg_toast.pg_toast_<OID>。TOAST 表也有自己的索引(用于按 chunk_id + chunk_seq 查找块)。

原始表: public.large_table
TOAST 表: pg_toast.pg_toast_16384  (16384 是原始表的 OID)
TOAST 索引: pg_toast.pg_toast_16384_index

TOAST 表的列结构:

-- 逻辑结构
CREATE TABLE pg_toast.pg_toast_16384 (
    chunk_id        oid,       -- 标识同一个原始行的所有 chunk
    chunk_seq       int4,      -- chunk 序号(从 0 开始)
    chunk_data      bytea      -- 实际数据块(~2KB)
);
```text

一个原始的 10KB JSON 字段被 TOAST 后,会在 TOAST 表中产生约 5-6 行 chunk。

### TOAST 在 Tuple Header 中的标记

外存列的值在原 tuple 中不包含实际数据,而是一个 **TOAST pointer**(varatt_external 结构),包含 TOAST 表的 OID 和 `chunk_id`:

```c
// src/include/varatt.h
typedef struct varatt_external {
    int32   va_rawsize;       // 解压后的原始大小
    int32   va_extsize;       // 压缩后的大小
    Oid     va_valueid;       // TOAST 表中关联行的 chunk_id
    Oid     va_toastrelid;    // TOAST 表的 OID
} varatt_external;

当需要读取这个字段时,PG 会调用 heap_tuple_untoast_attr() 去 TOAST 表按 chunk_id + chunk_seq 把 chunks 拼接起来并解压。


四、实验:用 pageinspect 观察页面

1. 创建测试表

CREATE TABLE test_page (
    id    INTEGER,
    name  TEXT,
    value DOUBLE PRECISION
);

INSERT INTO test_page VALUES
    (1, 'hello', 3.14),
    (2, 'world', 2.71);

-- 找到表文件的路径
SELECT pg_relation_filepath('test_page');
-- 输出:base/5/16384
```bash

### 2. 使用 pageinspect 观察页面

```sql
CREATE EXTENSION pageinspect;

-- 查看页面 header
SELECT * FROM page_header(get_raw_page('test_page', 0));

-- 输出示例:
-- lsn     | checksum | flags | lower | upper | special | pagesize | version | prune_xid
-- 0/2A00  | 12345    | 1     | 36    | 8096  | 8192    | 8192     | 4       | 0

-- lower=36:两个 ItemId(每个 4 bytes)+ PageHeaderData(24 bytes)+ 对齐 = 36
-- upper=8096:两个 tuple 占用了从 8096 到 8192 的空间
-- 空闲空间 = 8096 - 36 = 8060 bytes

-- 查看所有 ItemId
SELECT *
FROM heap_page_items(get_raw_page('test_page', 0));

-- 输出:
-- lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_ctid        | t_infomask | t_data
--  1 |   8152 |        1 |     42 |    101 |      0 | (0,1)         |       2305 | ...
--  2 |   8096 |        1 |     42 |    101 |      0 | (0,2)         |       2305 | ...

3. 观察 UPDATE 产生的多版本

UPDATE test_page SET name = 'updated' WHERE id = 1;

-- 再看页面内容
SELECT lp, lp_off, lp_flags, lp_len, t_xmin, t_xmax, t_ctid, t_infomask
FROM heap_page_items(get_raw_page('test_page', 0));

-- 现在你会看到:
-- lp=1:旧版本,xmax=102(更新它的事务),t_ctid=(0,3)(指向新版本)
-- lp=2:不变
-- lp=3:新版本,xmin=102,t_ctid=(0,3)(指向自身)
```bash

### 4. 观察 TOAST 行为

```sql
CREATE TABLE test_toast (data TEXT);

-- 插入一个超长字符串(超过 TOAST_TUPLE_THRESHOLD)
INSERT INTO test_toast VALUES (repeat('x', 5000));

-- 查看原始 tuple 的 TOAST pointer
SELECT t_infomask, t_data
FROM heap_page_items(get_raw_page('test_toast', 0));

-- t_infomask 包含 HEAP_HASEXTERNAL(0x0004)标志
-- t_data 中对应列的值是 TOAST pointer,不是实际数据

五、页面格式对性能的影响

HOT Update 的页面内优化

如果 UPDATE 不修改任何索引列,且新版本能放进同一页面,PG 可以执行 HOT (Heap-Only Tuple) 更新: - 旧 tuple 的 t_ctid 指向新 tuple - 旧 ItemId 的 lp_flags 设为 LP_REDIRECT,直接跳到最新的 HOT tuple - 不需要更新任何索引——索引项仍然指向旧 ItemId,通过 LP_REDIRECT 链找到最新版本

这是 PG 中非常重要的性能优化——不修改索引列的 UPDATE 不触发索引更新。但如果新版本放不进同一页面,HOT 无法执行,必须更新所有索引。

页面填充因子 (fillfactor)

CREATE TABLE ... WITH (fillfactor = 70) 控制页面初始填充比例。每个页面只在初始 bulk load 时填到 fillfactor 的比例,之后的 UPDATE 可以使用剩余的空间来容纳新版本。如果一个页面的 fillfactor 设得足够低,HOT update 成功在同一页面创建新版本的可能性就更高,索引维护开销就更低。

fillfactor 的默认值是 100(heap table)和 90(index),但对于 UPDATE-heavy 的表,设为 70 或 80 能显著减少索引膨胀。


六、关键要点

  1. PG 的最小 I/O 单位是 8KB 页面——heap table、index、WAL、TOAST 都以页面格式存储。页面结构是堆索引、VACUUM、Buffer Manager 工作的基础。
  2. ItemId 数组 + Tuple 数据双向增长——插入新行不需要移动现有数据,但删除行会产生碎片。VACUUM 负责回收和整理。
  3. HeapTupleHeader 携带 MVCC 所需的全部字段——xmin/xmax/t_infomask/t_ctid 是可见性判定、版本链追踪和 HOT 更新的基础。hint bit 写入后避免重复查 CLOG。
  4. TOAST 是 PG 对大字段的解决方案——通过压缩和外存(单独的 TOAST 表按 chunk 存储)突破 8KB 页面限制。四种存储策略给不同场景的优化空间。
  5. pageinspect 是观察页面物理结构的最佳工具——直接显示每个 page 的 header、每条 tuple 的 header 字段和实际数据,是理解 VACUUM、膨胀和索引行为的利器。

上一章:进程模型与共享内存 下一章:MVCC 实现:CLOG、hint bit 与快照可扩展性


参考资料

源码(PG 17)

官方文档

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-16 · database / kernel

【PG 内核】进程模型与共享内存:Postmaster 如何管理 100 个 Backend

拆解 PostgreSQL 多进程架构的核心:Postmaster 的启动与信号处理、Backend 进程的 fork()→InitPostgres→主循环生命周期、CreateSharedMemoryAndSemaphores() 的共享内存初始化流程、PGPROC/ProcArray/PGXACT 等关键共享内存结构的内存布局,以及 Background Worker 的注册与调度。理解了这个地基,才能理解 PG 为什么用进程而不是线程,以及 max_connections 为什么不能随便调大。

2026-06-16 · database / kernel

【PG 内核】MVCC 实现:CLOG、hint bit 与快照可扩展性

在已有 MVCC 文章基础上深入 PG 并发控制的三个基础设施:CLOG 的 SLRU 结构(事务状态位、页面格式、SLRU 淘汰)、hint bit 的写入时机和竞争问题(何时写、谁写、写坏了怎么办)、PG 14 snapshot scalability 优化的具体机制(ProcArrayLock 为什么是瓶颈、xid/xmin 的原子更新如何减少持锁路径),以及事务 ID 回卷(wraparound)的威胁模型。最后与 InnoDB undo log 方案做系统性对比。

2026-06-16 · database / kernel

【PG 内核】WAL 内部机制:从事务提交到磁盘刷写

拆解 PostgreSQL WAL 的完整内部机制:XLogInsert() 从分段锁到 WAL Buffer 的插入路径、XLogRecord 的物理布局(Header + Block Headers + Data)、Checkpoint 的两阶段流程与 IO 摊平算法、REDO 恢复的 RMGR 分发、wal_level 三级差异的 WAL 记录对比。运维部分聚焦 checkpoint IO 风暴的根因与 checkpoint_completion_target 的调优陷阱、max_wal_size 设小导致 WAL 段疯狂切换的机制,以及用 pg_waldump 定位问题 WAL record 的实操方法。

2026-06-16 · database / kernel

【PG 内核】Buffer Manager:为什么 shared_buffers 不是越大越好

拆解 PostgreSQL Buffer Manager 的核心机制:shared_buffers 的内部组织(BufferDescriptors 数组、Buffer Table hash table、buffer pool)、Clock sweep 替换算法的完整源码路径、buffer 四态状态机与 pin/unpin 协议、bgwriter 的触发条件与脏页写入策略、BAS_BULKREAD/BAS_VACUUM ring buffer 的缓存隔离机制。用 pg_buffercache 实验观察 buffer 分布和 clock sweep 行为,解释为什么 shared_buffers 超过 8-10GB 后回报递减——double buffering、checkpoint IO 尖峰和 clock sweep 扫描延迟的三重反噬。


By .