数据库基础体系 · 第 95/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

PostgreSQL 存储内部:Heap Page、Tuple、TOAST、FSM 和可见性图

在 PostgreSQL 中,一张表并不是“连续存放的一组行”。从存储引擎的角度看,一张普通表至少涉及以下对象:

  • Heap relation:保存表的主数据页;
  • Heap page:固定大小的页面,通常为 8 KiB;
  • Tuple:页面中的行版本,而不是抽象意义上的“当前行”;
  • TOAST relation:保存过大的字段值;
  • FSM fork:记录页面大致可用空间;
  • Visibility Map fork:记录哪些数据页中的元组对所有事务都可见,以及哪些页中的元组已经冻结;
  • WAL、页面 LSN 和 hint bit:保证页面修改能够恢复,并减少重复可见性判断。

理解这些对象之间的关系,是解释 PostgreSQL 中 MVCC、UPDATE 膨胀、VACUUM、HOT、索引只读扫描和存储诊断的基础。


一、从逻辑行到物理存储

先区分三个经常被混用的概念:

  1. 逻辑行:用户通过 SQL 看到的记录;
  2. Tuple:表中某个逻辑行在某一时刻的物理版本;
  3. 页面:保存多个 Tuple 以及它们的定位信息的固定大小存储单元。

例如:

CREATE TABLE account (
    id      bigint PRIMARY KEY,
    balance numeric(18, 2),
    note    text
);

INSERT INTO account VALUES (1, 100.00, 'initial');
UPDATE account SET balance = 120.00 WHERE id = 1;

用户通常认为第二条语句“修改了这一行”。但在 PostgreSQL 的 MVCC 模型中,UPDATE 通常不是直接覆盖原 Tuple 的字段,而是:

  1. 创建一个新的 Tuple;
  2. 将旧 Tuple 标记为被新版本替代;
  3. 让不同快照分别看到旧版本或新版本;
  4. 等旧版本不再被任何事务需要后,由 VACUUM 回收其空间。

因此,一条逻辑行可能在页面中对应多个物理 Tuple。

对于普通表,数据文件通常位于类似以下路径:

$PGDATA/base/<database_oid>/<relfilenode>
$PGDATA/base/<database_oid>/<relfilenode>_fsm
$PGDATA/base/<database_oid>/<relfilenode>_vm

这里:

  • 主文件保存 heap page;
  • _fsm 是 Free Space Map;
  • _vm 是 Visibility Map;
  • 文件名中的 relfilenode 是物理存储标识,不等同于表的逻辑 OID;
  • 表被重写后,relfilenode 可能改变。

索引也有自己的主文件和 FSM,但索引不使用与 heap 表相同语义的 Visibility Map 来表示索引页中的元组可见性。


二、Heap Page:一个 8 KiB 页面内部有什么

2.1 页面大小和页头

PostgreSQL 的默认页面大小是 8 KiB,由构建时的 BLCKSZ 决定。它是 PostgreSQL 的编译时常量,不能通过普通运行时参数随意修改。

一个 heap page 大致如下:

+-------------------------------+  page start
| PageHeaderData                |
+-------------------------------+
| ItemIdData / line pointer 1   |
| ItemIdData / line pointer 2   |
| ...                           |
|                               |
|           free space          |
|                               |
| Tuple 2                       |
| Tuple 1                       |
+-------------------------------+
| special space                 |  heap page 通常很少或没有
+-------------------------------+  page end

页面头中包含几个关键边界:

  • pd_lower:line pointer 区域的结束位置;
  • pd_upper:Tuple 数据区域的起始位置;
  • pd_special:特殊区域的起始位置,普通 heap page 通常位于页面末尾;
  • pd_pagesize_version:页面大小和版本信息;
  • pd_lsn:最后一次修改该页面的 WAL 位置;
  • 校验和相关信息取决于集群是否启用了 data checksums。

页面从两端向中间使用:

  • line pointer 从页面头部向后增长;
  • Tuple 从页面尾部向前增长;
  • 两者之间的空白区域是页面当前可用空间。

这是一种 slotted page,即“槽页”布局。它的重要性在于:Tuple 在页面中的物理偏移可以改变,但 line pointer 可以保持稳定。

2.2 Line pointer 不是 Tuple 本身

页面中的每个槽由一个 ItemIdData 描述,通常称为 line pointeritem pointer。它记录:

  • Tuple 在页面中的偏移;
  • Tuple 长度;
  • 当前状态。

因此,一个指向表中物理行版本的位置通常写作:

(block number, offset number)

例如:

(42, 7)

含义是:

  • 第 42 个页面;
  • 页面中的第 7 个 line pointer。

这个位置常见于 Tuple 的 ctid 字段。

需要注意,ctid 不是永久稳定的行 ID:

  • UPDATE 往往创建新 Tuple,ctid 会变化;
  • VACUUM FULLCLUSTER、某些表重写操作会重排物理位置;
  • 应用程序不应把 ctid 当作业务主键。

但在诊断和理解 MVCC 链条时,ctid 非常有用。

2.3 Tuple 的基本结构

heap Tuple 通常包含:

+-------------------------------+
| Tuple header                  |
+-------------------------------+
| null bitmap(如果存在)       |
+-------------------------------+
| 对齐填充                      |
+-------------------------------+
| column data                   |
+-------------------------------+

