数据库基础体系 · 第 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、索引只读扫描和存储诊断的基础。
一、从逻辑行到物理存储
先区分三个经常被混用的概念:
- 逻辑行:用户通过 SQL 看到的记录;
- Tuple:表中某个逻辑行在某一时刻的物理版本;
- 页面:保存多个 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 的字段,而是:
- 创建一个新的 Tuple;
- 将旧 Tuple 标记为被新版本替代;
- 让不同快照分别看到旧版本或新版本;
- 等旧版本不再被任何事务需要后,由 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 pointer 或 item pointer。它记录:
- Tuple 在页面中的偏移;
- Tuple 长度;
- 当前状态。
因此,一个指向表中物理行版本的位置通常写作:
(block number, offset number)
例如:
(42, 7)
含义是:
- 第 42 个页面;
- 页面中的第 7 个 line pointer。
这个位置常见于 Tuple 的 ctid 字段。
需要注意,ctid 不是永久稳定的行 ID:
- UPDATE 往往创建新 Tuple,
ctid会变化; VACUUM FULL、CLUSTER、某些表重写操作会重排物理位置;- 应用程序不应把
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_infomask与t_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 的主要路径可以概括为:
- 访问 FSM,寻找可容纳新 Tuple 的 heap page;
- 如果没有合适页面,扩展关系文件;
- 在目标 page 中分配 line pointer;
- 在页面末尾分配 Tuple 空间;
- 写入 Tuple header 和列数据;
- 生成 WAL;
- 根据需要更新页面 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 有足够空间,那么新版本可以放在同一页面中,索引不必为新版本新增索引项。
典型条件包括:
- UPDATE 没有修改被任何索引引用的列;
- 当前 heap page 有空间放置新 Tuple;
- 新版本可以形成同页的 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 主要解决三个问题:
- 大字段不能直接放入主 Tuple;
- 大字段可以被压缩;
- 大字段可以被拆成多个 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 的变长类型,例如 text、bytea、部分数组和 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;
可能发生:
- 生成新的 heap Tuple;
- 对新字段压缩或外置;
- 写入新的 TOAST chunk;
- 旧 heap Tuple 和旧 TOAST chunk 保留到可回收;
- VACUUM 分别处理主表和 TOAST 表。
因此,TOAST 可以避免单个 heap Tuple 过大,却可能带来额外的随机访问、写放大和垃圾版本。
6.5 TOAST 与查询代价
执行如下查询:
SELECT id FROM documents WHERE id = 1;
如果没有读取 body,数据库通常不需要把完整 TOAST 数据取出。
而执行:
SELECT body FROM documents WHERE id = 1;
则可能需要:
- 读取主 Tuple;
- 发现字段是外部 TOAST pointer;
- 访问 TOAST 表;
- 按 chunk 顺序读取;
- 解压或重组原始值。
这也是“大字段只在需要时读取”的物理基础,但不能把它误解成所有情况下都完全零成本:执行计划、投影、函数调用和类型转换都可能触发字段解包。
七、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。
但以下三件事要区分:
- 页面内部空间可复用:由 dead tuple 清理产生;
- FSM 能找到这些空间:由 FSM 更新保证;
- 操作系统文件变小:通常需要关系重写或尾部截断。
因此:
VACUUM
不等于
立即缩小表文件
普通 VACUUM 主要让空间重新用于后续 INSERT/UPDATE。即使 pg_relation_size() 没有下降,FSM 也可能已经改善了后续空间复用。
FSM 是辅助结构,可以在需要时重建;它不是数据库正确性的唯一依据。相比之下,Visibility Map 直接影响某些查询优化,因此其持久化和恢复约束更严格。
八、Visibility Map:页面级可见性摘要
8.1 两个独立的位
Visibility Map,简称 VM,是与 heap 关系配套的页面级位图。它为每个 heap page 维护两个独立标志:
- all-visible bit
- all-frozen bit
它们不是“页面是否可见”的简单布尔值,而是两种页面级摘要。
all-visible
如果某个 heap page 被标记为 all-visible,意味着该页面中的所有 Tuple 对所有当前和未来的普通 MVCC 快照都可见,或者已经没有需要阻止这一结论的 Tuple。
这使得索引只读扫描可以避免访问 heap page 来逐 Tuple 检查可见性。
all-frozen
如果某个页面被标记为 all-frozen,表示该页面中的相关 Tuple 已经冻结,不再需要依赖旧事务 ID 来判断其创建可见性。
all-frozen 比 all-visible 更强。一个页面可以:
all-visible = 1
all-frozen = 0
含义是:当前所有 Tuple 对所有快照可见,但仍存在未冻结的事务 ID。
也可能:
all-visible = 1
all-frozen = 1
这通常是 VACUUM 对页面进行冻结后形成的状态。
8.2 VM 与 VACUUM 的关系
VACUUM 扫描 heap page 时,通常会:
- 检查页面中的 Tuple;
- 清理已经无用的 dead tuple;
- 根据需要设置 hint bit;
- 判断页面是否满足 all-visible;
- 根据 Tuple 是否冻结判断 all-frozen;
- 更新 VM;
- 更新 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 的 xmin 和 xmax 只是事务 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 接口承诺。用于诊断时可以使用 pageinspect、pg_freespacemap 和 pg_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
这里 ctid、xmin 的具体值不应写死。
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 通常只改变事务可见性状态。空间要先经过:
- 删除事务提交;
- 旧版本对所有相关快照都不再有用;
- VACUUM 清理;
- 页面空间进入可复用状态;
- 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 很高
推理路径是:
- 索引已经覆盖查询列;
- 但对应 heap page 的 all-visible bit 不稳定或未设置;
- 查询必须访问 heap 验证 Tuple 可见性;
- 近期 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 与事务边界”的链条诊断,而不是只观察表的逻辑行数或文件总大小。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:PostgreSQL 高级 SQL:LATERAL、递归 CTE、窗口、数组和范围
- 下一篇:PostgreSQL WAL 与 Checkpoint:提交、崩溃恢复、归档和写入性能
- 延伸:PostgreSQL 架构全景:进程模型、共享内存、WAL 与存储布局
- 延伸:PostgreSQL MVCC 与 Vacuum:Tuple 可见性、膨胀、冻结和诊断
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论