二:行记录结构深度解析
PostgreSQL 行记录的设计哲学是将 MVCC 完全内嵌在每一行,以 Append-Only 的方式写入数据,用 xmin/xmax/ctid 在行头部维护完整的版本信息。特性原因COMMIT 极快只写 CLOG 的 2 bitROLLBACK 极快只写 CLOG,不回滚数据页读不阻塞写读操作找旧版本,写操作写新版本,互不干扰需要 VACUUMAppend-Only 导致旧版本堆积,必须定期
PostgreSQL 行记录结构深度解析
本文从存储引擎视角系统拆解 PostgreSQL 的行记录(Tuple)结构,涵盖物理布局、插入定位机制、MVCC 多版本控制、索引关联,以及与 MySQL InnoDB 的架构对比,帮助你真正理解"一行数据"在 PostgreSQL 内部是怎么存活的。
1. 基本概念:行、元组、版本
在 PostgreSQL 中,"行"在不同语境下有不同的叫法:
- Row(行):SQL 层面的逻辑概念,你
INSERT一条记录就是一行。 - Tuple(元组):存储引擎层面的物理概念,源自关系理论。一个 Tuple 是一行在某个时刻的物理版本。
- HeapTuple:存放在堆表(Heap)文件中的元组,区别于索引中的 IndexTuple。
三者的关键区别在于:一个逻辑行可能对应多个物理 Tuple。
PostgreSQL 使用 Append-Only 的 MVCC 机制:UPDATE 不会原地修改旧数据,而是写入一个新 Tuple,旧 Tuple 打上"删除"标记保留原位。因此,同一逻辑行在任意时刻可能在页面里同时存在多个版本的 Tuple,它们通过 ctid 字段串联成版本链。
2. 页面(Page)结构总览
PostgreSQL 所有数据文件(无论是堆表还是索引)都以 8KB 的页面(Page) 为单位进行读写,这是 I/O 的最小单元。
一个页面从低地址到高地址的布局如下:
+---------------------------+ ← 地址 0
| 页头 PageHeaderData | 固定 24 字节
+---------------------------+
| ItemId[1] | ↓ 槽位数组,每项 4 字节,向高地址增长
| ItemId[2] |
| ItemId[3] |
| ... |
+---------------------------+ ← lower(下一个槽从这里开始)
| |
| 空 闲 空 间 |
| |
+---------------------------+ ← upper(下一个 tuple 从这里开始)
| Tuple N(最新插入) | ↑ tuple 区,向低地址增长
| ... |
| Tuple 2 |
| Tuple 1(最早插入) |
+---------------------------+
| Special 空间 | 堆表页 = 0 字节;B-tree 索引页存兄弟页号等
+---------------------------+ ← 地址 8192
页头(PageHeaderData,24 字节) 记录:
pd_lsn:该页最后一次修改对应的 WAL 日志序列号,用于崩溃恢复pd_checksum:页面校验和(需开启data_checksums)pd_lower:ItemId 数组末尾的偏移量pd_upper:tuple 区起始的偏移量pd_special:Special 区起始的偏移量pd_flags:标志位(是否有空闲槽、是否全可见等)
pd_upper - pd_lower 就是当前页面的可用空闲空间,FSM(空闲空间映射文件)汇总了每个页面的这个值,用于快速找到可以容纳新行的页面。
槽位(ItemId)机制
ItemId 数组是页面的"目录",每个 ItemId 占 4 字节,记录三个字段:
| 字段 | 位宽 | 说明 |
|---|---|---|
lp_off |
15 bit | tuple 在页内的起始字节偏移 |
lp_flags |
2 bit | 状态:unused / normal / redirect / dead |
lp_len |
15 bit | tuple 的字节长度 |
槽位有四种状态:
- unused:从未使用过
- normal:正常,指向一个有效 tuple
- redirect:HOT 更新时使用,指向同页另一个槽
- dead:tuple 已被 VACUUM 物理清除,槽号保留等待复用
TID(Tuple Identifier) 的格式是 (页号, 槽号),例如 (3, 2) 表示第 3 页第 2 个槽。外部索引持有的正是 TID,而不是 tuple 的物理偏移。这层间接寻址的价值在于:VACUUM 在页内移动 tuple 做碎片整理时,只需修改 ItemId 里的偏移值,所有指向该 tuple 的索引引用(TID 里只有槽号)都不需要变。
3. 行记录(Heap Tuple)的完整字段
每个 HeapTuple = HeapTupleHeader(头部) + 用户数据。
头部最小 23 字节,因 null bitmap 和对齐填充通常更大。
3.1 头部字段详解
HeapTupleHeader 布局:
0 4 8 14 18 19 23
+-------+-------+-------+-------+------+------+
| xmin | xmax | ctid | infom | hoff | bits |
+-------+-------+-------+-------+------+------+
4字节 4字节 6字节 4字节 1字节 可变
t_xmin(4 字节)
创建这个 tuple 版本的事务 XID(Transaction ID)。
INSERT时:填入当前事务的 XID- 该值在此 tuple 的整个生命周期内不会改变
- 可见性判断:若 xmin 事务已提交,且在当前快照的可见范围内,则这个版本对当前事务可见
t_xmax(4 字节)
删除或覆盖这个 tuple 版本的事务 XID。
- 行刚插入时:
xmax = 0,同时 infomask 中的XMAX_INVALID位被置 1,表示"尚未删除" DELETE时:写入执行删除的事务 XID,清除XMAX_INVALIDUPDATE时:旧版本的 xmax 写入更新事务的 XID- 事务进行中,xmax 也用作行级锁的标记,其他事务看到非 0 xmax 就知道有并发修改
t_ctid(6 字节)
格式同 TID:(页号 uint32, 槽号 uint16)。
- 对于最新版本的 tuple:ctid 指向自身,如
(0, 1)指向第 0 页槽 1 UPDATE发生后:旧版本的 ctid 被改写为新版本的 TID,形成版本链:旧 → 新 → 更新- 通过 ctid 链可以从任意旧版本找到最新版本,这在 HOT 更新优化中尤为关键
t_infomask 和 t_infomask2(共 4 字节)
32 个状态位,最重要的称为提示位(Hint Bits):
| 掩码 | 名称 | 含义 |
|---|---|---|
0x0100 |
HEAP_XMIN_COMMITTED |
xmin 事务已提交(提示位) |
0x0200 |
HEAP_XMIN_INVALID |
xmin 事务已中止或无效 |
0x0400 |
HEAP_XMAX_COMMITTED |
xmax 事务已提交 |
0x0800 |
HEAP_XMAX_INVALID |
xmax 无效(行未被删除) |
0x0010 |
HEAP_HASNULL |
存在 NULL 列,有 null bitmap |
0x0020 |
HEAP_HASVARWIDTH |
存在变长列 |
0x4000 |
HEAP_UPDATED |
此版本由 UPDATE 产生(非首次插入) |
提示位的核心价值:缓存事务状态,避免重复查询 CLOG。
每次判断 tuple 可见性时,若没有提示位,需要去 CLOG(提交日志,位于 PGDATA/pg_xact/)查询事务是否已提交。代价较高。一旦某个进程确认了事务状态,就将结论写回 infomask,后续所有访问直接读 bit 即可。
这带来一个有趣的副作用:只读 SELECT 也可能写脏页(只改了 infomask),这是 PostgreSQL 特有的行为。
t_hoff(1 字节)
头部的总字节长度,用户数据从 tuple_base_addr + t_hoff 处开始。在有 null bitmap 和对齐填充的情况下,hoff 大于 23。
null bitmap(可变长度,按需分配)
仅当 HEAP_HASNULL 被设置时存在。每列占 1 bit,bit=1 表示该列为 NULL。长度 = ⌈列数/8⌉ 字节,之后再做 MAXALIGN(通常 8 字节)对齐补齐,得到最终 t_hoff。
若不存在 null bitmap,PostgreSQL 假设所有列均非 NULL,访问效率更高。
3.2 用户数据区
从 t_hoff 偏移开始,各列值按建表时的列顺序依次排列。
每个列值在内部以 Datum 表示:
- 定长类型(int、bool、float 等):直接存值,按类型的对齐要求对齐
- 变长类型(text、bytea、varchar 等):有一个 1~4 字节的
varlena头,记录长度,后跟实际数据 - TOAST 指针:若值被外部化存储,这里是一个 18 字节的指针,真实数据在 TOAST 表中
4. 数据对齐与字段顺序的影响
PostgreSQL 要求每个列值按其类型的 alignment 要求对齐,列值之间可能插入 padding 字节:
| 类型 | 大小 | 对齐要求 |
|---|---|---|
bool / char |
1 字节 | 1 字节 |
int2 / smallint |
2 字节 | 2 字节 |
int4 / int |
4 字节 | 4 字节 |
int8 / float8 |
8 字节 | 8 字节(64位系统) |
text / varchar |
变长 | 4 字节(varlena 头) |
字段顺序直接影响每行的物理大小,这是一个常被忽视的优化点:
-- 差的设计:bool-int-bool-int,每个 bool 后面补 3 字节 padding
-- 实际大小:23字节头 + 1 + 3(pad) + 4 + 1 + 3(pad) + 4 = 39字节
CREATE TABLE bad_order (
flag1 bool,
amount int,
flag2 bool,
count int
);
-- 好的设计:int-int-bool-bool,两个 bool 紧挨,只需 1 字节 padding
-- 实际大小:23字节头 + 4 + 4 + 1 + 1 + 1(pad) = 34字节
CREATE TABLE good_order (
amount int,
count int,
flag1 bool,
flag2 bool
);
对一张 5000 万行的表,每行节省 5 字节意味着节省约 238MB 存储,同时减少磁盘 I/O,提升顺序扫描性能。
最佳实践:将宽的定长列(int8、float8)放前面,窄列(bool、char)放后面,变长列(text)最后。
5. 插入一行:从 SQL 到物理位置的全过程
BEGIN;
INSERT INTO users (name, age) VALUES ('Alice', 30);
COMMIT;
第一步:事务分配 XID
只读事务使用虚拟 XID(由 backend_id + 序列号组成,不写磁盘),一旦执行写操作,从全局 XID 计数器分配一个真实 XID(32 位整数,全局单调递增)。
第二步:查询 FSM 找目标页
PostgreSQL 不会顺序遍历所有页面寻找空闲空间,而是查询 FSM(Free Space Map)文件(relfilenode_fsm)。
FSM 以 B 树形式组织,汇总了每个页面当前可用空间的近似值。查询 FSM 得到一个有足够空间容纳新 tuple 的页号,通常是 O(1) 操作。
若 FSM 找不到合适的页面(全满),则扩展文件,追加一个新的空页。
第三步:将目标页载入 Buffer Cache
通过页号计算文件偏移(页号 × 8192),若该页已在 shared_buffers(缓冲区缓存)中,直接使用;否则从磁盘读入缓存,缓存满时按 Clock Sweep 算法淘汰旧页。
第四步:在页面内分配空间
在目标页内,pd_upper 向低地址移动 tuple_size 字节,为新 tuple 腾出空间;pd_lower 向高地址移动 4 字节,为新 ItemId 腾出空间。
若页面有 dead 状态的旧槽位,优先复用该槽号;否则在 ItemId 数组末尾新增一个槽。
第五步:写入 tuple 头部和数据
填充 HeapTupleHeader:
t_xmin = 当前事务 XID(如 776)
t_xmax = 0,且 XMAX_INVALID = 1
t_ctid = (当前页号, 当前槽号),指向自身
infomask = XMAX_INVALID | HEAP_HASVARWIDTH 等
t_hoff = 计算后的头部长度
之后按列顺序写入用户数据,注意各列的对齐填充。
第六步:维护索引
对表上每一个索引,插入一条对应的 IndexTuple,内容为 (key值, TID),TID 指向刚才分配的 (页号, 槽号)。
第七步:写 WAL
将上述所有修改(页面的变化)写入 WAL(Write-Ahead Log),确保在 COMMIT 之前日志已持久化到磁盘。这保证了即使在 COMMIT 之后立即断电,重启时也能通过重放 WAL 恢复数据。
第八步:COMMIT
在 CLOG(PGDATA/pg_xact/)中将此事务 XID 标记为已提交(只写 2 个 bit)。此时页面内的 tuple 的 XMIN_COMMITTED 提示位尚未设置,等下一个事务访问该页时才会写回。
整个过程中,COMMIT 本身极快,因为它只写 CLOG 中的 2 bit,不修改任何数据页。
6. MVCC 与行记录的关系
PostgreSQL 的 MVCC(多版本并发控制)完全内嵌在每个行记录的头部,通过 xmin、xmax、infomask 三个字段实现,无需独立的 Undo Log 文件。
6.1 可见性判断逻辑
当一个事务读取某个 tuple 时,需要判断该 tuple 对当前事务是否可见,逻辑如下:
判断 tuple 是否可见:
1. 检查 infomask 提示位
- XMIN_COMMITTED 已设 → xmin 事务已提交,继续判断 xmax
- XMIN_INVALID 已设 → xmin 事务已中止,此 tuple 不可见,退出
2. 提示位未设,查 ProcArray(共享内存中的活跃事务列表)
- xmin 仍在活跃列表 → 事务未结束,tuple 不可见,退出
- xmin 不在活跃列表 → 事务已结束,继续
3. 查 CLOG 确认 xmin 的最终状态
- committed → 写回 XMIN_COMMITTED 提示位,继续
- aborted → 写回 XMIN_INVALID 提示位,不可见,退出
4. xmin 可见,判断 xmax
- XMAX_INVALID = 1 或 xmax = 0 → 未删除,tuple 可见
- XMAX_COMMITTED = 1 → xmax 事务已提交,tuple 已被删除,不可见
- xmax 在活跃列表 → 删除事务未提交,tuple 对当前事务仍可见
6.2 快照隔离
不同隔离级别下,"可见"的定义不同:
- Read Committed:每条 SQL 语句开始时获取快照,看到的是语句开始时已提交的所有修改
- Repeatable Read / Serializable:事务开始时获取一次快照,整个事务期间看到固定的数据视图
快照的本质是一个 XID 列表,记录快照生成时所有活跃事务的 XID。可见性判断时,只有 xmin < 快照生成时的 xmax(全局最大已分配 XID)且不在活跃列表中的事务,才被认为已提交。
6.3 xmin / xmax 的版本控制示意
-- 初始状态(XID=100 插入)
-- xmin=100 xmax=0(invalid) ctid=(0,1) data='Alice'
-- XID=200 执行 UPDATE(name 改为 Alice2)
-- 旧 tuple:xmin=100 xmax=200 ctid=(0,2) data='Alice' ← 版本1,被标记删除
-- 新 tuple:xmin=200 xmax=0 ctid=(0,2) data='Alice2' ← 版本2,当前有效
-- XID=150 的事务(快照在 XID=200 之前获取)仍能读到 'Alice'
-- XID=300 的事务只能读到 'Alice2'
7. 索引与行记录的关系
7.1 索引的物理结构
PostgreSQL 的索引是独立于堆表的单独文件。以 B-tree 索引为例:
- 内部节点(非叶子):存储导航键(
key值 + 子页号),子页号是索引文件内部的页码,用于路由 - 叶子节点:存储
(key值, TID),TID 指向堆表中对应 tuple 的物理位置
IndexTuple 极度精简,没有 xmin、xmax、ctid 等 MVCC 字段,不做版本控制。
7.2 索引如何引用行记录
B-tree 索引文件 堆表文件
+--------------------+ +---------------------------+
| Root | | Page 0 |
| key='Charlie' | | Slot1: Charlie (xmin=100)|
+--------------------+ | Slot2: Alice (xmin=101)|
| 左叶子 | | Slot3: Bob (xmin=102)|
| 'Alice'→TID(0,2) |──TID(0,2)──→ Page 0, Slot 2 |
| 'Bob' →TID(0,3) |──TID(0,3)──→ Page 0, Slot 3 |
| 'Charlie'→TID(0,1)|──TID(0,1)──→ Page 0, Slot 1 |
+--------------------+ +---------------------------+
一次索引查找的完整路径:
- 从 Root 页读取导航键,二分查找确定子页号
- 重复第 1 步,直到到达叶子层(每层一次 I/O)
- 在叶子页内二分,找到目标 key,拿到 TID
- 用 TID 定位堆表页面,通过 ItemId 找到 tuple 偏移,读出完整数据(回表/heap fetch)
- 对读到的 tuple 做可见性检查(判断 xmin/xmax)
树高 3 层(覆盖约 6400 万条记录)的查询只需 4 次 I/O(3层索引 + 1次回表),上层节点因访问频繁通常常驻 shared_buffers,实际磁盘 I/O 往往只有 1~2 次。
7.3 索引与 MVCC 的矛盾
索引没有版本信息,但堆表里可能同时存在同一行的多个版本(新旧 tuple)。这导致:
- UPDATE 后,旧版本的索引条目(指向旧 tuple)和新版本的索引条目(指向新 tuple)会同时存在于索引中
- 查询扫描索引时,两个条目都会被访问,回堆后通过 xmin/xmax 判断可见性,过滤掉不可见的旧版本
- 只有 VACUUM 确认无任何活跃快照需要旧版本后,才删除旧索引条目
7.4 Index Only Scan 与可见性映射
正常的索引扫描需要回堆检查可见性,但如果查询的所有列都在索引中(覆盖索引),理论上不需要回堆。
PostgreSQL 的 Index Only Scan 利用 VM(可见性映射文件,relfilenode_vm) 实现:VM 中每个页面对应 2 bit:
- all-visible bit:该页所有 tuple 对所有当前事务均可见,Index Only Scan 可以跳过回堆
- all-frozen bit:该页所有 tuple 已冻结,不需要 MVCC 检查
VM 由 VACUUM 维护更新。因此 autovacuum 的健康状况直接影响 Index Only Scan 的性能。
8. UPDATE 的真实过程
UPDATE 在 PostgreSQL 内部等价于 DELETE 旧版本 + INSERT 新版本,但做了大量优化。
-- XID=778
UPDATE users SET name = 'Bob2' WHERE id = 3;
执行过程
- 找到目标 tuple:通过全表扫描或索引定位到
(0, 3)这个 tuple - 标记旧版本删除:将旧 tuple 的
xmax写入 778,清除XMAX_INVALID,将ctid改写为新版本的 TID - 写入新版本:在页内(或新页)分配空间,写入新 tuple:
xmin = 778 xmax = 0 (INVALID) ctid = (0, 4) ← 指向自身 data = 'Bob2' - 更新索引:若被更新的列在索引中,旧索引条目保留,新增一个
('Bob2', TID(0,4))的索引条目
页面内两个 tuple 共存,直到 VACUUM 清理为止,这是 PostgreSQL 表膨胀(Table Bloat) 的根本原因。
HOT 更新优化
若满足以下两个条件,PostgreSQL 会使用 HOT(Heap Only Tuple) 优化:
- 新旧 tuple 在同一个页面内
- 被修改的列没有索引
HOT 更新时,不会在索引中新增任何条目,而是在页面内把旧槽位设为 redirect 状态,指向新 tuple 的槽号。索引条目仍指向旧槽,通过 redirect 链找到新 tuple。这大幅减少了索引膨胀和索引维护开销。
正常 UPDATE:索引有两个条目(旧key→旧TID,新key→新TID)
HOT UPDATE: 索引条目不变,旧槽 redirect→ 新槽
9. DELETE 的真实过程
-- XID=779
DELETE FROM users WHERE id = 3;
DELETE 不物理删除任何数据,只做一件事:
将目标 tuple 的 xmax 写入 779,清除 XMAX_INVALID:
xmin = 102 (未变)
xmax = 779 (新写入:标记为"被事务779删除")
ctid = (0,3) (未变,指向自身)
事务提交后,在 CLOG 中将 XID=779 标记为已提交。此后所有快照在 XID=779 之后获取的事务,读到这个 tuple 时,发现 xmax 已提交且在快照可见范围外,判定该行已删除,不返回。
物理空间此时仍然被占用,等待 VACUUM 回收。
10. VACUUM:死元组的清理机制
由于 UPDATE 和 DELETE 只做标记,不物理删除,表文件会持续膨胀。VACUUM 是 PostgreSQL 特有的清理机制。
VACUUM 的工作流程
- 扫描堆表:逐页读取,识别所有
xmax已提交且无任何活跃快照需要的"死元组" - 物理清除死元组:在页内移除死 tuple 的数据,调用
PageRepairFragmentation()整理页内碎片,将存活 tuple 向高地址紧缩,空闲空间重新聚合 - 更新 ItemId 状态:死 tuple 对应的槽位标记为
dead(槽号保留可复用) - 更新 FSM:将回收的空间记录到 FSM 文件,让后续 INSERT 能找到这些空间
- 更新 VM:若某页所有 tuple 均为当前版本(无死元组),设置 all-visible bit
- 清理索引:删除索引中所有指向已清理死元组的条目
VACUUM FULL vs 普通 VACUUM
| 特性 | VACUUM | VACUUM FULL |
|---|---|---|
| 空间归还给 OS | 否(只归还给 FSM) | 是 |
| 是否重写文件 | 否 | 是(相当于重建表) |
| 是否持有表锁 | 否(只持行锁) | 是(持 AccessExclusiveLock) |
| 适用场景 | 日常维护 | 严重膨胀后的紧急处理 |
autovacuum
PostgreSQL 有后台进程 autovacuum 自动执行 VACUUM,由以下参数控制触发时机:
-- 当死元组数量超过以下阈值时触发
autovacuum_vacuum_threshold = 50 -- 基础阈值(行数)
autovacuum_vacuum_scale_factor = 0.2 -- 表大小的比例
-- 触发条件:dead_tuples > threshold + scale_factor × table_rows
11. TOAST:超长字段的存储机制
PostgreSQL 要求每个 tuple 必须完整放入一个页面(最大约 8160 字节)。对于超长的列值(text、bytea、jsonb 等),使用 TOAST(The Oversized-Attribute Storage Technique) 机制。
TOAST 策略
每个可 TOAST 的列都有一个存储策略:
| 策略 | 说明 |
|---|---|
plain |
不使用 TOAST(适用于 int、bool 等短类型) |
extended |
先尝试压缩,若仍超长则移到 TOAST 表(默认,适用于 text) |
external |
不压缩,直接移到 TOAST 表(适用于已压缩的 JPEG 等) |
main |
先压缩,尽量留在主表,必要时才外移 |
TOAST 表结构
每张有可 TOAST 列的表,都会自动创建一张对应的 TOAST 表(在 pg_toast schema 下):
-- TOAST 表的结构
CREATE TABLE pg_toast.pg_toast_XXXXX (
chunk_id oid, -- 标识哪个值的哪个分块
chunk_seq integer, -- 分块序号
chunk_data bytea -- 实际数据(每块约 2000 字节)
);
-- 主表的 tuple 里存的是一个 18 字节的 TOAST 指针
TOAST 触发机制
当一行数据加上头部超过约 2000 字节时,PostgreSQL 按以下顺序处理 TOAST 列,直到行缩短到可接受范围:
- 压缩
extended策略的列(LZ 算法) - 将
external/extended策略的列移到 TOAST 表 - 压缩
main策略的列 - 将
main策略的列移到 TOAST 表
读取时自动拼接,对应用完全透明。若 SQL 不查询 TOAST 列(如 SELECT id, name 不查大字段),TOAST 表完全不被访问,这也是避免 SELECT * 的原因之一。
12. 与 MySQL InnoDB 行结构的核心对比
| 维度 | PostgreSQL | MySQL InnoDB |
|---|---|---|
| 数据组织 | 堆表(无序),数据与索引分离 | 聚簇索引,数据按主键有序,数据即索引 |
| 主键索引 | 普通 B-tree,叶子存 key+TID |
聚簇索引,叶子直接存完整行数据 |
| 二级索引 | 叶子存 key+TID,直接定位堆表 |
叶子存 key+主键值,需二次查聚簇索引(回表) |
| MVCC 旧版本存储 | 内嵌在堆表页面,旧 tuple 原地保留 | 独立的 Undo Log 文件(ibdata 或独立 undo 表空间) |
| UPDATE 方式 | Append-Only:写新 tuple,打标旧 tuple | 原地修改,旧值写入 Undo Log |
| ROLLBACK 方式 | 只在 CLOG 标记"已中止",不回滚数据 | 从 Undo Log 将数据还原 |
| 表膨胀问题 | 存在,需 VACUUM 定期清理 | 不存在表膨胀,Undo Log 有独立的 Purge 线程 |
| 主键选型影响 | UUID 主键不影响堆表写性能(只影响索引) | UUID 主键导致聚簇索引页频繁分裂,写性能严重下降 |
| 行头部开销 | 最小 23 字节(MVCC 字段内嵌) | 约 5~20 字节(不含 Undo Log 开销) |
核心架构差异一句话:MySQL 是"数据跟着主键走",PostgreSQL 是"索引找数据"。两者各有取舍,没有绝对优劣。
13. 实战:用 pageinspect 观察真实页面
pageinspect 是 PostgreSQL 官方扩展,可以直接读取页面的原始内容。
-- 安装扩展
CREATE EXTENSION pageinspect;
-- 建表并插入测试数据
CREATE TABLE t (id int, name text, age int);
INSERT INTO t VALUES (1, 'Alice', 30);
INSERT INTO t VALUES (2, 'Bob', 22);
BEGIN;
UPDATE t SET name = 'Bob2' WHERE id = 2;
-- 先不提交,观察中间状态
-- 查看页头信息
SELECT lower, upper, special, pagesize
FROM page_header(get_raw_page('t', 0));
-- lower=148, upper=7900, special=8192, pagesize=8192
-- 查看所有 tuple 的详细字段
SELECT
'(0,' || lp || ')' AS ctid,
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,
t_hoff,
lp_len
FROM heap_page_items(get_raw_page('t', 0));
输出示例(UPDATE 事务提交前):
ctid | state | t_xmin | t_xmax | t_ctid | xmin_committed | xmax_committed | t_hoff | lp_len
-------+--------+--------+--------+--------+----------------+----------------+--------+-------
(0,1) | normal | 776 | 0 | (0,1) | t | f | 24 | 40
(0,2) | normal | 777 | 778 | (0,3) | t | f | 24 | 38
(0,3) | normal | 778 | 0 | (0,3) | f | f | 24 | 40
可以清晰看到:
(0,2)是 Bob 的旧版本:xmax=778(被 UPDATE 事务标记删除),ctid=(0,3)指向新版本(0,3)是 Bob2 的新版本:xmin=778,xmax=0,xmin_committed=f(事务未提交)
14. 总结
PostgreSQL 行记录的设计哲学是将 MVCC 完全内嵌在每一行,以 Append-Only 的方式写入数据,用 xmin/xmax/ctid 在行头部维护完整的版本信息。这一设计带来了以下特性:
| 特性 | 原因 |
|---|---|
| COMMIT 极快 | 只写 CLOG 的 2 bit |
| ROLLBACK 极快 | 只写 CLOG,不回滚数据页 |
| 读不阻塞写 | 读操作找旧版本,写操作写新版本,互不干扰 |
| 需要 VACUUM | Append-Only 导致旧版本堆积,必须定期清理 |
| 所有索引地位平等 | 索引与堆表完全分离,叶子都指向 TID |
| UUID 主键无负担 | 数据无序堆放,主键只影响索引文件 |
理解行记录的物理结构,是优化 PostgreSQL 性能、理解 autovacuum 行为、排查表膨胀问题的基础,也是深入掌握 MVCC 和并发控制的起点。
更多推荐
所有评论(0)