Tuple header 中的重要字段包括:

  • t_xmin:创建该 Tuple 的事务 ID;
  • t_xmax:删除或替换该 Tuple 的事务 ID;如果还没有删除,通常表示为无效或未设置状态;
  • t_cid:命令 ID,帮助区分同一事务内的命令顺序;
  • t_ctid:当前 Tuple 的物理位置,或者更新链上的下一个位置;
  • t_infomaskt_infomask2:记录 NULL、锁、HOT、事务状态提示等信息;
  • t_hoff:用户数据起始偏移。

Tuple 头本身通常至少需要约 23 字节,再加上 NULL bitmap 和对齐空间。这个数字属于当前常见实现细节,不应被当成所有版本和所有构建方式下的稳定 SQL 契约。

一个页面可容纳多少行,不仅取决于列数据大小,还取决于:

Tuple header
+ NULL bitmap
+ alignment padding
+ 各列的物理表示
+ line pointer

因此,用“页面大小除以平均行大小”估算行数通常会偏乐观。


三、Tuple、事务 ID 和 MVCC 可见性

3.1 Tuple 是版本,不是抽象行

PostgreSQL 使用 MVCC。一个 Tuple 是否可见,不仅取决于它是否存在,还取决于:

  • 创建它的事务是否提交;
  • 读取事务的快照何时生成;
  • 删除或替换它的事务是否提交;
  • 这些事务是否在快照生成时仍处于进行中;
  • Tuple 是否被冻结;
  • Tuple header 中是否存在可供快速判断的 hint bit。

可以先用概念化的条件表示。

设:

  • xmin 是创建 Tuple 的事务;
  • xmax 是删除或替换 Tuple 的事务;
  • S 是读取事务的快照;
  • Committed(x) 表示事务 x 已提交;
  • VisibleToSnapshot(x, S) 表示事务 x 的结果已经进入快照 S 的可见范围。

那么,Tuple 对快照 S 可见,大致需要满足:

创建条件:
    xmin 是当前事务,
    或 xmin 已提交且 xmin 对 S 可见

删除条件:
    xmax 不存在,
    或 xmax 尚未提交,
    或 xmax 对 S 不可见

合并起来就是:

TupleVisible(T, S)
=
CreatorVisible(xmin, S)
AND
NOT DeleterVisible(xmax, S)

这不是 PostgreSQL 内部 HeapTupleSatisfiesMVCC 的逐行伪代码,而是理解其决策的形式化模型。真实实现还要处理:

  • 当前事务自己的修改;
  • 子事务;
  • 中止事务;
  • 命令 ID;
  • 特殊事务 ID;
  • 冻结后的 xmin
  • hint bit;
  • 特定快照类型。

3.2 完整算例:两个事务看到不同版本

假设事务 ID 按顺序分配:

T1 = 100
T2 = 101
T3 = 102

初始插入:

T1: INSERT id = 1, balance = 100

提交后,Tuple 可能具有概念上的状态:

Tuple A:
    xmin = 100
    xmax = invalid

然后:

T2: UPDATE account SET balance = 120 WHERE id = 1;

UPDATE 创建新 Tuple:

Tuple A:
    xmin = 100
    xmax = 101
    ctid  -> Tuple B

Tuple B:
    xmin = 101
    xmax = invalid

若 T2 已提交:

  • T3 在 T2 提交之后生成快照:
    • Tuple A 的删除者 101 对快照可见,因此 A 不可见;
    • Tuple B 的创建者 101 对快照可见,因此 B 可见。
  • 一个在 T2 提交前已经生成快照的长事务:
    • 可能仍看见 A;
    • 看不见 B,因为 B 的创建者对该快照不可见。

这就是为什么 UPDATE 会产生旧版本,也解释了为什么长事务会阻止旧版本回收。

3.3 t_ctid 与更新链

在普通更新中,旧 Tuple 的 t_ctid 常指向新 Tuple:

旧 Tuple A: (block 42, offset 7)
    t_ctid -> (block 42, offset 9)

新 Tuple B: (block 42, offset 9)
    t_ctid -> 自身或后续版本

这不是说读取一定沿着 ctid 逐个追踪版本。索引扫描、heap fetch、可见性判断和 HOT 链处理会共同完成定位。ctid 主要是物理链信息,不是应用层版本机制。


四、页面中的空间如何管理:插入、更新和删除

4.1 INSERT

一次普通 INSERT 的主要路径可以概括为:

  1. 访问 FSM,寻找可容纳新 Tuple 的 heap page;
  2. 如果没有合适页面,扩展关系文件;
  3. 在目标 page 中分配 line pointer;
  4. 在页面末尾分配 Tuple 空间;
  5. 写入 Tuple header 和列数据;
  6. 生成 WAL;
  7. 根据需要更新页面 LSN、FSM 和相关元数据。

FSM 提供的是“大致可用空间”,并不是实时精确的字节计数。因此,即使 FSM 表示页面可能足够,实际插入时仍可能发现空间不足并继续寻找其他页面。

4.2 DELETE

DELETE 通常不会立即从页面中删除 Tuple 数据。它主要修改 Tuple 的事务状态,例如设置 xmax,并生成相应 WAL。

