三:页结构与剪枝

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 性能调优的必经之路。


相关推荐
倒流时光三十年1 小时前
PostgreSQL CASE 条件表达式详解
数据库·postgresql
倒流时光三十年2 小时前
PostgreSQL COALESCE 条件表达式函数详解
数据库·postgresql
雁無痕3 小时前
Postgresql启动无监听端口问题的解决
postgresql
倒流时光三十年6 小时前
PostgreSQL NULLIF 条件表达式函数详解
数据库·sql·postgresql
倒流时光三十年9 小时前
PostgreSQL VALUES 列表详解
数据库·postgresql
倒流时光三十年9 小时前
PostgreSQL GREATEST 条件表达式函数详解
服务器·数据库·postgresql
agilearchitect2 天前
asyncpg:专为 asyncio 打造的 PostgreSQL 驱动
数据库·其他·postgresql
IvorySQL2 天前
PostgreSQL 技术日报 (6月10日)|多工具版本更新,PG19 图语法落地
数据库·postgresql
IvorySQL2 天前
PostgreSQL 全球对话:开源链接世界,共建共治共享
数据库·postgresql·开源
IvorySQL2 天前
PostgreSQL 技术日报 (6月11日)|规划器扩展优化,POSETTE 大会倒计时
数据库·postgresql