Skip to content

元组版本与 ctid(Tuple Versions and ctid) ​

上一节讲了 XID 与快照,它们是「判断者」;本节要讲「被判断者」——元组版本。MVCC 的所有魔法最终都落在元组的头部字段上:xmin 说明谁创建了它,xmax 说明谁删除了它,ctid 说明它在磁盘上的确切位置。理解了元组头的布局,你就能用 pageinspect 直接「看到」MVCC 的运行,而不是停留在概念层面。

前置知识

阅读本节前,建议先了解:事务 ID 与快照

基础概念 ​

元组版本(Tuple Version) 也称行版本(Row Version),是表中某一行的某个历史状态在物理文件里的一份完整记录。PostgreSQL 从不在原地修改一行的内容,而是写入一个全新的元组版本,再给旧版本打上「已删除」标记。

理解元组版本需要先统一以下术语:

术语英文说明
元组Tuple表中一行的物理表示,位于某个页的某个槽位上
元组版本Tuple Version / Row Version同一逻辑行的某一次历史状态,一次 UPDATE 产生一个新版本
元组头HeapTupleHeader元组开头的固定长度控制信息(23 字节 + 对齐)
元组标识符ctid, Item Pointer格式 (block, offset),指向元组所在的页与槽位
创建事务xmin插入该版本的 XID
删除事务xmax删除/更新该版本的 XID,0 表示尚未被删除
命令序号cmin / cmax同一事务内第几条语句做的操作,仅对当前事务有意义
信息位掩码t_infomask / t_infomask2一组标志位,记录 xmin/xmax 是否已提交、是否 HOT、是否有 NULL 等
堆元组Heap Tuple存放在堆表(heap)中的元组,区别于索引元组
行指针Line Pointer / ItemId页内的 4 字节槽位条目,指向元组数据的偏移与长度
HOT 更新Heap-Only Tuple Update更新的新版本仍在同一页内,无需改动索引
版本链Version Chain同一行多个版本通过 ctid 首尾相连形成的链

元组头:一行的「身份证」 ​

每个元组都以一个 HeapTupleHeaderData 结构开头。它的字段不多,但每一个都直接支撑 MVCC:

text
HeapTupleHeaderData(共 23 字节,后接可选 NULL 位图与数据)
┌────────────────────────────────────────────────────────────┐
│ t_xmin      4B   创建该版本的事务 ID                        │
│ t_xmax      4B   删除该版本的事务 ID(0 = 还活着)           │
│ t_cid       4B   命令 ID(cmin/cmax 合并字段)               │
│ t_ctid      6B   指向自己或本行的下一个版本(block, offset)  │
│ t_infomask2 2B   属性个数 + HOT 相关标志                     │
│ t_infomask  2B   状态标志位(提交/回滚/冻结/NULL 等)         │
│ t_hoff      1B   头部长度(含 NULL 位图),用于定位真实数据   │
└────────────────────────────────────────────────────────────┘

这 23 字节的头部之所以存在,是因为元组必须自己能回答两个问题:

sql
-- ① 我是谁创建的?(t_xmin)
-- ② 谁让我失效了?(t_xmax)
-- MVCC 的可见性判断,就是拿这两个 XID 去和快照比较

ctid:物理位置坐标 ​

ctid(Item Pointer) 是对「元组在哪」的完整回答,它由两部分组成:

text
ctid = (block_number, item_offset)

block_number   页号,从 0 开始,对应 1GB 段文件里的第几个 8KB 页
item_offset    槽位号(Line Pointer 下标),从 1 开始

ctid 有三层含义,逐层递进:

层次含义怎么观察
物理地址元组在数据文件中的唯一位置SELECT ctid FROM t
版本链的指针t_ctid 指向本行的下一个版本heap_page_items 的 t_ctid 列
定位手段用 WHERE ctid = '(0,1)' 直接取一个元组SELECT * FROM t WHERE ctid = '(0,1)'

常见误解

ctid 不是主键,也不稳定。一次 UPDATE 之后,同一逻辑行的 ctid 通常会变(新版本写到了别的位置)。只有在同一页内且执行 HOT 更新时,ctid 才可能保持不变。不要把 ctid 存进业务字段当作行标识。

核心机制 ​

一次 UPDATE 产生的版本链 ​

下面用最经典的场景来说明:页里原本有一个元组 (0,1),执行一次 UPDATE 后变成两个元组。

sql
-- 准备一张演示表
CREATE TABLE chain_demo (
  id    integer PRIMARY KEY,
  note  text
);