在删除事务提交之前,其他事务仍可能根据自己的快照看到该 Tuple。提交后,Tuple 可能对新快照不可见,但仍占据页面空间。

随后 VACUUM 才会在确认没有任何仍然有效的快照需要该版本后:

  • 清理死 Tuple;
  • 回收 line pointer 或将其标记为可复用;
  • 进行页面内整理;
  • 将新的可用空间反馈给 FSM。

4.3 UPDATE 和膨胀

UPDATE 通常需要同时保留旧版本和新版本,直到旧版本不再被需要。于是大量 UPDATE/DELETE 会造成:

  • 页面中出现 dead tuple;
  • FSM 可用空间增加,但文件大小未必下降;
  • 索引中也可能留下旧索引项,等待 VACUUM 清理;
  • 长事务阻止旧版本回收;
  • 表和索引的物理大小增长,这就是常说的膨胀。

普通 VACUUM 主要是让空间在关系内部复用,并不通常把文件截断到最小尺寸。要重写表并释放大量尾部空间,通常需要:

VACUUM FULL account;

VACUUM FULL 会重写表,通常需要更强的锁,并消耗额外磁盘空间。它不是日常替代普通 VACUUM 的命令。


五、HOT:为什么某些 UPDATE 不需要新增索引项

5.1 HOT 的条件

HOT 是 Heap-Only Tuple 的缩写。它的核心目标是:如果 UPDATE 没有修改任何索引列,并且同一 heap page 有足够空间,那么新版本可以放在同一页面中,索引不必为新版本新增索引项。

典型条件包括:

  1. UPDATE 没有修改被任何索引引用的列;
  2. 当前 heap page 有空间放置新 Tuple;
  3. 新版本可以形成同页的 HOT 更新链。

例如:

CREATE TABLE sensor (
    id       bigint PRIMARY KEY,
    reading  integer,
    note     text
);

UPDATE sensor
SET reading = reading + 1
WHERE id = 10;

如果 reading 没有被索引,并且页面有足够空间,那么可能形成:

索引项
  |
  v
Tuple A -> Tuple B -> Tuple C

索引仍指向链的起点,heap 层通过 HOT 链找到对当前快照可见的版本。

5.2 HOT 并不等于“没有膨胀”

HOT 只能减少索引项增长,不能让旧 Tuple 消失。页面中仍然会产生新版本,旧版本仍需要 VACUUM 清理。

此外:

  • 如果更新索引列,通常无法使用 HOT;
  • 如果页面没有足够空间,新版本必须放到其他页面;
  • 一旦 HOT 链过长,访问和清理成本仍然可能增加;
  • 页面填充策略、列宽、更新模式都会影响 HOT 机会。

所以,HOT 是减少写放大的机制,不是消除 MVCC 版本的机制。


六、TOAST:大字段如何离开主 heap page

6.1 为什么需要 TOAST

单个 Tuple 必须放在一个 heap page 内,而页面大小通常只有 8 KiB。即使允许一行很大,也不能简单地让一个 Tuple 跨越多个 heap page。

因此 PostgreSQL 为大字段提供 TOAST,名称来自 “The Oversized-Attribute Storage Technique”。

TOAST 主要解决三个问题:

  1. 大字段不能直接放入主 Tuple;
  2. 大字段可以被压缩;
  3. 大字段可以被拆成多个 chunk,存入独立的 TOAST 表。

6.2 TOAST 的物理对象

当表包含适合 TOAST 的变长字段时,系统可能为它关联一张内部 TOAST 表。该表通常包含类似以下逻辑结构:

chunk_id
chunk_seq
chunk_data

其中:

  • chunk_id 标识原始字段值;
  • chunk_seq 表示 chunk 顺序;
  • chunk_data 保存一段数据。

主 heap Tuple 中不再保存完整字段,而是保存一个 TOAST pointer,指向 TOAST 表中的数据。

TOAST 表本身也是 PostgreSQL 的关系,具有自己的 heap 文件、FSM 和 Visibility Map。TOAST chunk 也遵循 MVCC,主表 Tuple 的版本与 TOAST 数据之间存在版本生命周期关系。

6.3 压缩、外置和切片

对于可 TOAST 的变长类型,例如 textbytea、部分数组和 JSON 类型,PostgreSQL 会根据类型的存储策略决定如何处理:

  • 保持在主 Tuple 中;
  • 压缩后放在主 Tuple 中;
  • 压缩后外置;
  • 不压缩,直接外置;
  • 拆成多个 chunk。

字段是否 TOAST 化不由“类型名字”单独决定,还受以下因素影响:

  • 类型是否支持变长存储;
  • 列的存储策略;
  • 数据实际大小;
  • 压缩效果;
  • 当前 Tuple 剩余空间;
  • PostgreSQL 版本及编译时页面大小。

默认目标通常是让 Tuple 尽量保持在页面可接受的大小范围内。常见实现中,TOAST 触发阈值与页面大小相关,而不是一个适用于所有构建和版本的固定 SQL 保证。因此,不应简单断言“超过某个固定字节数一定进入 TOAST”。

可以用列级存储策略影响行为:

ALTER TABLE documents
    ALTER COLUMN body SET STORAGE EXTERNAL;

