元组版本与 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:
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 字节的头部之所以存在,是因为元组必须自己能回答两个问题:
-- ① 我是谁创建的?(t_xmin)
-- ② 谁让我失效了?(t_xmax)
-- MVCC 的可见性判断,就是拿这两个 XID 去和快照比较ctid:物理位置坐标
ctid(Item Pointer) 是对「元组在哪」的完整回答,它由两部分组成:
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 后变成两个元组。
-- 准备一张演示表
CREATE TABLE chain_demo (
id integer PRIMARY KEY,
note text
);
INSERT INTO chain_demo (id, note) VALUES (1, 'v1');-- 更新一次,观察版本链
UPDATE chain_demo SET note = 'v2' WHERE id = 1;
SELECT ctid, xmin, xmax, id, note FROM chain_demo; ctid | xmin | xmax | id | note
-------+------+------+----+------
(0,2) | 761 | 0 | 1 | v2注意查询结果里只看到一行,但物理上页里有两个元组。原因是旧版本 (0,1) 的 t_xmax 已经被写成了 761,而 761 已提交,所以它对任何新快照都不可见。用 pageinspect 才能看到全部:
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; 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 表示:
槽位 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_COMMITTED | 0x0100 | 创建者已提交(可直接判定可见,无需查 CLOG) |
HEAP_XMIN_INVALID | 0x0200 | 创建者已回滚(元组直接作废) |
HEAP_XMIN_FROZEN | 0x0300 | 创建者已冻结(永远可见,xmin 记为 2) |
HEAP_XMAX_COMMITTED | 0x0400 | 删除者已提交(元组可回收) |
HEAP_XMAX_INVALID | 0x0800 | xmax 无意义(元组仍存活) |
HEAP_XMAX_IS_MULTI | 0x1000 | xmax 是 MultiXact ID 而非普通 XID |
HEAP_HOT_UPDATED | 0x4000 | 本元组被 HOT 更新过(t_ctid 指向新版本) |
HEAP_ONLY_TUPLE | 0x8000 | 本元组是 HOT 链上的非头部元组(索引里没有它) |
-- 直接读取标志位,看它到底记录了什么
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; 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 路径,带来两个直接收益:
① 不插入新索引项 → 索引不膨胀,写入更快
② 新版本带 HEAP_ONLY_TUPLE 标志 → 索引扫描到旧版本后,可顺着 ctid 链找到新版本不满足条件时,走普通更新:
① 每个索引都插入一个新条目,指向新版本的 ctid
② 旧索引条目变成"死条目",要靠 VACUUM 清理
③ 旧元组不带 HOT 标志,索引扫描必须先定位旧版本再判断"已死"→ 触发额外堆访问为什么 HOT 链能工作
索引条目指向的是链的头部(有 HEAP_HOT_UPDATED 的那个)。当索引扫描拿到头部元组的 ctid 时,如果发现它已经被 HOT 更新过,就顺着 t_ctid 链一路走到活元组。这样即使经历了 5 次 HOT 更新,索引里也只有一个条目。
相关参数
元组版本的行为基本由 MVCC 机制决定,几乎没有直接可调的参数;但下面这些参数会间接影响版本的产生与回收:
| 参数 | 默认值 | 生效方式 | 说明 |
|---|---|---|---|
fillfactor | 100 | 表级 ALTER TABLE | 页预留空间比例,降低它可提升 HOT 更新成功率 |
autovacuum_vacuum_scale_factor | 0.2 | user | 死元组占比超过该比例触发 VACUUM |
autovacuum_vacuum_threshold | 50 | user | 死元组绝对数量下限 |
autovacuum_vacuum_cost_delay | 2ms | user | VACUUM 每轮限速延迟,调大可减少 I/O 冲击 |
track_counts | on | 重启 | 统计死元组数量,关闭后 n_dead_tup 不再更新 |
default_statistics_target | 100 | user | 影响 ANALYZE 的采样量,间接影响插入位置选择 |
# postgresql.conf
# 高频更新的表:全局把 fillfactor 调低不现实,建议逐表设置
autovacuum_vacuum_scale_factor = 0.1
autovacuum_vacuum_threshold = 100
track_counts = on-- 对高频更新的大表单独设置,为 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 | 变化 | 本质上也是整表重写 |
-- 观察:连续两次 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; 第一次查询 第二次查询(HOT 更新后)
ctid | xmin → ctid | xmin
-------+------ → -------+------
(0,2) | 761 → (0,2) | 763 ← ctid 没变,xmin 变了
若某次更新后 ctid 变了,说明该次走了非 HOT 路径用 ctid 定位元组并手工「查看」可见性
ctid 除了被系统内部使用,还可以作为查询条件。这在诊断时非常有用——你可以绕过可见性规则,直接看到物理元组:
-- 用 ctid 精确定位一个元组(不加别的条件)
SELECT ctid, xmin, xmax, note FROM chain_demo WHERE ctid = '(0,1)';这条查询会返回旧版本(因为 WHERE ctid = ... 是特殊的 TidScan,它仍然遵守可见性,但定位是精确的)。如果旧版本对当前快照不可见,结果就是空。想无视可见性,必须用 pageinspect:
-- 无视可见性,把页里所有元组都倒出来
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; 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) | livecmin / cmax:同一事务内的顺序
如果两条 SQL 在同一个事务里执行,xmin 可能相同(同一个 XID),此时靠什么区分先后?答案是 cmin / cmax(命令 ID):
cmin:本事务内产生该版本的命令序号
cmax:本事务内删除该版本的命令序号例如在同一个事务里插入再删除,元组的 xmin = xmax = 同一个 XID,只有 cmin < cmax 才能说明「是插入之后才删的」。pg 13 之后这两者被合并存进 t_cid 字段(t_cid = (cmax << 16) | cmin)。
-- 同一事务内的插入+删除,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 回收」的全过程。
准备环境:
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; heap_size | index_size
-----------+------------
64 kB | 64 kB第 1 轮:更新无索引列(应走 HOT)
-- 循环更新 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; heap_size | index_size
-----------+------------
176 kB | 64 kB ← 堆变大了,索引没变第 2 轮:更新索引列(HOT 必然失败)
-- 循环更新 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; heap_size | index_size
-----------+------------
320 kB | 256 kB ← 索引涨到 4 倍第 3 轮:VACUUM 回收
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; 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 重排。不要把它写进业务表。
-- 不推荐:把 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 命中率是否健康。
-- 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 顺便也会清理链上的死元组。
- 诊断膨胀时同时看「堆大小」「索引大小」「死元组数」「最老事务年龄」四个指标,单看任何一个都会误判。
下一节
继续学习:可见性判断规则