INSERT INTO chain_demo (id, note) VALUES (1, 'v1');
sql
-- 更新一次,观察版本链
UPDATE chain_demo SET note = 'v2' WHERE id = 1;

SELECT ctid, xmin, xmax, id, note FROM chain_demo;
text
 ctid  | xmin | xmax | id | note
-------+------+------+----+------
 (0,2) |  761 |    0 |  1 | v2

注意查询结果里只看到一行,但物理上页里有两个元组。原因是旧版本 (0,1) 的 t_xmax 已经被写成了 761,而 761 已提交,所以它对任何新快照都不可见。用 pageinspect 才能看到全部:

sql
CREATE EXTENSION IF NOT EXISTS pageinspect;

SELECT lp, lp_off, lp_flags, t_xmin, t_xmax, t_ctid,
       encode(t_data, 'escape') AS data
FROM heap_page_items(get_raw_page('chain_demo', 0))
ORDER BY lp;
text
 lp | lp_off | lp_flags | t_xmin | t_xmax | t_ctid |          data
----+--------+----------+--------+--------+--------+-------------------------
  1 |   8128 |        1 |    760 |    761 | (0,2)  | ...id=1 note=v1
  2 |   8080 |        1 |    761 |      0 | (0,2)  | ...id=1 note=v2

关键观察:

  • 槽位 1 是旧版本,t_xmax = 761(被 761 删除),并且它的 t_ctid = (0,2) —— 不指向自己,而是指向新版本,这就是版本链的指针。
  • 槽位 2 是新版本,t_xmax = 0(仍然活着),t_ctid = (0,2) 指向自己。
  • 两个元组的 xmin 分别是 760、761 —— 同一个逻辑行,不同的事务创建的。

版本链的 ASCII 表示:

text
  槽位 1                    槽位 2
┌──────────────┐         ┌──────────────┐
│ t_xmin = 760 │         │ t_xmin = 761 │
│ t_xmax = 761 │         │ t_xmax = 0   │
│ t_ctid ──────┼────────►│ t_ctid = self│
│ note = 'v1'  │         │ note = 'v2'  │
└──────────────┘         └──────────────┘
     (死元组)                (活元组)

再更新一次 → 槽位 3 加入,链变成 1→2→3,头部始终是活元组

t_infomask:状态标志位 ​

t_infomask 是一个 16 位的位掩码,可见性判断的第一道过滤就靠它。它最核心的作用是用空间换时间:把「xmin 是否已提交」「xmax 是否已提交」这类信息直接缓存在元组头部,避免每次判断都去查 CLOG。

标志位十六进制含义
HEAP_XMIN_COMMITTED0x0100创建者已提交(可直接判定可见,无需查 CLOG)
HEAP_XMIN_INVALID0x0200创建者已回滚(元组直接作废)
HEAP_XMIN_FROZEN0x0300创建者已冻结(永远可见,xmin 记为 2)
HEAP_XMAX_COMMITTED0x0400删除者已提交(元组可回收)
HEAP_XMAX_INVALID0x0800xmax 无意义(元组仍存活)
HEAP_XMAX_IS_MULTI0x1000xmax 是 MultiXact ID 而非普通 XID
HEAP_HOT_UPDATED0x4000本元组被 HOT 更新过(t_ctid 指向新版本)
HEAP_ONLY_TUPLE0x8000本元组是 HOT 链上的非头部元组(索引里没有它)
sql
-- 直接读取标志位,看它到底记录了什么
SELECT lp, t_xmin, t_xmax, t_infomask, t_infomask2,
       (t_infomask & 256)  <> 0 AS xmin_committed,   -- 0x0100
       (t_infomask & 2048) <> 0 AS xmax_invalid,     -- 0x0800
       (t_infomask & 16384) <> 0 AS hot_updated,     -- 0x4000
       (t_infomask & 32768) <> 0 AS heap_only_tuple  -- 0x8000
FROM heap_page_items(get_raw_page('chain_demo', 0))
ORDER BY lp;
text
 lp | t_xmin | t_xmax | t_infomask | t_infomask2 | xmin_committed | xmax_invalid | hot_updated | heap_only_tuple
----+--------+--------+------------+-------------+----------------+--------------+-------------+-----------------
  1 |    760 |    761 |       1281 |           2 | t              | f            | t           | f
  2 |    761 |      0 |       1153 |       32770 | t              | t            | f           | t