常见策略包括:

  • PLAIN:不允许压缩和外置;
  • EXTENDED:允许压缩和外置,通常是默认策略;
  • EXTERNAL:允许外置,但不要求压缩;
  • MAIN:倾向于先保留在主 Tuple 中,只在必要时外置。

这些策略是偏好,不是绝对命令。尤其是 MAIN 并不保证字段永远不外置。

6.4 TOAST 对 UPDATE 的影响

假设一行包含很大的 text 字段:

UPDATE documents
SET title = 'new title'
WHERE id = 1;

即使只修改 title,新的 heap Tuple 仍然是一个新的 MVCC 版本。对于未改变的 TOAST 值,PostgreSQL 可以在版本之间复用相同的外部 TOAST 值引用,而不是每次都重新写入全部 chunk;但这不表示 UPDATE 完全没有 TOAST 相关成本。

如果真正修改了大字段:

UPDATE documents
SET body = body || ' appended'
WHERE id = 1;

可能发生:

  1. 生成新的 heap Tuple;
  2. 对新字段压缩或外置;
  3. 写入新的 TOAST chunk;
  4. 旧 heap Tuple 和旧 TOAST chunk 保留到可回收;
  5. VACUUM 分别处理主表和 TOAST 表。

因此,TOAST 可以避免单个 heap Tuple 过大,却可能带来额外的随机访问、写放大和垃圾版本。

6.5 TOAST 与查询代价

执行如下查询:

SELECT id FROM documents WHERE id = 1;

如果没有读取 body,数据库通常不需要把完整 TOAST 数据取出。

而执行:

SELECT body FROM documents WHERE id = 1;

则可能需要:

  1. 读取主 Tuple;
  2. 发现字段是外部 TOAST pointer;
  3. 访问 TOAST 表;
  4. 按 chunk 顺序读取;
  5. 解压或重组原始值。

这也是“大字段只在需要时读取”的物理基础,但不能把它误解成所有情况下都完全零成本:执行计划、投影、函数调用和类型转换都可能触发字段解包。


七、FSM:数据库如何寻找可写页面

7.1 FSM 记录什么

FSM 是 Free Space Map,用来记录关系中每个页面的大致可用空间。

它回答的问题是:

哪些页面可能足够容纳这次 INSERT 或 UPDATE 产生的新 Tuple?

FSM 不记录:

  • 哪些 Tuple 对当前事务可见;
  • 哪些 Tuple 是死版本;
  • 哪些索引项已经失效;
  • 页面中每个 Tuple 的精确状态。

FSM 中保存的是空间类别或近似值,而不是对页面每次变化都精确同步后的字节数。页面实际可用空间发生变化后,FSM 可能暂时过时。

7.2 FSM 的工作路径

一次需要分配 heap 空间的操作大致如下:

INSERT/UPDATE
    |
    v
查询 FSM:寻找可能有足够空间的 page
    |
    +-- 找到候选 page --> 读取页面并再次确认实际空间
    |                         |
    |                         +-- 足够:写入
    |                         +-- 不足:继续寻找或扩展关系
    |
    +-- 没有候选 page --> 扩展关系文件

这里有一个重要的正确性边界:

FSM 不准确不会破坏数据正确性,只会影响空间利用率和寻址效率。

如果 FSM 错误地认为某页有空间,插入时会重新检查;如果 FSM 没有及时反映某页已释放空间,数据库可能选择扩展文件,而不是马上复用该页。

7.3 VACUUM 与 FSM

VACUUM 清理 dead tuple 后,会发现页面有更多可复用空间,并更新 FSM。

但以下三件事要区分:

  1. 页面内部空间可复用:由 dead tuple 清理产生;
  2. FSM 能找到这些空间:由 FSM 更新保证;
  3. 操作系统文件变小:通常需要关系重写或尾部截断。

因此:

VACUUM
不等于
立即缩小表文件

普通 VACUUM 主要让空间重新用于后续 INSERT/UPDATE。即使 pg_relation_size() 没有下降,FSM 也可能已经改善了后续空间复用。

FSM 是辅助结构,可以在需要时重建;它不是数据库正确性的唯一依据。相比之下,Visibility Map 直接影响某些查询优化,因此其持久化和恢复约束更严格。


八、Visibility Map:页面级可见性摘要

8.1 两个独立的位

Visibility Map,简称 VM,是与 heap 关系配套的页面级位图。它为每个 heap page 维护两个独立标志:

  1. all-visible bit
  2. all-frozen bit

它们不是“页面是否可见”的简单布尔值,而是两种页面级摘要。

all-visible

如果某个 heap page 被标记为 all-visible,意味着该页面中的所有 Tuple 对所有当前和未来的普通 MVCC 快照都可见,或者已经没有需要阻止这一结论的 Tuple。

这使得索引只读扫描可以避免访问 heap page 来逐 Tuple 检查可见性。

all-frozen

如果某个页面被标记为 all-frozen,表示该页面中的相关 Tuple 已经冻结,不再需要依赖旧事务 ID 来判断其创建可见性。

all-frozenall-visible 更强。一个页面可以:

all-visible = 1
all-frozen  = 0

含义是:当前所有 Tuple 对所有快照可见,但仍存在未冻结的事务 ID。

