三:页结构与剪枝

PostgreSQL 页结构深度解析

本文系统拆解 PostgreSQL 14 的页面(Page)机制,涵盖数据页、索引页的物理结构,快照与行版本可见性,页剪枝与 HOT 更新优化,并在关键处与 MySQL InnoDB 进行对比,帮助你从存储引擎底层真正理解 PostgreSQL 的运行方式。

文章目录

  • [PostgreSQL 页结构深度解析](#PostgreSQL 页结构深度解析)
    • [1. 页(Page)是什么](#1. 页(Page)是什么)
    • [2. 数据页(堆表页)结构](#2. 数据页(堆表页)结构)
      • [2.1 页头(PageHeaderData,24 字节)](#2.1 页头(PageHeaderData,24 字节))
      • [2.2 ItemId 槽位数组(每项 4 字节)](#2.2 ItemId 槽位数组(每项 4 字节))
      • [2.3 空闲空间](#2.3 空闲空间)
      • [2.4 元组区(Tuple / HeapTuple)](#2.4 元组区(Tuple / HeapTuple))
      • [2.5 Special 空间](#2.5 Special 空间)
      • [2.6 fillfactor 参数](#2.6 fillfactor 参数)
    • [3. 索引页结构](#3. 索引页结构)
      • [3.1 索引文件的页面类型](#3.1 索引文件的页面类型)
      • [3.2 索引页头与 Special 区](#3.2 索引页头与 Special 区)
      • [3.3 内部节点条目](#3.3 内部节点条目)
      • [3.4 叶子节点条目(IndexTuple)](#3.4 叶子节点条目(IndexTuple))
      • [3.5 不同索引类型的页面差异](#3.5 不同索引类型的页面差异)
    • [4. 行版本与 MVCC](#4. 行版本与 MVCC)
      • [4.1 元组头的 MVCC 字段](#4.1 元组头的 MVCC 字段)
      • [4.2 提示位(Hint Bits)](#4.2 提示位(Hint Bits))
      • [4.3 INSERT / DELETE / UPDATE 对页面的影响](#4.3 INSERT / DELETE / UPDATE 对页面的影响)
    • [5. 快照:可见性的核心机制](#5. 快照:可见性的核心机制)
      • [5.1 什么是快照](#5.1 什么是快照)
      • [5.2 快照的三个核心字段](#5.2 快照的三个核心字段)
      • [5.3 可见性判断规则](#5.3 可见性判断规则)
      • [5.4 数据库视界(Database Horizon)](#5.4 数据库视界(Database Horizon))
    • [6. 页剪枝(Page Pruning)](#6. 页剪枝(Page Pruning))
      • [6.1 什么是页剪枝](#6.1 什么是页剪枝)
      • [6.2 触发条件](#6.2 触发条件)
      • [6.3 页剪枝的工作内容](#6.3 页剪枝的工作内容)
    • [7. HOT 更新(Heap Only Tuple)](#7. HOT 更新(Heap Only Tuple))
      • [7.1 普通 UPDATE 的问题](#7.1 普通 UPDATE 的问题)
      • [7.2 HOT 更新的原理](#7.2 HOT 更新的原理)
      • [7.3 HOT 链的结构](#7.3 HOT 链的结构)
      • [7.4 HOT 链剪枝(HOT Chain Pruning)](#7.4 HOT 链剪枝(HOT Chain Pruning))
    • [8. HOT 链分裂](#8. HOT 链分裂)
    • [9. 索引页的剪枝](#9. 索引页的剪枝)
    • [10. 与 MySQL InnoDB 的页结构对比](#10. 与 MySQL InnoDB 的页结构对比)
    • [11. 相关辅助文件:FSM、VM、TOAST](#11. 相关辅助文件:FSM、VM、TOAST)
      • [11.1 空闲空间映射(FSM,`*_fsm`)](#11.1 空闲空间映射(FSM,*_fsm))
      • [11.2 可见性映射(VM,`*_vm`)](#11.2 可见性映射(VM,*_vm))
      • [11.3 TOAST 表(`pg_toast.*`)](#11.3 TOAST 表(pg_toast.*))
    • [12. 实战:用 pageinspect 观察页面](#12. 实战:用 pageinspect 观察页面)
      • [12.1 安装和基本用法](#12.1 安装和基本用法)
      • [12.2 观察 UPDATE 后的页面状态](#12.2 观察 UPDATE 后的页面状态)
      • [12.3 观察索引页的 HOT 链](#12.3 观察索引页的 HOT 链)
      • [12.4 观察页剪枝效果](#12.4 观察页剪枝效果)
    • [13. 总结](#13. 总结)

1. 页(Page)是什么

PostgreSQL 以 页(Page / Block) 作为磁盘 I/O 的最小单元,默认大小为 8 KB (编译时可通过 ./configure --with-blocksize 修改,最大 32 KB,一旦设定则不可在线更改)。

所有数据文件------无论是堆表、B-tree 索引、Hash 索引还是 TOAST 表------都由若干连续的页面组成,统一编号从 0 开始。读写操作以整页为粒度,页面先被载入 shared_buffers 缓冲区缓存,修改后标记为脏页,再由 writer 进程或 checkpointer 异步刷盘。

与 MySQL InnoDB 类似,PostgreSQL 的页大小也是固定的(InnoDB 默认 16 KB),两者均以页为单位管理缓冲区。但两者的页面内部结构和组织方式存在本质差异,后文详细对比。


2. 数据页(堆表页)结构

在 PostgreSQL 中,"表"被称为堆(Heap),因为行数据是无序堆放的,不像 MySQL InnoDB 聚簇索引那样按主键排列。

一个 8 KB 的堆表页面从低地址(地址 0)到高地址(地址 8192)依次排列如下五个区域:

复制代码
地址 0     ┌─────────────────────────────────┐
           │  页头 PageHeaderData(24 字节)  │
           ├─────────────────────────────────┤
           │  ItemId[1]   (4 字节)          │  ← 槽位数组,向高地址增长
           │  ItemId[2]                       │
           │  ItemId[3]                       │
           │  ...                             │
lower ──── ├─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┤
           │                                 │
           │          空 闲 空 间             │
           │                                 │
upper ──── ├─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┤
           │  Tuple N(最新插入)             │  ← 元组区,向低地址增长
           │  ...                             │
           │  Tuple 2                         │
           │  Tuple 1(最早插入)             │
           ├─────────────────────────────────┤
地址 8192  │  Special 空间(堆表 = 0 字节)   │
           └─────────────────────────────────┘

2.1 页头(PageHeaderData,24 字节)

页头字段含义:

字段 大小 说明
pd_lsn 8 字节 该页最后一次修改对应的 WAL LSN,用于崩溃恢复时判断 redo 是否需要重放
pd_checksum 2 字节 页面校验和(需开启 data_checksums 才生效)
pd_flags 2 字节 标志位,含 PD_HAS_FREE_LINES(有 dead 槽可复用)、PD_ALL_VISIBLE
pd_lower 2 字节 ItemId 数组末端偏移,即下一个新槽的起始位置
pd_upper 2 字节 元组区起始偏移,即下一个新 tuple 的起始位置
pd_special 2 字节 Special 区起始偏移(堆表页此值等于页大小,意味着 special 区为空)
pd_pagesize_version 2 字节 页大小和格式版本
pd_prune_xid 4 字节 页面上最老死元组的 xmax,用于判断是否值得剪枝

pd_upper - pd_lower 即页面当前的可用空闲字节数,FSM 文件汇总了所有页面这个值,用于快速找到可容纳新行的页面,避免全表扫描寻找空闲空间。

sql 复制代码
-- 查看页头信息
SELECT lower, upper, special, pagesize
FROM page_header(get_raw_page('accounts', 0));
-- lower=152, upper=6904, special=8192, pagesize=8192

2.2 ItemId 槽位数组(每项 4 字节)

紧接页头之后,是指向元组的指针数组,称为 ItemId(项指针 / 槽位),每个槽位 4 字节,包含三个字段:

字段 位宽 说明
lp_off 15 bit 元组在页内的起始字节偏移量
lp_flags 2 bit 状态标志:0=unused / 1=normal / 2=redirect / 3=dead
lp_len 15 bit 元组的字节长度

四种状态含义:

  • unused:从未分配过,初始状态
  • normal:正常,指向一个有效的 tuple
  • redirect :HOT 更新时使用,指向同页另一个槽号,lp_off 此时存的是目标槽号而非偏移
  • dead:tuple 已被 VACUUM 物理清除,槽号保留等待复用

TID(Tuple Identifier)的格式是 (页号, 槽号),例如 (3, 2) 表示第 3 页第 2 个槽。索引叶子节点存的就是 TID,而不是 tuple 的直接物理地址。这层间接寻址的设计价值在于:VACUUM 在页内整理碎片时移动 tuple,只需更新 ItemId 里的偏移,所有持有该 TID 的索引条目无需变化。

2.3 空闲空间

pd_lower(槽位数组末端)和 pd_upper(元组区起始)之间的区域是当前页的可用空间。

页面不会产生碎片 :每当 VACUUM 或页剪枝清理死 tuple 后,会调用 PageRepairFragmentation() 将存活 tuple 向高地址紧缩,空闲空间重新聚合成一个连续块。这与 MySQL InnoDB 需要通过 OPTIMIZE TABLE 来整理碎片的方式不同。

2.4 元组区(Tuple / HeapTuple)

元组从高地址向低地址生长,每次插入新行,pd_upper 向低地址移动 tuple_size 字节。

每个 HeapTuple = HeapTupleHeader(头部,最小 23 字节)+ 用户数据列值。行头字段详解请参考《PostgreSQL 行记录结构深度解析》一文,此处不再重复。

2.5 Special 空间

堆表页的 Special 区大小为 0(pd_special 等于页大小)。索引页会在此区域存放索引类型相关的辅助数据,详见下节。

2.6 fillfactor 参数

堆表有一个重要的存储参数 fillfactor(默认 100),表示 INSERT 操作允许填满页面的百分比。

sql 复制代码
CREATE TABLE orders (id serial, amount numeric)
WITH (fillfactor = 75);

设置 fillfactor = 75 意味着:INSERT 只能用到页面的 75% 空间,剩余 25% 预留给 UPDATE 操作在同页生成新版本(这是触发 HOT 更新的前提之一)。对于频繁 UPDATE 非索引列的表,适当降低 fillfactor 可以显著减少索引膨胀。


3. 索引页结构

B-tree 索引文件与堆表文件完全独立(relfilenode 不同)。索引文件同样由 8 KB 页面组成,但页面布局和内容与堆表有显著差异。

3.1 索引文件的页面类型

B-tree 索引文件包含三种页面:

  • Page 0:元数据页(Meta Page),记录根页号、索引版本等全局信息,不存实际数据
  • 内部节点页(Internal Node Page):存储导航键,用于在树中路由
  • 叶子节点页(Leaf Node Page) :存储实际的索引条目 (key, TID)

3.2 索引页头与 Special 区

索引页的页头结构与堆表页相同(同样的 PageHeaderData),但 Special 区pd_special 到页尾)存放了 B-tree 专属信息:

c 复制代码
/* B-tree 页 Special 区结构 BTPageOpaqueData */
typedef struct BTPageOpaqueData {
    BlockNumber btpo_prev;   /* 左兄弟页号(用于叶子层横向链表) */
    BlockNumber btpo_next;   /* 右兄弟页号 */
    uint32      btpo_level;  /* 节点层级:0 = 叶子,1 以上为内部节点 */
    uint16      btpo_flags;  /* 标志位:BTP_LEAF / BTP_ROOT / BTP_META 等 */
    BTCycleId   btpo_cycleid;/* 用于页分裂时的并发控制 */
} BTPageOpaqueData;

btpo_prevbtpo_next 将所有叶子页 串联成双向链表。范围扫描(如 WHERE name BETWEEN 'A' AND 'Z')找到起始叶子后,只需沿链表横向遍历,无需回到父节点,这是 B+ 树相对 B 树的核心优势。

这是堆表页与索引页最显著的结构差异:堆表页的 Special 区为空,而 B-tree 索引页的 Special 区存储了兄弟页号,实现了叶子层双向链表。

3.3 内部节点条目

内部节点(非叶子节点)的条目结构极为精简:

复制代码
内部节点条目 = 子页号(BlockNumber,4 字节)+ 导航 key(索引列值)

没有 TID,没有用户数据,没有 MVCC 信息。内部节点的条目只用于路由:决定查找时应该往哪个子节点走。在一个 8 KB 页面中,内部节点可以存放约 400 个子指针(以整数 key 为例),扇出极高,这是 B+ 树即使对于亿级数据也只需 4~5 层的根本原因。

3.4 叶子节点条目(IndexTuple)

叶子节点存储实际的索引条目:

复制代码
叶子节点条目(IndexTuple)= key 值(索引列的实际数据)+ TID(指向堆表 tuple 的坐标)

IndexTuple 不包含 xminxmaxctidinfomask 等 MVCC 字段。索引本身没有版本控制机制,所有版本的 tuple 都可能在索引中有对应条目,可见性判断完全依靠回堆后读取 HeapTuple 的头部字段。

以整数 key + TID(6 字节)为例,每个叶子条目约 20 字节,一个 8 KB 叶子页可以容纳约 400 个条目。这就是"叶子节点 ≠ 叶子条目"的关键所在:10 万条记录对应 10 万个叶子条目 ,但只需约 250 个叶子页面

3.5 不同索引类型的页面差异

PostgreSQL 支持多种索引类型,各自的 Special 区和页面内容不同:

索引类型 Special 区内容 适用场景
B-tree 左右兄弟页号、层级、标志位 等值、范围、排序查询(最常用)
Hash 哈希桶编号、分割标志 纯等值查询
GiST 扩展方法自定义内容 几何、全文索引
GIN 待处理列表指针等 数组、JSONB、全文索引
BRIN 范围摘要信息 超大表的物理相关性很好的列

4. 行版本与 MVCC

PostgreSQL 用 Append-Only 的 MVCC 机制 实现多版本并发控制:UPDATE 时不原地修改旧数据,而是追加新版本,旧版本通过 xmax 字段打上删除标记,两者共存于页面,直到 VACUUM 清理。

4.1 元组头的 MVCC 字段

每个 HeapTuple 头部的关键 MVCC 字段:

字段 大小 含义
t_xmin 4 字节 创建此版本的事务 XID(INSERT / UPDATE 写入新版本时设置)
t_xmax 4 字节 删除此版本的事务 XID(DELETE / UPDATE 旧版本时设置,0 表示未删)
t_ctid 6 字节 指向同一行的下一个更新版本(UPDATE 时旧版本的 ctid 改写为新版本 TID)
t_infomask 2+2 字节 提示位,缓存 xmin/xmax 的提交/中止状态,避免反复查 CLOG

4.2 提示位(Hint Bits)

t_infomask 中的以下四个 bit 是最常用的提示位:

复制代码
HEAP_XMIN_COMMITTED  (0x0100):xmin 事务已提交
HEAP_XMIN_INVALID    (0x0200):xmin 事务已中止或无效
HEAP_XMAX_COMMITTED  (0x0400):xmax 事务已提交
HEAP_XMAX_INVALID    (0x0800):xmax 无效(行未被删除)

提示位的特殊性 :它们由第一个"发现"某事务已完成的进程写回到 tuple 头部,之后所有访问该 tuple 的进程直接读 bit,无需再查 CLOG。这意味着只读 SELECT 也可能产生脏页(只修改了 infomask),这是 PostgreSQL 特有的行为,与 MySQL 不同。

4.3 INSERT / DELETE / UPDATE 对页面的影响

INSERT

  • 在目标页面追加新 tuple,xmin = 当前 XIDxmax = 0(XMAX_INVALID 位置 1)
  • ctid 指向自身

DELETE

  • 找到目标 tuple,将其 xmax 设为当前 XID,清除 XMAX_INVALID
  • 物理数据不变,等 VACUUM 清理

UPDATE(等价于 DELETE 旧版本 + INSERT 新版本):

  • 旧 tuple:xmax 写入当前 XID,ctid 改写为新版本 TID
  • 新 tuple:追加到页面,xmin = 当前 XIDctid = 自身 TID
  • 两个版本同时存在于页面,这是表膨胀(Table Bloat)的根源

ROLLBACK

  • 仅在 CLOG 中将 XID 标记为已中止,数据页不变
  • 下一个访问该 tuple 的进程发现 xmin/xmax 对应的事务是 aborted,写回 INVALID 提示位,该版本对所有后续事务不可见

5. 快照:可见性的核心机制

5.1 什么是快照

快照(Snapshot)是 PostgreSQL 在某一时刻对"哪些事务已提交"的一张快照记录,它定义了当前查询"能看到什么数据"。快照不是数据的物理副本,而是由几个数字组成的轻量结构。

  • Read Committed 隔离级别:每条 SQL 语句执行前重新获取快照
  • Repeatable Read / Serializable 隔离级别:事务内第一条语句获取快照,整个事务共用

5.2 快照的三个核心字段

复制代码
快照结构示例(pg_current_snapshot() 返回:790:792:789)

xmin = 790   最老活跃事务 ID
             小于此值的事务:要么已提交(其修改包含在快照中),要么已中止(忽略)

xmax = 792   最新已提交 XID + 1(上边界)
             大于等于此值的事务:仍在运行或尚未开始,其修改不可见

xip_list = [789]  快照创建时所有活跃事务的 XID 列表
                  即使这些 XID 在 [xmin, xmax) 范围内,其修改也不可见

5.3 可见性判断规则

对于一个 tuple,当前快照按以下规则判断其可见性:

复制代码
1. xmin 的事务是否已提交?
   - 已中止 → 不可见(此版本从未真正存在)
   - 未提交(在 xip_list 中)→ 不可见
   - 已提交且 XID < xmin → 可见(老事务,无条件可见)
   - 已提交且 xmin ≤ XID < xmax 且不在 xip_list → 可见

2. 若 xmin 可见,再判断 xmax:
   - xmax = 0 或 XMAX_INVALID 位置 1 → 未删除,tuple 可见
   - xmax 事务已提交 → 此 tuple 已被删除,不可见
   - xmax 事务未提交或已中止 → 删除无效,tuple 仍可见

PostgreSQL 为何无法实现时间旅行查询(Temporal / Flashback Query):快照记录的是"当时哪些事务活跃",而不是时间戳。事务完成后,就无法重建"那一刻所有事务的精确状态",因此无法回溯到任意历史时间点。

5.4 数据库视界(Database Horizon)

事务视界(Transaction Horizon) :一个事务当前快照的 xmin 值,即它能看到的最老事务边界。

数据库视界:所有活跃事务视界中最小的那个 xmin。这是 VACUUM 的关键参数------只有超出数据库视界的死元组,才能被安全清理(因为没有任何活跃事务需要看到它们)。

sql 复制代码
-- 查看当前事务的视界
SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid();

长事务对清理的危害:若某事务持有很老的快照(xmin 很小),数据库视界就无法前进,VACUUM 无法清理该视界内的任何死元组------即使那些数据与该长事务毫无关联。这是 PostgreSQL 运维中表膨胀问题的常见根源。


6. 页剪枝(Page Pruning)

6.1 什么是页剪枝

页剪枝是 PostgreSQL 的一种轻量级、单页范围内的清理机制,比全量 VACUUM 更快,发生时机更早,但作用范围仅限于单个堆表页面。

与 VACUUM 的区别:

  • VACUUM 需要扫描整张表,会更新 FSM、VM、清理索引
  • 页剪枝仅操作单个页面,不更新 FSM 和 VM,执行更快,但回收的空间只对 UPDATE 可用(不对 INSERT 开放)

6.2 触发条件

页剪枝在以下两种情况触发:

  1. UPDATE 找不到足够空间 :当 UPDATE 试图在当前页放置新版本 tuple,但发现空间不足时,先触发页剪枝,尝试清理死元组腾出空间。页头的 pd_prune_xid 字段记录了页面中最老的可能可清理元组的 xmax,用于快速判断是否值得触发剪枝。

  2. 超过 fillfactor 阈值 :INSERT 操作发现页面填充率已超过 fillfactor 参数设定的百分比时,在下次 UPDATE 访问该页时触发剪枝。

6.3 页剪枝的工作内容

页剪枝只清理超出数据库视界 的死元组------即 xmax < 数据库视界 xmin 的已提交删除操作对应的 tuple。尚有活跃快照需要的旧版本不会被碰。

清理过程:

  1. 遍历页内所有 ItemId,识别可清理的死 tuple
  2. 物理移除死 tuple 的数据
  3. 调用 PageRepairFragmentation() 将存活 tuple 向高地址紧缩,空闲空间合并为一个连续块
  4. 被剪枝 tuple 的槽位状态改为 dead(索引可能仍引用这些槽号)
sql 复制代码
-- 验证:用 fillfactor=75 的表观察页剪枝行为
CREATE TABLE hot(id integer, s char(2000)) WITH (fillfactor = 75);
CREATE INDEX hot_id ON hot(id);
INSERT INTO hot VALUES (1, 'A');
UPDATE hot SET s = 'B';
UPDATE hot SET s = 'C';
UPDATE hot SET s = 'D';
-- 此时页面有 4 个 tuple,超过 fillfactor 阈值
-- 下次 UPDATE 触发剪枝:
UPDATE hot SET s = 'E';

SELECT * FROM heap_page('hot', 0);
-- ctid | state | xmin  | xmax
-- (0,1)| dead  |       |
-- (0,2)| dead  |       |
-- (0,3)| dead  |       |
-- (0,4)| normal| 804 c | 805
-- (0,5)| normal| 805   | 0 a

7. HOT 更新(Heap Only Tuple)

7.1 普通 UPDATE 的问题

在没有 HOT 优化的情况下,每次 UPDATE 都会:

  1. 在堆表追加一个新版本 tuple
  2. 对表上所有索引新增一个条目(指向新 tuple 的 TID)

即使被修改的列根本没有索引,所有索引也必须增加新条目。这导致了两个问题:

  • 索引膨胀:索引中积累大量指向历史旧版本 tuple 的条目
  • 写放大:每次 UPDATE 都要写所有索引页

7.2 HOT 更新的原理

**HOT(Heap Only Tuple)**是一种针对"修改非索引列"场景的优化。若满足以下两个条件,PostgreSQL 使用 HOT 路径:

条件一:被修改的列不属于任何索引

条件二:新旧 tuple 在同一个堆表页面内

满足条件时,HOT 更新不会在任何索引中新增条目,而是在堆表页面内通过 ctid 链和 redirect 槽将新旧版本串联。

7.3 HOT 链的结构

HOT 更新后,堆表页面内形成如下结构:

复制代码
索引条目(只有这一个)
  |
  ↓
槽1(normal 状态)← 索引条目的 TID 指向这里
  Tuple(0,1):HHU bit=1(Heap Hot Updated,表示需要沿 ctid 链继续扫描)
    t_ctid = (0,2)(指向下一版本)
  |
  ↓ ctid 链
槽2(normal 状态)
  Tuple(0,2):HOT bit=1(Heap Only Tuple,表示无索引引用此版本)
    t_ctid = (0,2)(指向自身,是最新版本)

HHU(Heap Hot Updated)bit:设在旧版本 tuple 上,告知扫描器"需要沿 ctid 链继续找更新版本"。

HOT(Heap Only Tuple)bit:设在新版本 tuple 上,表示"没有任何索引直接引用我"。

当索引扫描访问堆页面,找到 HHU 标记的 tuple 时,会继续沿 ctid 链遍历,直到找到最新版本,再由快照规则决定哪个版本可见。

7.4 HOT 链剪枝(HOT Chain Pruning)

当页面空间耗尽需要剪枝时,HOT 链的处理比普通死元组更复杂:

  • 链头不能移动:索引条目指向链头的 TID(如槽1),移动它会导致索引引用失效
  • 解决方案:redirect 重定向

剪枝后,链头槽位变为 redirect 状态,lp_off 字段存储当前有效链头的槽号(而不是物理偏移),索引扫描通过重定向找到实际 tuple:

复制代码
剪枝前:
  槽1 normal → Tuple(0,1) 旧版本(链头,被索引引用)
  槽2 normal → Tuple(0,2) 新版本
  槽3 normal → Tuple(0,3) 当前版本

剪枝后(Tuple(0,1)和(0,2)超出视界被清理):
  槽1 redirect → 槽3(索引仍指向槽1,通过 redirect 跳到槽3)
  槽2 unused(释放,可被新 tuple 复用)
  槽3 normal → Tuple(0,3) 当前版本

8. HOT 链分裂

HOT 链只能在单个页面内延伸------这是 HOT 更新的天然约束。一旦当前页满了,即使被修改的列没有索引,PostgreSQL 也无法继续 HOT 更新:

  1. 新 tuple 被写入另一个页面(如 Page 1 槽1)
  2. 索引必须新增一个条目:key → TID(1,1)
  3. 原来的 HOT 链在此中断,Page 0 和 Page 1 上各有一条独立的 HOT 链

此后该索引将有两个条目(一个指向 Page 0 的旧链头,一个指向 Page 1 的新链头),HOT 优化的效益被削弱。

延迟 HOT 链分裂的手段 :降低表的 fillfactor,为 UPDATE 预留更多同页空间。

sql 复制代码
ALTER TABLE orders SET (fillfactor = 70);

9. 索引页的剪枝

索引页也有自己的剪枝机制,在 B-tree 页即将分裂时触发(分裂需要空间,若剪枝能腾出足够空间则避免分裂)。

可以被剪枝的两类索引条目:

  1. dead 标记的条目:在之前的索引扫描中,某进程访问该条目时发现对应堆 tuple 已超出所有事务的视界,便将此索引条目标记为 dead。

  2. LP_DEAD 标记的条目:当同一行有多个版本在索引中都有条目,且这些旧版本已超出数据库视界,PostgreSQL 可以识别出其中哪些是可以清理的。

索引页剪枝是避免索引过度膨胀的重要手段。B-tree 索引一旦分裂,两个分裂出的页面不会因后来数据删减而再合并(这会导致索引长期膨胀),但索引剪枝可以推迟分裂发生的时机。


10. 与 MySQL InnoDB 的页结构对比

维度 PostgreSQL 堆表页 MySQL InnoDB 页
默认页大小 8 KB(可编译时改) 16 KB(innodb_page_size
数据组织 无序堆放,与索引分离 聚簇索引,数据按主键有序
页内行的物理顺序 按插入顺序,无排序 按主键排序(聚簇)
UPDATE 机制 Append-Only,旧版本留原处 原地修改,旧版本存 Undo Log
MVCC 旧版本存放 堆表页面内(与当前版本共存) 独立的 Undo Log 文件
表膨胀问题 存在,需 VACUUM 定期清理 不存在,Undo Purge 线程异步清理
页头信息 24 字节 PageHeaderData 38 字节 FIL_PAGE_HEADER
页内目录 ItemId 槽位数组(页头后) Page Directory(页尾,存槽偏移)
空闲空间位置 槽位数组与 tuple 区中间 页中间 Free Space 区
Special / Trailer 区 Special 区(堆表=0,索引存兄弟页号) FIL_PAGE_TRAILER(8 字节,存校验和)
碎片整理 页剪枝/VACUUM 自动合并 OPTIMIZE TABLE 或 ALTER TABLE
索引回表 用 TID 直接定位,一次 I/O 用主键二次查聚簇索引,可能两次 I/O
主键选型敏感度 低(堆表无序,UUID 无写放大) 高(UUID 主键导致聚簇索引页频繁分裂)

核心差异总结

MySQL 的数据就是聚簇索引,数据文件和主键 B-tree 是同一棵树,物理有序。旧版本存在独立的 Undo Log,不占用数据页空间。

PostgreSQL 的数据文件(堆)和索引文件完全分离,数据无序堆放,任何索引(包括主键)都是独立的 B-tree 文件,叶子节点存 TID 指回堆表。旧版本就地保留在堆表页面,占用实际存储,必须靠 VACUUM 定期回收。


11. 相关辅助文件:FSM、VM、TOAST

每张表对应的物理文件不只一个,还有三个辅助文件:

11.1 空闲空间映射(FSM,*_fsm

FSM 文件以 B 树形式组织,存储每个堆表页面当前可用空闲空间的近似值(以 1/256 精度存储)。INSERT 时查询 FSM 找到有足够空间的页,是 O(1) 操作。

sql 复制代码
-- VACUUM 后 FSM 才会更新
VACUUM accounts;
SELECT * FROM pg_freespace('accounts');
-- blkno | avail
-- 0     | 3456
-- 1     | 7200

11.2 可见性映射(VM,*_vm

VM 文件每个堆表页面对应 2 bit:

  • All-Visible bit:该页所有 tuple 对所有当前事务均可见(无死元组)。Index Only Scan 若发现该位已设,可跳过回堆,直接返回索引中的数据。
  • All-Frozen bit:该页所有 tuple 已被冻结(XID 转为特殊值),不需要 MVCC 检查。

VACUUM 在清理死元组后维护 VM,autovacuum 的健康与否直接影响 Index Only Scan 的效率。

11.3 TOAST 表(pg_toast.*

当一行的数据加头部超过约 2000 字节(页面的四分之一),PostgreSQL 使用 TOAST 机制处理超长列:

  1. 先尝试 LZ 压缩
  2. 若仍超长,切分成约 2000 字节的块,存入单独的 TOAST 表(pg_toast schema)
  3. 主表 tuple 中只保留一个 18 字节的 TOAST 指针

TOAST 表本身也是普通的堆表(有对应的堆表文件),有自己的 TOAST 索引,也遵循同样的页面结构。对应用完全透明,查询时 PostgreSQL 自动拼接恢复原始值。若查询不涉及超长列,TOAST 表完全不会被读取(避免 SELECT * 的原因之一)。


12. 实战:用 pageinspect 观察页面

pageinspect 扩展可以直接读取页面的原始内容,是观察上述机制的最佳工具。

12.1 安装和基本用法

sql 复制代码
CREATE EXTENSION pageinspect;

-- 查看页头
SELECT lower, upper, special, pagesize
FROM page_header(get_raw_page('accounts', 0));

-- 查看堆表页所有槽和 tuple
SELECT
    lp AS slot,
    lp_off AS offset,
    lp_len AS length,
    CASE lp_flags
        WHEN 0 THEN 'unused'
        WHEN 1 THEN 'normal'
        WHEN 2 THEN 'redirect to ' || lp_off
        WHEN 3 THEN 'dead'
    END AS state,
    t_xmin,
    t_xmax,
    t_ctid,
    (t_infomask & 256) > 0  AS xmin_committed,
    (t_infomask & 512) > 0  AS xmin_aborted,
    (t_infomask & 1024) > 0 AS xmax_committed,
    (t_infomask & 2048) > 0 AS xmax_aborted
FROM heap_page_items(get_raw_page('accounts', 0));

12.2 观察 UPDATE 后的页面状态

sql 复制代码
CREATE TABLE t (id int, s text);
CREATE INDEX ON t(s);

BEGIN;
INSERT INTO t VALUES (1, 'FOO');
-- 查看插入后状态
SELECT lp, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page('t', 0));
-- lp=1, xmin=776, xmax=0, ctid=(0,1)
COMMIT;

BEGIN;
UPDATE t SET s = 'BAR';
-- 查看 UPDATE 后状态(事务未提交)
SELECT lp, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page('t', 0));
-- lp=1: xmin=776, xmax=778, ctid=(0,2)  ← 旧版本,xmax 打上当前 XID,ctid 指向新版本
-- lp=2: xmin=778, xmax=0,   ctid=(0,2)  ← 新版本
COMMIT;

12.3 观察索引页的 HOT 链

sql 复制代码
-- 使用 bt_page_items 查看索引页内容
SELECT itemoffset, htid, dead
FROM bt_page_items('t_s_idx', 1);
-- 普通 UPDATE 后:有两个条目(旧 TID 和新 TID)
-- HOT UPDATE 后:只有一个条目(指向链头)

12.4 观察页剪枝效果

sql 复制代码
-- 创建 fillfactor=75 的表
CREATE TABLE hot(id integer, s char(2000)) WITH (fillfactor = 75);
CREATE INDEX ON hot(id);
INSERT INTO hot VALUES (1, 'A');
UPDATE hot SET s = 'B'; UPDATE hot SET s = 'C'; UPDATE hot SET s = 'D';

-- 此时页面满(4个 tuple,超过 75% 阈值)
SELECT upper, pagesize FROM page_header(get_raw_page('hot', 0));

-- 触发页剪枝
UPDATE hot SET s = 'E';

-- 查看剪枝后页面:前3个槽变为 dead
SELECT ctid, state, xmin, xmax FROM heap_page('hot', 0);

13. 总结

PostgreSQL 的页面机制围绕"数据无序堆放,版本在页内共存,索引独立指回堆表"的核心设计展开:

机制 关键点
堆表页布局 页头→槽位数组(↓)→空闲区→元组区(↑)→Special(0字节)
间接寻址 TID 指向槽位,槽位指向 tuple 偏移,碎片整理无需修改索引
索引页 Special 区存兄弟页号(叶子双向链);叶子条目无 MVCC 字段,只有 key+TID
MVCC xmin/xmax 内嵌在 tuple 头,提示位缓存事务状态避免重复查 CLOG
快照 xmin/xmax/xip_list 三元组定义可见范围;长事务阻塞数据库视界前进
页剪枝 单页、轻量、实时触发,清理超出数据库视界的死元组,不更新 FSM/VM
HOT 更新 修改非索引列且同页时,不新增索引条目,通过 redirect+ctid 链访问新版本
HOT 链分裂 页满时 HOT 链跨页中断,退化为普通 UPDATE,索引新增条目
索引页剪枝 页分裂前触发,清理 dead 或过期索引条目,推迟分裂减少膨胀
VACUUM 全表扫描,清理所有死元组,更新 FSM/VM,清理索引;autovacuum 自动维护

理解页面结构,是排查表/索引膨胀、调优 autovacuum 参数、理解查询计划选择(Seq Scan vs Index Scan vs Index Only Scan)的基础,也是深入掌握 PostgreSQL 性能调优的必经之路。


相关推荐
运维 小白4 小时前
4. 部署postgresql服务并监控postgresql
数据库·postgresql
SelectDB技术团队5 小时前
PostgreSQL + Apache Doris:构建用于实时分析的 HTAP 架构
数据库·postgresql·架构·实时数仓·湖仓一体·apache doris·selectdb
gameboy03113 小时前
从MySQL迁移到PostgreSQL的完整指南
数据库·mysql·postgresql
IvorySQL19 小时前
PostgreSQL 技术日报 (3月18日)|从 MD5 到 SCRAM:PG 的安全转变
数据库·postgresql·开源
IvorySQL1 天前
速看!HOW 2026 12 大分论坛出品人集结
数据库·postgresql·开源
zklgin1 天前
PostgreSQL常用时间函数与时间计算提取示例说明
数据库·postgresql
Zacks_xdc1 天前
【全栈】Next.js + PostgreSQL + Vercel 实现完整登录系统(完整源码)
postgresql·全栈·next.js·登录鉴权·vercel
李长渊哦1 天前
PostgreSQL 18 本地部署与运维完全指南 (Windows版)
运维·windows·postgresql