解读这份输出:

  • 槽位 1 的 hot_updated = t,heap_only_tuple = f —— 它是 HOT 链的头部。
  • 槽位 2 的 heap_only_tuple = t,t_infomask2 = 32770(含 0x8000)—— 它是 HOT 链上的非头部元组,索引里没有它的条目。
  • 这就是「HOT 更新」的全部秘密:只要新版本能放进同一页,就不必更新索引。

HOT 更新与普通更新 ​

HOT(Heap-Only Tuple)更新是 PostgreSQL 一个极其重要的优化。它成立的条件很苛刻:

条件说明
新版本能放进同一页页里得有足够空闲空间(依赖 FSM 提供的空间信息)
被更新的列没有被索引引用一旦改了索引列,所有相关索引都必须插入新条目

满足条件时,PostgreSQL 走 HOT 路径,带来两个直接收益:

text
① 不插入新索引项 → 索引不膨胀,写入更快
② 新版本带 HEAP_ONLY_TUPLE 标志 → 索引扫描到旧版本后,可顺着 ctid 链找到新版本

不满足条件时,走普通更新:

text
① 每个索引都插入一个新条目,指向新版本的 ctid
② 旧索引条目变成"死条目",要靠 VACUUM 清理
③ 旧元组不带 HOT 标志,索引扫描必须先定位旧版本再判断"已死"→ 触发额外堆访问

为什么 HOT 链能工作

索引条目指向的是链的头部(有 HEAP_HOT_UPDATED 的那个)。当索引扫描拿到头部元组的 ctid 时,如果发现它已经被 HOT 更新过,就顺着 t_ctid 链一路走到活元组。这样即使经历了 5 次 HOT 更新,索引里也只有一个条目。

相关参数 ​

元组版本的行为基本由 MVCC 机制决定,几乎没有直接可调的参数;但下面这些参数会间接影响版本的产生与回收:

参数默认值生效方式说明
fillfactor100表级 ALTER TABLE页预留空间比例,降低它可提升 HOT 更新成功率
autovacuum_vacuum_scale_factor0.2user死元组占比超过该比例触发 VACUUM
autovacuum_vacuum_threshold50user死元组绝对数量下限
autovacuum_vacuum_cost_delay2msuserVACUUM 每轮限速延迟,调大可减少 I/O 冲击
track_countson重启统计死元组数量,关闭后 n_dead_tup 不再更新
default_statistics_target100user影响 ANALYZE 的采样量,间接影响插入位置选择
ini
# postgresql.conf
# 高频更新的表:全局把 fillfactor 调低不现实,建议逐表设置
autovacuum_vacuum_scale_factor = 0.1
autovacuum_vacuum_threshold = 100
track_counts = on
sql
-- 对高频更新的大表单独设置,为 HOT 留出页内空间
ALTER TABLE chain_demo SET (fillfactor = 80);
-- 注意:fillfactor 只对之后写入的页生效,存量页需要 REINDEX/CLUSTER/VACUUM FULL 才重排

详细说明 ​

ctid 的稳定性边界 ​

理解了 HOT 之后,就能准确回答「ctid 什么时候不变」:

操作ctid 是否变化原因
SELECT不变只读,不产生新版本
UPDATE(HOT 成功)不变新版本写在同一页,头部位置被复用为链指针,索引仍指旧 ctid
UPDATE(HOT 失败)变化新版本写到别处,索引被更新指向新 ctid
UPDATE(改了索引列)变化必须更新索引,强制走普通更新路径
VACUUM不变(活元组)VACUUM 只回收死元组,不移动活元组
VACUUM FULL / CLUSTER变化整表重写,所有元组被紧凑排布到新位置
pg_repack变化本质上也是整表重写
sql
-- 观察:连续两次 HOT 更新后,ctid 保持不变
SELECT ctid, xmin, note FROM chain_demo WHERE id = 1;

UPDATE chain_demo SET note = 'v3' WHERE id = 1;
SELECT ctid, xmin, note FROM chain_demo WHERE id = 1;
text
 第一次查询        第二次查询(HOT 更新后)
 ctid  | xmin  →   ctid  | xmin
-------+------  →  -------+------
 (0,2) |  761  →   (0,2) |  763      ← ctid 没变,xmin 变了

若某次更新后 ctid 变了,说明该次走了非 HOT 路径

用 ctid 定位元组并手工「查看」可见性 ​

ctid 除了被系统内部使用,还可以作为查询条件。这在诊断时非常有用——你可以绕过可见性规则,直接看到物理元组:

sql
-- 用 ctid 精确定位一个元组(不加别的条件)
SELECT ctid, xmin, xmax, note FROM chain_demo WHERE ctid = '(0,1)';

这条查询会返回旧版本(因为 WHERE ctid = ... 是特殊的 TidScan,它仍然遵守可见性,但定位是精确的)。如果旧版本对当前快照不可见,结果就是空。想无视可见性,必须用 pageinspect:

sql
-- 无视可见性,把页里所有元组都倒出来
SELECT lp AS slot, t_xmin, t_xmax, t_ctid,
       CASE WHEN t_xmax = 0 THEN 'live' ELSE 'dead-or-updated' END AS rough_state
FROM heap_page_items(get_raw_page('chain_demo', 0))
ORDER BY lp;
text
 slot | t_xmin | t_xmax | t_ctid |   rough_state
------+--------+--------+--------+-----------------
    1 |    760 |    761 | (0,2)  | dead-or-updated
    2 |    761 |    763 | (0,3)  | dead-or-updated
    3 |    763 |      0 | (0,3)  | live

cmin / cmax:同一事务内的顺序 ​

如果两条 SQL 在同一个事务里执行,xmin 可能相同(同一个 XID),此时靠什么区分先后?答案是 cmin / cmax(命令 ID):

text
cmin:本事务内产生该版本的命令序号
cmax:本事务内删除该版本的命令序号

例如在同一个事务里插入再删除,元组的 xmin = xmax = 同一个 XID,只有 cmin < cmax 才能说明「是插入之后才删的」。pg 13 之后这两者被合并存进 t_cid 字段(t_cid = (cmax << 16) | cmin)。

sql
-- 同一事务内的插入+删除,xmin 与 xmax 相同
BEGIN;
INSERT INTO chain_demo (id, note) VALUES (99, 'same-txn');
DELETE FROM chain_demo WHERE id = 99;
SELECT ctid, xmin, xmax FROM chain_demo WHERE id = 99;   -- 返回空(不可见)
COMMIT;

实战示例 ​

示例:观测一次非 HOT 更新如何造成索引膨胀 ​

这个示例完整演示「改索引列导致 HOT 失效 → 索引膨胀 → VACUUM 回收」的全过程。

准备环境:

sql
CREATE TABLE hot_demo (
  id    integer PRIMARY KEY,
  tag   integer,          -- 有索引的列
  note  text              -- 无索引的列
);

CREATE INDEX idx_hot_demo_tag ON hot_demo (tag);

INSERT INTO hot_demo
SELECT g, g % 10, 'initial'
FROM generate_series(1, 1000) AS g;

-- 记录初始大小
SELECT pg_size_pretty(pg_relation_size('hot_demo'))          AS heap_size,
       pg_size_pretty(pg_relation_size('idx_hot_demo_tag'))  AS index_size;
text
 heap_size | index_size
-----------+------------
 64 kB     | 64 kB

第 1 轮:更新无索引列(应走 HOT)

sql
-- 循环更新 5 次,只改 note(非索引列)
UPDATE hot_demo SET note = 'r1';
UPDATE hot_demo SET note = 'r2';
UPDATE hot_demo SET note = 'r3';
UPDATE hot_demo SET note = 'r4';
UPDATE hot_demo SET note = 'r5';

SELECT pg_size_pretty(pg_relation_size('hot_demo'))         AS heap_size,
       pg_size_pretty(pg_relation_size('idx_hot_demo_tag')) AS index_size;
text
 heap_size | index_size
-----------+------------
 176 kB    | 64 kB        ← 堆变大了,索引没变

第 2 轮:更新索引列(HOT 必然失败)

sql
-- 循环更新 5 次,改 tag(索引列)
UPDATE hot_demo SET tag = tag + 1000;
UPDATE hot_demo SET tag = tag + 1000;
UPDATE hot_demo SET tag = tag + 1000;
UPDATE hot_demo SET tag = tag + 1000;
UPDATE hot_demo SET tag = tag + 1000;

SELECT pg_size_pretty(pg_relation_size('hot_demo'))         AS heap_size,
       pg_size_pretty(pg_relation_size('idx_hot_demo_tag')) AS index_size;
text
 heap_size | index_size
-----------+------------
 320 kB    | 256 kB       ← 索引涨到 4 倍

第 3 轮:VACUUM 回收

sql
VACUUM hot_demo;