也可能:

all-visible = 1
all-frozen  = 1

这通常是 VACUUM 对页面进行冻结后形成的状态。

8.2 VM 与 VACUUM 的关系

VACUUM 扫描 heap page 时,通常会:

  1. 检查页面中的 Tuple;
  2. 清理已经无用的 dead tuple;
  3. 根据需要设置 hint bit;
  4. 判断页面是否满足 all-visible;
  5. 根据 Tuple 是否冻结判断 all-frozen;
  6. 更新 VM;
  7. 更新 FSM。

如果后来有修改操作触及该页面:

  • 旧版本可能不再满足 all-visible;
  • 页面对应的 all-visible bit 必须被清除;
  • 新版本可能包含尚未冻结的事务 ID,因此 all-frozen 也可能失效。

所以 VM 是动态的,不能把它理解成“VACUUM 设置后永久有效”。

8.3 VM 为什么影响索引只读扫描

假设有索引:

CREATE INDEX account_id_balance_idx
ON account(id) INCLUDE (balance);

查询:

SELECT balance
FROM account
WHERE id = 1;

如果执行计划选择 Index Only Scan,那么索引中已经有:

  • 搜索键 id
  • 覆盖列 balance

但 PostgreSQL 仍必须确认 heap 中对应 Tuple 对当前快照可见,因为普通 B-tree 索引项本身不保存完整的 MVCC 可见性信息。

执行逻辑近似为:

扫描索引
    |
    v
得到 heap page 编号
    |
    v
查询 VM
    |
    +-- all-visible = 1 --> 可以不读取 heap page
    |
    +-- all-visible = 0 --> 必须访问 heap page 检查 Tuple 可见性

因此,Index Only Scan 并不保证完全不访问 heap。它只有在相关 heap page 被标记为 all-visible 时,才真正能够省略 heap fetch。

可以通过执行计划观察:

EXPLAIN (ANALYZE, BUFFERS)
SELECT balance
FROM account
WHERE id = 1;

如果是 Index Only Scan,重点观察:

Heap Fetches: 0

较高的 Heap Fetches 往往表示:

  • 相关页面近期被修改;
  • VACUUM 尚未将页面标记为 all-visible;
  • 表存在持续更新;
  • autovacuum 跟不上;
  • 访问的数据页本身不适合长期保持 all-visible。

这不是单纯的“索引失效”,而是 heap 可见性状态导致的额外访问。


九、all-visible 与 all-frozen 的安全边界

9.1 为什么设置 VM 不能过早

假设一个 heap page 中刚插入了由事务 200 创建的 Tuple:

Tuple:
    xmin = 200

事务 200 尚未提交,或者某个快照尚未能看到它。此时不能设置 all-visible。

如果错误地设置,Index Only Scan 可能直接跳过 heap,而把本应不可见的 Tuple 当作可见。这会破坏 MVCC 正确性。

因此,设置 all-visible 必须满足比“当前 VACUUM 看起来没问题”更严格的条件。相关修改还必须遵守 WAL 与崩溃恢复顺序,防止数据库在崩溃恢复后出现“VM 说页面全可见,但 heap 页面内容尚未持久化”的矛盾。

9.2 清除 VM 可以保守,设置 VM 必须证明

VM 的安全原则可以概括为:

  • 清除 bit:保守地让查询多读一次 heap,性能下降但结果仍正确;
  • 设置 bit:必须确保页面状态已经满足相应可见性条件。

页面被修改后清除 all-visible,是一种安全的保守操作。若 VM 位因为崩溃或恢复丢失,查询会退化为访问 heap,但不会因此返回错误结果。

这也是 VM 和 FSM 的重要区别:

  • FSM 不准确主要影响空间查找;
  • VM 不准确若错误地“设置为可见”,可能影响结果正确性,因此设置路径受到更严格保护。

十、事务状态、hint bit 和冻结

10.1 Tuple header 不保存完整事务状态

Tuple 的 xminxmax 只是事务 ID。事务是否提交、是否中止等信息主要位于事务状态相关的系统结构中,而不是每个 Tuple 内部都复制一份完整状态。

第一次访问某个 Tuple 时,PostgreSQL 可能需要查询事务状态。确定结果后,可以把部分结论写入 Tuple header 的 hint bit,例如:

  • 创建事务已提交;
  • 删除事务已提交;
  • 创建事务已中止;
  • 删除事务已中止。

hint bit 的作用是减少以后重复读取事务状态的成本。

它与 WAL 的关系需要谨慎理解:hint bit 属于可重建的缓存性信息,通常不需要像逻辑数据变化那样单独形成语义 WAL;但页面最终写出时仍必须遵守数据页和 WAL 的持久化规则。具体是否产生额外 I/O,还受 full_page_writes、页面首次修改、检查点和数据校验等因素影响。

10.2 为什么需要冻结

事务 ID 的空间有限,不能无限增长。更重要的是,旧事务 ID 如果被误认为“未来事务”,会导致极老 Tuple 的可见性判断出现问题。

VACUUM 会对足够老的 Tuple 进行冻结,使其创建事务语义变成“对所有事务都已可见”。冻结后的 Tuple 不再依赖原始旧事务 ID 参与普通可见性判断。

