可见性判断规则(Visibility Rules)
前两节分别讲了「判断者」(XID 与快照)和「被判断者」(元组版本)。本节把它们合起来,回答 MVCC 最核心的问题:给定一个元组和一个快照,这个元组到底可不可见? 这套规则完全由元组头部的三个字段(xmin、xmax、t_infomask)和快照的四个字段(xmin、xmax、xip_list)决定,全部是纯内存计算,不查任何表。
前置知识
阅读本节前,建议先了解:元组版本与 ctid
基础概念
可见性判断(Visibility Check) 是 PostgreSQL 在执行每一行扫描时,判断「这个物理元组对当前事务的快照是否可见」的过程。它由 HeapTupleSatisfiesMVCC() 等函数实现,是数据库中最热的代码路径之一。
理解可见性规则需要先统一以下术语:
| 术语 | 英文 | 说明 |
|---|---|---|
| 可见性判断 | Visibility Check | 决定一个元组版本对当前快照可见与否的过程 |
| 快照 | Snapshot | 由 xmin/xmax/xip_list 组成的判断基准 |
| 水位线 | xmin(快照) | 所有活跃事务中最小的 XID,小于它的事务都已结束 |
| 快照 xmax | xmax(快照) | 下一个将分配的 XID,大于等于它的都还没开始 |
| 进行中列表 | xip_list | 快照时刻正在运行的事务 ID |
| 提示位 | Hint Bits | 缓存在 t_infomask 里的「xmin/xmax 是否已提交」标志 |
| 已提交 | Committed | CLOG 中状态为 TRANSACTION_STATUS_COMMITTED |
| 已回滚 | Aborted | CLOG 中状态为 TRANSACTION_STATUS_ABORTED |
| 进行中 | In Progress | 事务尚未结束,CLOG 中为 IN_PROGRESS |
| 子事务 | Subtransaction | 嵌套在事务中的 SAVEPOINT,有独立 XID |
| 命令 ID | cmin / cmax | 同一事务内的语句序号,用于区分同事务内的先后 |
| 驱逐 | Pruning | PG 14+ 在读取时顺手清理页内死元组的优化 |
判断所需要的全部输入
可见性判断只依赖两组数据,且都在内存中:
输入 A:元组头(来自页里)
t_xmin 创建者 XID
t_xmax 删除者 XID(0 = 没被删)
t_infomask 提示位(xmin/xmax 是否已提交/回滚/冻结)
t_infomask2 属性个数、HOT 标志
输入 B:快照(来自事务上下文)
snapshot->xmin 水位线
snapshot->xmax 下一个将分配的 XID
snapshot->xip[] 进行中事务列表输出:一个布尔值
true → 元组对当前快照可见,返回给上层
false → 元组被跳过(但可能仍在磁盘上占空间)三种「事务状态」的判定优先级
判断任何 XID 的状态时,PostgreSQL 都按下面的顺序尽量少做事:
| 优先级 | 手段 | 成本 | 什么时候有效 |
|---|---|---|---|
| 1 | 快照比较(水位线) | 几次整数比较 | XID 明确小于水位线或大于等于 xmax 时 |
| 2 | 进行中列表(xip_list) | 数组查找 | XID 落在水位线与 xmax 之间时 |
| 3 | Hint Bits(t_infomask) | 一次位运算 | 提示位已经被设置过时 |
| 4 | 查 CLOG(pg_xact) | 一次共享缓冲区读 | 以上都不确定时(首次判断) |
这条「从快到慢」的链条解释了为什么 Hint Bits 如此重要:它把第 4 步的结果缓存进了元组本身,让后续所有判断都停在 1~3 步。
核心机制
创建者检查(xmin 是否可见)
第一步永远是问:这个版本是谁创建的?那个人提交了吗?
-- 伪代码:判断创建者是否已提交
-- 1) 若 xmin 是 "当前事务自己",则可见(自己的写入自己看得到)
-- 2) 若 snapshot->xmin 与 xmin 比较发现 xmin 还没开始(xmin >= snapshot->xmax)
-- → 不可见(未来事务写的)
-- 3) 若 xmin 在 xip_list 里 → 不可见(写入者还在跑)
-- 4) 查提示位:HEAP_XMIN_COMMITTED → 可见
-- HEAP_XMIN_INVALID → 不可见
-- 5) 查 CLOG 定论,并把结果写回提示位用一张表总结:
t_xmin 状态 | 结果 | 原因 |
|---|---|---|
| 等于当前事务的 XID | 可见 | 自己创建的自己看得到 |
| 小于快照水位线且已提交 | 可见 | 在快照之前就完成了 |
| 小于快照水位线但已回滚 | 不可见 | 创建它的操作被撤销了 |
大于等于快照 xmax | 不可见 | 快照建立时它还没开始 |
在 xip_list 中 | 不可见 | 快照建立时它还在运行 |
已冻结(xmin = 2) | 可见 | 冻结元组永远可见 |
| 未提交且不在列表中(异常) | 不可见 | 保守处理 |
删除者检查(xmax 是否生效)
只有创建者检查通过后,才轮到第二步:这个版本被谁删除了?删除生效了吗?
-- 伪代码:判断删除者是否已生效
-- 1) xmax = 0 或 HEAP_XMAX_INVALID → 没人删它 → 可见
-- 2) xmax = 当前事务自己的 XID
-- → 需要看 cmin/cmax:若删除命令在插入命令之前,说明"删的是旧版本"
-- 3) xmax 未提交或已回滚 → 删除未生效 → 可见
-- 4) xmax 已提交
-- → 若 xmax 在 xip_list 中且是本事务自己 → 不可见
-- → 若 xmax 在快照之后才开始(>= snapshot->xmax)→ 可见(删除对我还没发生)
-- → 否则 → 不可见(已被别人删掉)t_xmax 状态 | 结果 | 原因 |
|---|---|---|
0 或 HEAP_XMAX_INVALID | 可见 | 从未被删除 |
| 已回滚的 XID | 可见 | 删除操作被撤销 |
| 未提交且在快照建立后才开始 | 可见 | 删除对当前快照「尚未发生」 |
| 已提交且在快照之前完成 | 不可见 | 已被删除 |
| 等于当前事务自己的 XID 且已提交 | 不可见 | 自己删的自己看得到 |
完整判定流程图
把两步串起来,就是 PostgreSQL 每扫一行都要跑一遍的逻辑:
拿到一个元组
│
▼
┌────────────────────────────┐
│ ① xmin 是否等于当前事务? │──是──► 可见(自己写的)
└────────────┬───────────────┘
│否
▼
┌────────────────────────────┐
│ ② xmin 在快照建立时还没开始?│──是──► 不可见
│ (xmin >= snapshot->xmax) │
└────────────┬───────────────┘
│否
▼
┌────────────────────────────┐
│ ③ xmin 在 xip_list 里? │──是──► 不可见
└────────────┬───────────────┘
│否
▼
┌────────────────────────────┐
│ ④ 查提示位 / CLOG:xmin │──回滚─► 不可见
│ 提交了吗? │
└────────────┬───────────────┘
│已提交
▼
┌────────────────────────────┐
│ ⑤ xmax 无效(0)? │──是──► 可见(活元组)
└────────────┬───────────────┘
│否
▼
┌────────────────────────────┐
│ ⑥ xmax 未提交/已回滚/ │──是──► 可见(删除未生效)
│ 在快照后才开始? │
└────────────┬───────────────┘
│否
▼
不可见(已被删除)判断顺序不能颠倒
如果先判断 xmax 再判断 xmin,会把「未来事务插入的、并且已经被未来事务删除的元组」误判为不可见——虽然结果碰巧一致,但当 xmax 属于当前事务时会出错(因为「自己删的」对别人不可见,对自己还要看 cmin/cmax)。先 xmin 后 xmax 是必须的顺序。
五个典型场景的逐一推演
下面用一个统一的快照 745:760:746,748 推演五种元组:
快照:xmin=745,xmax=760,xip_list=[746, 748]
含义:745 之前的都结束了;746、748 还在跑;760 及之后的还没开始| 场景 | 元组头 | 判定过程 | 结果 |
|---|---|---|---|
| A. 老元组已提交 | xmin=700, xmax=0 | 700 < 745,非进行中,已提交;xmax=0 | 可见 |
| B. 老元组已被旧事务删除 | xmin=700, xmax=730 | xmin 已提交;xmax=730 已提交且 < 745 | 不可见 |
| C. 进行中事务插入 | xmin=746, xmax=0 | 746 在 xip_list 中 | 不可见 |
| D. 未来事务插入 | xmin=765, xmax=0 | 765 >= 760 | 不可见 |
| E. 进行中事务删除 | xmin=700, xmax=748 | xmin 已提交;xmax=748 未提交 | 可见 |
场景 E 最能体现 MVCC 的价值:删除者还在跑,删除尚未对任何人生效,所以元组依然可见。
-- 场景 E 的可复现实验:会话 A 删而不提交,会话 B 仍能查到
-- 会话 A:
BEGIN;
DELETE FROM vis_demo WHERE id = 1; -- 不提交
-- 会话 B(另一个连接):
SELECT * FROM vis_demo WHERE id = 1; -- 仍然返回该行!相关参数
可见性判断本身没有直接可调参数,但以下参数影响它的性能与结果边界:
| 参数 | 默认值 | 生效方式 | 说明 |
|---|---|---|---|
track_commit_timestamp | off | 重启 | 记录提交时间戳,可用于 pg_xact_commit_timestamp() 反查 |
old_snapshot_threshold | -1 | 重启 | 允许回收「对老快照仍可见」的元组,代价是可能报 snapshot too old |
idle_in_transaction_session_timeout | 0 | user | 回收长期 idle 的进行中事务,缩小 xip_list |
transaction_timeout | 0 | user | 事务总时长上限(PG17+) |
enable_seqscan | on | user | 顺序扫描要判断每个元组,关闭可强制走索引 |
enable_indexonlyscan | on | user | 依赖 VM 跳过可见性判断 |
vacuum_defer_cleanup_age | 0 | user | 保留多少事务内不清理,会延长旧版本存活(仅主库有效) |
hot_standby_feedback | off | user | 备库反馈最老快照,避免主库回收备库仍需要的版本 |
# postgresql.conf
# 可见性判断是纯内存操作,不需要为它调参;
# 但下面两项决定了"有多少元组要判断"和"旧版本能活多久"
old_snapshot_threshold = -1 # 保持关闭,避免 snapshot too old
idle_in_transaction_session_timeout = 10min # 防止 xip_list 被长时间占用-- 查看当前所有事务的快照信息(backend_xmin 是水位线的主要来源)
SELECT pid, state, backend_xmin, xact_start, query_start,
now() - xact_start AS xact_age
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY backend_xmin
LIMIT 10;详细说明
提示位(Hint Bits)的副作用
提示位是可见性判断最重要的优化,但它有一个常被忽略的副作用:设置提示位本身是一次写操作。
第一次判断某个元组时:
① 提示位是空的 → 必须查 CLOG
② 查到"已提交" → 顺便在元组头上写下 HEAP_XMIN_COMMITTED
③ 这一次写入会让页变"脏",进而产生 WAL 与磁盘写
后果:一条纯 SELECT 也可能造成磁盘写入(首次访问冷数据时)这解释了两个常见现象:
- 只读查询也可能产生少量 WAL,尤其是首次扫描大量冷数据时。
pg_stat_user_tables里n_tup_newpage_upd等指标在只读库上也非零;而只读从库会把提示位写进自己的数据文件(这是允许的例外)。
-- 观察提示位是否已被设置(对比刚插入与已被扫描过的元组)
SELECT lp, t_xmin, t_infomask,
(t_infomask & 256) <> 0 AS xmin_committed,
(t_infomask & 2048) <> 0 AS xmax_invalid
FROM heap_page_items(get_raw_page('vis_demo', 0))
ORDER BY lp;PG 14+ 的读取时驱逐(Pruning)
从 PostgreSQL 14 起,可见性判断多了一个「顺手清理」的能力:当一次读取发现页里有死元组时,可以直接把该页的元组整理一遍,把死元组占的空间标为可复用(HEAP_DEAD 或 LP_UNUSED),甚至把页标为全可见。这叫做 Pruning。
| 特性 | 传统行为(PG 13 及之前) | Pruning(PG 14+) |
|---|---|---|
| 死元组清理时机 | 只能等 VACUUM | 读取时顺手清理 |
| 页内碎片 | 越读越多 | 读取即整理 |
| 表膨胀速度 | 快 | 明显变慢 |
| 额外成本 | 无 | 读取时需要判断能否整页收紧 |
-- 观察 Pruning 效果:连续更新+读取后,死元组数增长变慢
UPDATE vis_demo SET note = 'r1';
SELECT count(*) FROM vis_demo; -- 这次读取可能触发 Pruning
SELECT relname, n_live_tup, n_dead_tup
FROM pg_stat_user_tables WHERE relname = 'vis_demo';Pruning 不能替代 VACUUM
Pruning 只在同一页内、且该页的所有元组都对某个已有快照不可见时才能收紧。它无法处理「长事务钉住旧版本」的情况,也无法把空间归还操作系统,更无法推进 relfrozenxid。VACUUM 依然是必需的。
可见性与索引扫描的交互
索引只存键值和 ctid,不存可见性信息。所以索引扫描拿到 ctid 后,必须回表做可见性判断。这就是「Index-Only Scan」为什么依赖 VM:
-- 需要回表判断可见性时,Heap Fetches 会很大
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM vis_demo WHERE id < 1000; Index Only Scan using vis_demo_pkey on vis_demo
(cost=... rows=... width=4) (actual time=... rows=999 loops=1)
Heap Fetches: 999 ← 每个元组都回表判断可见性了
Buffers: shared hit=12-- 先 VACUUM 设置 VM,再跑一次,Heap Fetches 归零
VACUUM vis_demo;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM vis_demo WHERE id < 1000; Index Only Scan using vis_demo_pkey on vis_demo
Heap Fetches: 0 ← VM 告诉它"整页可见",跳过判断可见性对「自己事务」的特殊处理
同事务内的操作必须能互相看见,否则 INSERT 后立刻 SELECT 会查不到。规则的关键在于 cmin/cmax 命令序号:
| 情形 | 判定依据 | 结果 |
|---|---|---|
| 自己插入,同事务内查询 | t_xmin = 自己,cmin < 当前命令号 | 可见 |
| 自己插入,同事务内删除后再查 | t_xmin = t_xmax = 自己,cmax < 当前命令号 | 不可见 |
| 自己删除,同事务内再查 | t_xmax = 自己,cmax < 当前命令号 | 不可见 |
| 自己删除后又回滚到 SAVEPOINT | 子事务状态为回滚 | 可见 |
-- 演示同事务内的 cmin/cmax 效果
BEGIN;
INSERT INTO vis_demo (id, note) VALUES (50, 'first');
SELECT ctid, xmin, xmax FROM vis_demo WHERE id = 50; -- 可见
DELETE FROM vis_demo WHERE id = 50;
SELECT ctid, xmin, xmax FROM vis_demo WHERE id = 50; -- 不可见
COMMIT;用 pg_visibility 检查「可见性」的另一面
pg_visibility 扩展从页级看可见性,它回答的是「这个页/这个元组是否对所有事务都可见」,与逐行的快照判断互为补充:
CREATE EXTENSION IF NOT EXISTS pg_visibility;
-- 页级全可见位图(1 = 该页所有元组对所有事务可见)
SELECT blkno, all_visible, all_frozen
FROM pg_visibility_map('vis_demo')
ORDER BY blkno
LIMIT 10; blkno | all_visible | all_frozen
-------+-------------+------------
0 | t | f
1 | t | f
2 | f | f-- 统计全可见页比例
SELECT count(*) FILTER (WHERE all_visible) AS visible_pages,
count(*) AS total_pages,
round(100.0 * count(*) FILTER (WHERE all_visible) / count(*), 2) AS visible_pct
FROM pg_visibility_map('vis_demo');实战示例
示例:用两个会话完整验证五条可见性规则
这个示例逐条验证上文表格中的规则,构造可复现的证据。
准备环境(会话 A):
DROP TABLE IF EXISTS vis_check;
CREATE TABLE vis_check (id integer PRIMARY KEY, note text);
INSERT INTO vis_check VALUES (1, 'old-row');
VACUUM vis_check; -- 让 (1) 成为"老元组已提交"
-- 查看基线快照
SELECT pg_current_snapshot() AS snapshot_before;
SELECT ctid, xmin, xmax, note FROM vis_check; snapshot_before
------------------
800:805:801
ctid | xmin | xmax | note
-------+------+------+----------
(0,1) | 799 | 0 | old-row规则 A:老元组已提交 → 可见(已验证,上面的查询返回了行)
规则 B:老元组被旧事务删除 → 不可见
-- 会话 A:删除并提交
BEGIN;
DELETE FROM vis_check WHERE id = 1;
SELECT pg_current_xact_id() AS deleter_xid; -- 记录删除者 XID
COMMIT; deleter_xid
-------------
802-- 会话 A:新快照下查询
SELECT * FROM vis_check; -- 返回 0 行(规则 B 生效)
SELECT lp, t_xmin, t_xmax FROM heap_page_items(get_raw_page('vis_check', 0)); lp | t_xmin | t_xmax
----+--------+--------
1 | 799 | 802 ← xmax=802 已提交,故不可见规则 C:进行中事务插入 → 不可见
-- 会话 A:插入但不提交
BEGIN;
INSERT INTO vis_check VALUES (2, 'uncommitted');
SELECT pg_current_xact_id() AS inserter_xid;
-- 不要 COMMIT inserter_xid
--------------
803-- 会话 B(另一个连接,需另开终端执行):
SELECT pg_current_snapshot() AS snapshot_b; -- 803 将出现在 xip_list 中
SELECT * FROM vis_check WHERE id = 2; -- 返回 0 行 snapshot_b
---------------
799:804:803
(第二行查询返回 0 行)关键证据:快照的 xip_list 里出现了 803,正是那个未提交的插入者。规则 C 生效。
规则 D:未来事务插入 → 不可见
-- 会话 B:先建快照
SELECT pg_current_snapshot() AS snapshot_c; snapshot_c
---------------
799:804:803-- 会话 A:提交插入(XID 803 提交),然后又一个新事务插入 804
COMMIT; -- 提交 803
BEGIN;
INSERT INTO vis_check VALUES (3, 'future');
SELECT pg_current_xact_id() AS future_xid; -- 应该是 804
COMMIT; future_xid
------------
804-- 会话 B:用 session 内保留的旧快照判断(用 REPEATABLE READ 固定快照)
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT pg_current_snapshot() AS snapshot_d; -- 固定成 799:804:803
SELECT * FROM vis_check ORDER BY id; -- 只看到 id=2,看不到 id=3
COMMIT;结果解读:
snapshot_d = 799:804:803,xmax = 804表示 804 及之后的事务全部"还没开始"。id = 3由 XID 804 插入,804 >= 804,命中规则 D → 不可见。id = 2由 XID 803 插入,803 < 804且不在新的xip_list里(803 已提交,快照建立时它不在运行中)→ 可见。- 这就是同一份物理数据,在不同快照下呈现不同结果的完整证据链。
规则 E:进行中事务删除 → 仍然可见
-- 会话 A:删除 id=2 但不提交
BEGIN;
DELETE FROM vis_check WHERE id = 2;
SELECT pg_current_xact_id() AS deleter2_xid; -- 记录 XID
-- 不提交-- 会话 B(默认 READ COMMITTED,每次查询取新快照):
SELECT * FROM vis_check ORDER BY id; -- id=2 仍然可见!结果解读: 删除者对 id = 2 写下了 t_xmax = <未提交 XID>,但该 XID 在 CLOG 中仍是「进行中」,命中规则 E → 删除未生效 → 元组依然可见。这正是「读写不互相阻塞」的直接体现。
收尾:
-- 会话 A:提交删除
COMMIT;
-- 会话 B:现在再看,id=2 消失了
SELECT * FROM vis_check ORDER BY id;注意事项
- 可见性判断只看元组头与快照,不看任何目录表,因此它极快;性能瓶颈往往来自「要判断的元组太多」(顺序扫描)而不是判断本身。
ctid定位仍受可见性约束:WHERE ctid = '(0,1)'若该元组不可见,返回空行。
-- 不推荐:以为用 ctid 就能"绕过"可见性
-- SELECT * FROM vis_check WHERE ctid = '(0,1)'; -- 不可见时返回 0 行- 提示位会写入磁盘,因此「纯只读负载」也可能产生少量 WAL 与脏页,不要因此误判配置有误。
HEAP_XMAX_IS_MULTI时xmax不是 XID 而是 MultiXact ID,需要额外查pg_multixact,判断逻辑更复杂(SELECT ... FOR SHARE并发时常见)。- 子事务(SAVEPOINT)的 XID 状态可能被合并:父事务提交时子事务状态会归并到父事务,
pg_subtrans是查询中介。 - Pruning 是「读取时的机会主义清理」,不要指望它替代 VACUUM,也不要因为它而关闭 autovacuum。
old_snapshot_threshold打破「可见性永不失效」的承诺,开启后旧快照可能读到已被回收的元组而报错;只有极少数长时间分析场景值得开启。- 备库上的可见性判断依赖主库的信息:
hot_standby_feedback = off时,主库可能回收备库查询仍需的元组(备库报canceling statement due to conflict with recovery)。 - 提示位不会出现在 WAL 重放里(PG 9.4+),这是为了减小 WAL 体积;备库的提示位由备库自己首次访问时设置。
最佳实践
- 把「可见性判断」理解为「快照 + 元组头」的纯函数,遇到「数据查不到 / 数据重复出现」类问题时,先查
backend_xmin与长事务,而不是怀疑数据损坏。 - 排查异常可见性问题的第一招是看
pg_stat_activity.backend_xmin:
-- 找出所有还"钉住"旧版本的事务(水位线来源)
SELECT pid, state, backend_xmin,
now() - xact_start AS xact_age,
left(query, 60) AS query_head
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY backend_xmin
LIMIT 20;- 给应用设置
idle_in_transaction_session_timeout,让「忘了提交」的事务自动断开,缩小xip_list的存活时间。 - 定期 VACUUM 让 VM 生效,Index-Only Scan 才能跳过可见性判断,
Heap Fetches才能归零。 - 依赖 PG 14+ 的 Pruning 减缓膨胀,但不要指望它;高频更新表仍应配好 autovacuum 参数。
- 不使用
old_snapshot_threshold,除非有明确的「长期只读分析 + 可容忍报错」场景。 - 主从架构下谨慎设置
hot_standby_feedback:开启能避免备库查询被杀,代价是主库更难回收旧版本。可用max_standby_streaming_delay折中。 - 不要用
SELECT ... FOR UPDATE代替业务级并发控制,它会产生 MultiXact,让可见性判断变复杂、让pg_multixact变胖。 - 理解「同事务可见性」由
cmin/cmax决定,在写触发器等场景中,同一语句内看到的数据是「语句开始时的快照」,需要用FOR UPDATE或UPDATE ... RETURNING显式获取最新版本。 - 诊断「查不到刚写入的数据」时按顺序排查:是否跨连接(快照不同)→ 是否未提交 → 是否 REPEATABLE READ 固定了旧快照 → 是否有触发器/RLS 过滤。
下一节
继续学习:VACUUM 与空间回收