SELECT pg_size_pretty(pg_relation_size('hot_demo'))         AS heap_size,
       pg_size_pretty(pg_relation_size('idx_hot_demo_tag')) AS index_size,
       (SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'hot_demo') AS dead_tup;
text
 heap_size | index_size | dead_tup
-----------+------------+----------
 320 kB    | 128 kB     | 0

结果解读:

  • 第 1 轮堆涨了 112 kB、索引一点没涨,这就是 HOT 的价值:更新无索引列不会造成索引膨胀。
  • 第 2 轮索引从 64 kB 涨到 256 kB,因为每次改 tag 都必须在索引里插入新条目,旧条目变成死条目。
  • VACUUM 后索引回落到 128 kB、dead_tup = 0,但堆大小没变(320 kB)——VACUUM 只把空间标记为可复用,不归还操作系统。
  • 结论:为高频更新的表把「经常变的列」和「有索引的列」尽量分开,能显著降低索引膨胀速度。若必须改索引列,考虑用 fillfactor 预留页内空间,或者干脆接受膨胀并用 REINDEX CONCURRENTLY 定期整理索引。

注意事项 ​

  • 元组头不是「额外开销」,而是 MVCC 的实现载体。每个元组约 23 字节的头部是为「读写不互斥」付出的结构性成本。
  • ctid 会随 UPDATE 变化,且 VACUUM FULL、CLUSTER、pg_repack 会让所有 ctid 重排。不要把它写进业务表。
sql
-- 不推荐:把 ctid 存下来当"行地址"用
-- UPDATE users SET cached_ctid = ctid::text WHERE id = 1;   -- 下次 UPDATE 后即失效
  • HOT 更新只对「同一页内、不涉及索引列」的更新生效。页空间不足时会退化为普通更新。
  • t_infomask 里的「已提交」位是一种缓存,它由写入者或 VACUUM 设置;不要指望它在事务刚提交的瞬间就一定被写好。
  • 不要用 WHERE ctid = ... 做批量删除来「加速」,虽然它确实快,但 ctid 不稳定,多轮之间可能失效;优先用主键或 ctid IN (SELECT ctid ... FOR UPDATE) 的原子写法。
  • 索引元组也会产生版本。B-tree 索引同样有「删除条目」的问题,只是清理方式不同(VACUUM 清理死索引条目)。
  • pageinspect 需要超级用户或授予权限,且读页时表可能正在被修改,结果只是一个快照式的近似,不要用它做业务逻辑判断。
  • lp_flags 为 0 表示槽位未被使用,为 2 表示已重定向(REDIRECT)。HOT 链上被清理过的旧元组可能出现 REDIRECT 状态。
  • 元组版本的数量等于「插入 + 每次更新」的总和,一张更新 100 次的行会有 101 个物理版本(未 VACUUM 前)。

最佳实践 ​

  • 高频更新的表把 fillfactor 设为 70~85,为 HOT 更新留出页内空间,能显著降低索引与堆的膨胀速度。
  • 把「是否更新索引列」作为表设计的考量:把频繁变化的列从索引中移出,或改用表达式索引、部分索引。
  • 监控 n_tup_upd 与 n_tup_hot_upd 的比值,判断 HOT 命中率是否健康。
sql
-- HOT 命中率:接近 1 说明绝大多数更新都没动索引,非常健康
SELECT relname,
       n_tup_upd,
       n_tup_hot_upd,
       round(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 2) AS hot_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 0
ORDER BY n_tup_upd DESC
LIMIT 10;
  • 用 pgstattuple 定期体检表的死元组比例,超过 20% 就值得排查(是长事务、还是缺 VACUUM、还是更新过于频繁)。
  • 不要把 SELECT * 用在百万行表上反复刷新页面缓存;宽表的元组会更长,页内能放的元组更少,HOT 成功率更低。必要时把大字段(text/jsonb)拆到单独的表,让主表变窄。
  • 批量更新后主动跑 VACUUM,不要依赖 autovacuum 的滞后响应。
  • 用 pg_repack 而不是 VACUUM FULL 回收空间,前者不长时间持排他锁。
  • 理解「元组版本链」的链长是有代价的:链太长时,索引扫描需要多跳才能找到活元组。定期 VACUUM 顺便也会清理链上的死元组。
  • 诊断膨胀时同时看「堆大小」「索引大小」「死元组数」「最老事务年龄」四个指标,单看任何一个都会误判。

下一节

继续学习:可见性判断规则

参考链接 ​