冻结的核心目标是防止事务 ID 回卷问题。相关概念包括:

  • relfrozenxid:关系级冻结进度;
  • datfrozenxid:数据库级冻结进度;
  • autovacuum 的冻结触发参数;
  • 长事务、复制槽和准备事务对旧行版本及冻结推进的影响。

all-frozen bit 表示页面层面已经完成相应冻结条件,便于后续 VACUUM 跳过不需要再次扫描的页面。但它不意味着整个表或数据库都已经冻结。


十一、Heap、FSM、VM 和 WAL 的协作

可以把一次更新抽象为以下数据流:

客户端 UPDATE
    |
    v
执行器定位旧 Tuple
    |
    v
检查快照和锁
    |
    v
在 heap page 写入新 Tuple
    |
    +--> 旧 Tuple 设置 xmax / 更新链
    |
    +--> 相关页面清除 all-visible
    |
    +--> 生成 heap/index WAL
    |
    +--> 页面 LSN 前进
    |
    +--> FSM 可能变化
    |
    +--> 相关索引可能新增索引项

如果满足 HOT 条件:

heap page
    |
    +--> 旧 Tuple -> 新 Tuple
    +--> 索引通常不增加新项

如果不满足 HOT:

heap page
    |
    +--> 创建新 heap Tuple

index page
    |
    +--> 增加指向新 heap Tuple 的索引项

提交时,事务提交记录写入 WAL。其他事务在可见性判断中结合:

  • Tuple 的 xmin/xmax
  • 事务状态;
  • 自身快照;
  • hint bit;
  • 冻结状态;

判断应该看到哪个版本。

11.1 页面 LSN 和 WAL 先行

每个页面有 page LSN,用于标识该页面已经包含到哪个 WAL 位置对应的修改。崩溃恢复时,WAL 重放可以根据页面 LSN 判断是否需要重放某条记录。

WAL 的基本持久化原则是:

包含数据修改的 WAL
    必须先于对应数据页面持久化

这就是 WAL 先行写入。它保证即使数据页面尚未写回,恢复过程也可以通过 WAL 重建修改。

页面校验和则用于发现磁盘或 I/O 层面的页面损坏;它不替代 WAL,也不负责解释 Tuple 可见性。


十二、用工具观察真实页面

页面布局属于 PostgreSQL 内部实现,不是普通 SQL 接口承诺。用于诊断时可以使用 pageinspectpg_freespacemappg_visibility 等扩展,但必须明确:

  • 需要安装对应扩展;
  • 某些函数通常需要较高权限;
  • 输出字段和解释可能随版本变化;
  • 不应在生产环境随意修改内部页面;
  • get_raw_page() 直接读取页面,不能代替一致性 SQL 查询。

12.1 查看表文件路径

SELECT
    oid,
    relfilenode,
    pg_relation_filepath(oid::regclass) AS path
FROM pg_class
WHERE oid = 'account'::regclass;

预期结果类似:

 oid  | relfilenode |       path
------+-------------+-------------------
 ...  |      ...    | base/16384/...

pg_relation_filepath() 返回相对于数据目录的路径。不要根据文件名永久绑定表,因为重写操作可能改变 relfilenode。

12.2 安装页面检查扩展

CREATE EXTENSION pageinspect;

创建扩展需要相应数据库权限。然后获取第 0 页的原始内容:

SELECT *
FROM page_header(get_raw_page('account', 0));

page_header() 可以帮助观察:

  • pd_lsn
  • pd_lower
  • pd_upper
  • pd_special
  • 页面大小;
  • 页面版本。

查看 heap page 中的 line pointer 和 Tuple 元信息:

SELECT
    lp,
    lp_off,
    lp_flags,
    lp_len,
    t_xmin,
    t_xmax,
    t_ctid,
    t_infomask,
    t_infomask2,
    t_hoff
FROM heap_page_items(get_raw_page('account', 0));

可能看到类似:

 lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_ctid | ...
----+--------+----------+--------+--------+--------+---------+-----
  1 |   8056 |        1 |     40 |    100 |    101 | (0,2)  | ...
  2 |   8016 |        1 |     40 |    101 |      0 | (0,2)  | ...

这类输出可以帮助理解:

  • 同一页面中存在旧版本和新版本;
  • 旧版本的 t_xmax 指向更新事务;
  • t_ctid 可能构成更新链;
  • Tuple 物理偏移和 line pointer 编号是两个不同概念。

但不要把某一次实验中看到的具体数值当成固定格式。事务 ID、Tuple 长度、偏移和标志都会受插入顺序、版本、对齐、页面已有内容影响。

12.3 查看 FSM

安装扩展:

CREATE EXTENSION pg_freespacemap;

查看表中页面的可用空间估计:

SELECT *
FROM pg_freespace('account')
LIMIT 20;

输出通常包含:

 blkno | avail
-------+-------
     0 |   ...
     1 |   ...

其中:

  • blkno 是 heap block 编号;
  • avail 是 FSM 报告的可用空间估计。

它不是逐字节实时测量。刚执行过 DELETE 但尚未 VACUUM 时,FSM 可能还没有反映所有可复用空间。

12.4 查看 Visibility Map

可以安装:

CREATE EXTENSION pg_visibility;

检查页面可见性:

SELECT *
FROM pg_visibility('account')
LIMIT 20;

不同版本中的返回列可能存在差异,但通常可以观察:

  • 页面是否 all-visible;
  • 页面是否 all-frozen;
  • 页面是否存在可见性相关问题。

也可以获取概要信息:

SELECT *
FROM pg_visibility_map_summary('account');

此类函数适合诊断“为什么 Index Only Scan 仍有大量 Heap Fetches”或“为什么 VACUUM 需要反复扫描某些页面”。


十三、一个可重复的观察实验

下面的实验用于观察 UPDATE 产生多个 Tuple 版本。它的结果受页面布局和 PostgreSQL 版本影响,因此重点是验证关系,而不是追求固定数字。

13.1 创建测试表

CREATE TABLE heap_demo (
    id   integer PRIMARY KEY,
    body text
);

INSERT INTO heap_demo VALUES (1, 'v1');

先记下物理位置:

SELECT id, ctid, xmin, xmax
FROM heap_demo;

预期关系类似:

 id | ctid  | xmin | xmax
----+-------+------+------
  1 | (0,1) |  ... |    0

这里 ctidxmin 的具体值不应写死。

13.2 更新并再次观察

UPDATE heap_demo
SET body = 'v2'
WHERE id = 1;

SELECT id, ctid, xmin, xmax
FROM heap_demo;

对普通可见查询,通常只会返回最新版本:

 id | ctid  | xmin | xmax
----+-------+------+------
  1 | (0,2) |  ... |    0

但页面检查可能发现页面中存在两个 line pointer:

SELECT
    lp,
    lp_flags,
    t_xmin,
    t_xmax,
    t_ctid
FROM heap_page_items(get_raw_page('heap_demo', 0));

概念上可能是:

lp | t_xmin | t_xmax | t_ctid
---+--------+--------+-------
 1 |   old  |   new  | (0,2)
 2 |   new  |     0  | (0,2)

然后运行:

VACUUM heap_demo;

如果没有长事务阻止清理,旧版本可能被标记为可回收,页面可用空间信息也会更新。VACUUM 是否立刻整理成某一种具体 line pointer 形态,属于实现细节,不应依赖其输出格式。

13.3 观察 VM 对 Index Only Scan 的影响

建立覆盖索引:

CREATE INDEX heap_demo_id_body_idx
ON heap_demo(id) INCLUDE (body);

查看计划:

EXPLAIN (ANALYZE, BUFFERS)
SELECT body
FROM heap_demo
WHERE id = 1;

如果计划选择 Index Only Scan,输出中可能出现:

Index Only Scan using heap_demo_id_body_idx ...
  Heap Fetches: 0

更新表后再次查询:

UPDATE heap_demo
SET body = 'v3'
WHERE id = 1;

EXPLAIN (ANALYZE, BUFFERS)
SELECT body
FROM heap_demo
WHERE id = 1;

此时相关页面的 all-visible 标志通常已经被清除,因此可能出现:

Heap Fetches: 1

再次执行:

VACUUM heap_demo;

随后如果页面满足 all-visible 条件,Heap Fetches 可能重新下降。这里的关键因果关系是:

UPDATE
  -> heap page 不再能直接声明 all-visible
  -> Index Only Scan 需要访问 heap
  -> VACUUM 确认页面状态后重新设置 VM
  -> 后续查询可能再次省略 heap fetch

这不是保证每次实验都严格得到相同计划的测试。优化器还会考虑表大小、代价参数、统计信息和缓存状态。


十四、常见误解和对应的失败表现

14.1 “UPDATE 是原地覆盖”

错误。PostgreSQL 的普通 UPDATE 通常创建新 Tuple 版本。结果是:

  • old version 保留一段时间;
  • 可能产生新索引项;
  • 可能形成 HOT 链;
  • 需要 VACUUM 清理。

如果把 UPDATE 当作原地修改,就无法解释表膨胀、长事务影响和 Heap Fetches 增加。

14.2 “删除后空间马上回到操作系统”

错误。DELETE 通常只改变事务可见性状态。空间要先经过:

  1. 删除事务提交;
  2. 旧版本对所有相关快照都不再有用;
  3. VACUUM 清理;
  4. 页面空间进入可复用状态;
  5. FSM 记录该空间。

即使以上步骤完成,文件通常也不会整体缩小。

14.3 “FSM 是精确空闲空间表”

错误。FSM 是近似摘要。它的错误通常导致:

  • 额外扩展;
  • 选择候选页面失败后重试;
  • 空间利用率暂时下降。

它不会被用来替代写入时对实际页面的检查。

14.4 “all-visible 表示页面没有 Tuple”

错误。all-visible 页面可以包含许多正常 Tuple。它表示这些 Tuple 对所有普通快照都可见,而不是页面为空。

同样:

all-visible = 1

不等于:

all-frozen = 1

后者还要求页面中的相关 Tuple 已冻结。

14.5 “Index Only Scan 一定不访问 heap”

错误。Index Only Scan 只是说明索引可能提供所需列;是否能跳过 heap 取决于 VM。

判断是否真正跳过 heap,应看:

Heap Fetches

而不是只看计划节点名称。

14.6 “TOAST 就是把字段存到另一个文件”

