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_INVALID
  • UPDATE 时:旧版本的 xmax 写入更新事务的 XID
  • 事务进行中,xmax 也用作行级锁的标记,其他事务看到非 0 xmax 就知道有并发修改
t_ctid(6 字节)

格式同 TID:(页号 uint32, 槽号 uint16)

  • 对于最新版本的 tuple:ctid 指向自身,如 (0, 1) 指向第 0 页槽 1
  • UPDATE 发生后:旧版本的 ctid 被改写为新版本的 TID,形成版本链:旧 → 新 → 更新
  • 通过 ctid 链可以从任意旧版本找到最新版本,这在 HOT 更新优化中尤为关键
t_infomaskt_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(多版本并发控制)完全内嵌在每个行记录的头部,通过 xminxmaxinfomask 三个字段实现,无需独立的 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            |
+--------------------+            +---------------------------+

一次索引查找的完整路径:

  1. 从 Root 页读取导航键,二分查找确定子页号
  2. 重复第 1 步,直到到达叶子层(每层一次 I/O)
  3. 在叶子页内二分,找到目标 key,拿到 TID
  4. 用 TID 定位堆表页面,通过 ItemId 找到 tuple 偏移,读出完整数据(回表/heap fetch
  5. 对读到的 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;

执行过程

  1. 找到目标 tuple:通过全表扫描或索引定位到 (0, 3) 这个 tuple
  2. 标记旧版本删除:将旧 tuple 的 xmax 写入 778,清除 XMAX_INVALID,将 ctid 改写为新版本的 TID
  3. 写入新版本:在页内(或新页)分配空间,写入新 tuple:
    xmin = 778
    xmax = 0 (INVALID)
    ctid = (0, 4)  ← 指向自身
    data = 'Bob2'
    
  4. 更新索引:若被更新的列在索引中,旧索引条目保留,新增一个 ('Bob2', TID(0,4)) 的索引条目

页面内两个 tuple 共存,直到 VACUUM 清理为止,这是 PostgreSQL 表膨胀(Table Bloat) 的根本原因。

HOT 更新优化

若满足以下两个条件,PostgreSQL 会使用 HOT(Heap Only Tuple) 优化:

  1. 新旧 tuple 在同一个页面
  2. 被修改的列没有索引

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 的工作流程

  1. 扫描堆表:逐页读取,识别所有 xmax 已提交且无任何活跃快照需要的"死元组"
  2. 物理清除死元组:在页内移除死 tuple 的数据,调用 PageRepairFragmentation() 整理页内碎片,将存活 tuple 向高地址紧缩,空闲空间重新聚合
  3. 更新 ItemId 状态:死 tuple 对应的槽位标记为 dead(槽号保留可复用)
  4. 更新 FSM:将回收的空间记录到 FSM 文件,让后续 INSERT 能找到这些空间
  5. 更新 VM:若某页所有 tuple 均为当前版本(无死元组),设置 all-visible bit
  6. 清理索引:删除索引中所有指向已清理死元组的条目

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 列,直到行缩短到可接受范围:

  1. 压缩 extended 策略的列(LZ 算法)
  2. external / extended 策略的列移到 TOAST 表
  3. 压缩 main 策略的列
  4. 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=778xmax=0xmin_committed=f(事务未提交)

14. 总结

PostgreSQL 行记录的设计哲学是将 MVCC 完全内嵌在每一行,以 Append-Only 的方式写入数据,用 xmin/xmax/ctid 在行头部维护完整的版本信息。这一设计带来了以下特性:

特性 原因
COMMIT 极快 只写 CLOG 的 2 bit
ROLLBACK 极快 只写 CLOG,不回滚数据页
读不阻塞写 读操作找旧版本,写操作写新版本,互不干扰
需要 VACUUM Append-Only 导致旧版本堆积,必须定期清理
所有索引地位平等 索引与堆表完全分离,叶子都指向 TID
UUID 主键无负担 数据无序堆放,主键只影响索引文件

理解行记录的物理结构,是优化 PostgreSQL 性能、理解 autovacuum 行为、排查表膨胀问题的基础,也是深入掌握 MVCC 和并发控制的起点。


Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