不够准确。TOAST 涉及:

  • 变长值的内部表示;
  • 压缩;
  • 外置 pointer;
  • TOAST 表;
  • chunk;
  • chunk 的 MVCC 生命周期。

大字段通常由 TOAST 表和主表 Tuple 共同表示,而不是简单地把一列复制到一个普通外部文件中。

14.7 “看到 ctid 就可以用它永久更新行”

不可靠。ctid 会因 UPDATE 和表重写变化。它适合:

  • 页面级诊断;
  • 临时定位;
  • 分析版本链。

它不适合替代主键或业务唯一标识。


十五、如何从现象反推存储状态

15.1 表大小持续增长

可能的因果链包括:

频繁 UPDATE/DELETE
    -> 产生旧 Tuple
    -> VACUUM 不及时或无法清理
    -> 页面复用不足
    -> 关系文件继续增长

需要进一步区分:

  • dead tuple 是否很多;
  • 是否存在长事务;
  • 是否存在复制槽阻止 xmin 推进;
  • autovacuum 是否频繁运行;
  • 索引是否比表膨胀更严重;
  • TOAST 表是否占据主要空间。

可以从这些视图开始:

SELECT
    relname,
    n_live_tup,
    n_dead_tup,
    last_vacuum,
    last_autovacuum,
    vacuum_count,
    autovacuum_count
FROM pg_stat_user_tables
WHERE relname IN ('account', 'documents');

这些统计值是统计信息,不是逐页精确结果。

15.2 Index Only Scan 但 Heap Fetches 很高

推理路径是:

  1. 索引已经覆盖查询列;
  2. 但对应 heap page 的 all-visible bit 不稳定或未设置;
  3. 查询必须访问 heap 验证 Tuple 可见性;
  4. 近期 UPDATE、长时间未 VACUUM 或高更新率都可能导致该现象。

此时应结合:

  • EXPLAIN (ANALYZE, BUFFERS)
  • pg_visibility
  • autovacuum 日志;
  • 表的更新频率;
  • n_dead_tup
  • 长事务状态;

而不是简单重复创建索引。

15.3 磁盘没有下降,但 INSERT 仍能复用空间

这通常是正常现象:

VACUUM 清理旧版本
    -> 页面内部产生空闲空间
    -> FSM 记录空间
    -> 新 INSERT 复用页面
    -> 文件总大小不变

文件大小不下降不等于 VACUUM 没有工作。可以通过 FSM、dead tuple 统计和实际写入行为验证空间是否被复用。

15.4 单列很大但主表没有按比例变大

可能是 TOAST 生效。应分别观察主表和 TOAST 表的大小:

SELECT
    c.relname AS table_name,
    pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
    pg_size_pretty(pg_relation_size(c.oid)) AS main_size,
    pg_size_pretty(pg_indexes_size(c.oid)) AS index_size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'documents';

TOAST 表是内部关系,名称通常带有系统生成的标识。也可以通过 pg_class.reltoastrelid 找到关联对象:

SELECT
    c.oid::regclass AS table_name,
    c.reltoastrelid::regclass AS toast_table
FROM pg_class c
WHERE c.oid = 'documents'::regclass;

如果 TOAST 表更新频繁,它也可能产生自己的 dead tuple 和膨胀,不能只检查主表。


十六、这些机制如何共同决定生产行为

把整个存储过程串起来,可以得到一个更完整的模型:

逻辑行
  |
  v
一个或多个 MVCC Tuple 版本
  |
  +--> 过大字段由 TOAST 压缩/外置/chunk 化
  |
  v
Heap page
  |
  +--> line pointer 定位 Tuple
  +--> 页面空间在 FSM 中留下近似摘要
  +--> 页面可见性在 VM 中留下页面级摘要
  +--> 修改通过 WAL 持久化和恢复
  |
  v
VACUUM
  |
  +--> 清理无用 Tuple
  +--> 推进冻结
  +--> 更新 FSM
  +--> 设置或清除 VM 位
  +--> 帮助 Index Only Scan

其中最容易混淆的是三种“状态”:

对象 记录的主要问题 不负责什么
Tuple header 这个版本由谁创建、由谁删除、物理链在哪里 不保存完整事务历史
FSM 哪些页面可能有可用空间 不判断 Tuple 可见性
Visibility Map 哪些 heap page 可跳过逐 Tuple 可见性检查 不告诉 INSERT 应该写哪个页面

最终,PostgreSQL 的存储效率不是由某一个组件单独决定的:

  • heap page 决定物理布局和局部空间;
  • Tuple 和事务 ID 实现 MVCC;
  • HOT 决定部分 UPDATE 能否避免索引写入;
  • TOAST 解决大字段无法放入单页的问题;
  • FSM 帮助寻找可复用页面;
  • VM 帮助 VACUUM 跳过稳定页面,并让 Index Only Scan 减少 heap 访问;
  • WAL 和页面 LSN 保证这些状态在崩溃后能够恢复到一致结果。

当表出现膨胀、Index Only Scan 仍频繁回表、TOAST 表异常增长或长事务阻止清理时,应该沿着“Tuple 版本 → heap page → FSM/VM → VACUUM → WAL 与事务边界”的链条诊断,而不是只观察表的逻辑行数或文件总大小。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。