Skip to content

可见性判断规则(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,小于它的事务都已结束
快照 xmaxxmax(快照)下一个将分配的 XID,大于等于它的都还没开始
进行中列表xip_list快照时刻正在运行的事务 ID
提示位Hint Bits缓存在 t_infomask 里的「xmin/xmax 是否已提交」标志
已提交CommittedCLOG 中状态为 TRANSACTION_STATUS_COMMITTED
已回滚AbortedCLOG 中状态为 TRANSACTION_STATUS_ABORTED
进行中In Progress事务尚未结束,CLOG 中为 IN_PROGRESS
子事务Subtransaction嵌套在事务中的 SAVEPOINT,有独立 XID
命令 IDcmin / cmax同一事务内的语句序号,用于区分同事务内的先后
驱逐PruningPG 14+ 在读取时顺手清理页内死元组的优化

判断所需要的全部输入 ​

可见性判断只依赖两组数据,且都在内存中:

text
输入 A:元组头(来自页里)
  t_xmin         创建者 XID
  t_xmax         删除者 XID(0 = 没被删)
  t_infomask     提示位(xmin/xmax 是否已提交/回滚/冻结)
  t_infomask2    属性个数、HOT 标志

输入 B:快照(来自事务上下文)
  snapshot->xmin     水位线
  snapshot->xmax     下一个将分配的 XID
  snapshot->xip[]    进行中事务列表
text
输出:一个布尔值
  true  → 元组对当前快照可见,返回给上层
  false → 元组被跳过(但可能仍在磁盘上占空间)

三种「事务状态」的判定优先级 ​

判断任何 XID 的状态时,PostgreSQL 都按下面的顺序尽量少做事:

优先级手段成本什么时候有效
1快照比较(水位线)几次整数比较XID 明确小于水位线或大于等于 xmax 时
2进行中列表(xip_list)数组查找XID 落在水位线与 xmax 之间时
3Hint Bits(t_infomask)一次位运算提示位已经被设置过时
4查 CLOG(pg_xact)一次共享缓冲区读以上都不确定时(首次判断)

这条「从快到慢」的链条解释了为什么 Hint Bits 如此重要:它把第 4 步的结果缓存进了元组本身,让后续所有判断都停在 1~3 步。

核心机制 ​

创建者检查(xmin 是否可见) ​

第一步永远是问:这个版本是谁创建的?那个人提交了吗?

sql
-- 伪代码:判断创建者是否已提交
-- 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 是否生效) ​

只有创建者检查通过后,才轮到第二步:这个版本被谁删除了?删除生效了吗?

sql
-- 伪代码:判断删除者是否已生效
-- 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 每扫一行都要跑一遍的逻辑:

text
            拿到一个元组
                 │
                 ▼
    ┌────────────────────────────┐
    │ ① xmin 是否等于当前事务?   │──是──► 可见(自己写的)
    └────────────┬───────────────┘
                 │否
                 ▼
    ┌────────────────────────────┐
    │ ② xmin 在快照建立时还没开始?│──是──► 不可见
    │   (xmin >= snapshot->xmax) │
    └────────────┬───────────────┘
                 │否
                 ▼
    ┌────────────────────────────┐
    │ ③ xmin 在 xip_list 里?     │──是──► 不可见
    └────────────┬───────────────┘
                 │否
                 ▼
    ┌────────────────────────────┐
    │ ④ 查提示位 / CLOG:xmin    │──回滚─► 不可见
    │    提交了吗?               │
    └────────────┬───────────────┘
                 │已提交
                 ▼
    ┌────────────────────────────┐
    │ ⑤ xmax 无效(0)?          │──是──► 可见(活元组)
    └────────────┬───────────────┘
                 │否
                 ▼
    ┌────────────────────────────┐
    │ ⑥ xmax 未提交/已回滚/       │──是──► 可见(删除未生效)
    │    在快照后才开始?          │
    └────────────┬───────────────┘
                 │否
                 ▼
              不可见(已被删除)

判断顺序不能颠倒

如果先判断 xmax 再判断 xmin,会把「未来事务插入的、并且已经被未来事务删除的元组」误判为不可见——虽然结果碰巧一致,但当 xmax 属于当前事务时会出错(因为「自己删的」对别人不可见,对自己还要看 cmin/cmax)。先 xmin 后 xmax 是必须的顺序。

五个典型场景的逐一推演 ​

下面用一个统一的快照 745:760:746,748 推演五种元组:

text
快照:xmin=745,xmax=760,xip_list=[746, 748]
含义:745 之前的都结束了;746、748 还在跑;760 及之后的还没开始
场景元组头判定过程结果
A. 老元组已提交xmin=700, xmax=0700 < 745,非进行中,已提交;xmax=0可见
B. 老元组已被旧事务删除xmin=700, xmax=730xmin 已提交;xmax=730 已提交且 < 745不可见
C. 进行中事务插入xmin=746, xmax=0746 在 xip_list 中不可见
D. 未来事务插入xmin=765, xmax=0765 >= 760不可见
E. 进行中事务删除xmin=700, xmax=748xmin 已提交;xmax=748 未提交可见

场景 E 最能体现 MVCC 的价值:删除者还在跑,删除尚未对任何人生效,所以元组依然可见。

sql
-- 场景 E 的可复现实验:会话 A 删而不提交,会话 B 仍能查到
-- 会话 A:
BEGIN;
DELETE FROM vis_demo WHERE id = 1;   -- 不提交

-- 会话 B(另一个连接):
SELECT * FROM vis_demo WHERE id = 1; -- 仍然返回该行!

相关参数 ​

可见性判断本身没有直接可调参数,但以下参数影响它的性能与结果边界:

参数默认值生效方式说明
track_commit_timestampoff重启记录提交时间戳,可用于 pg_xact_commit_timestamp() 反查
old_snapshot_threshold-1重启允许回收「对老快照仍可见」的元组,代价是可能报 snapshot too old
idle_in_transaction_session_timeout0user回收长期 idle 的进行中事务,缩小 xip_list
transaction_timeout0user事务总时长上限(PG17+)
enable_seqscanonuser顺序扫描要判断每个元组,关闭可强制走索引
enable_indexonlyscanonuser依赖 VM 跳过可见性判断
vacuum_defer_cleanup_age0user保留多少事务内不清理,会延长旧版本存活(仅主库有效)
hot_standby_feedbackoffuser备库反馈最老快照,避免主库回收备库仍需要的版本
ini
# postgresql.conf
# 可见性判断是纯内存操作,不需要为它调参;
# 但下面两项决定了"有多少元组要判断"和"旧版本能活多久"
old_snapshot_threshold = -1                 # 保持关闭,避免 snapshot too old
idle_in_transaction_session_timeout = 10min # 防止 xip_list 被长时间占用
sql
-- 查看当前所有事务的快照信息(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)的副作用 ​

提示位是可见性判断最重要的优化,但它有一个常被忽略的副作用:设置提示位本身是一次写操作。

text
第一次判断某个元组时:
  ① 提示位是空的 → 必须查 CLOG
  ② 查到"已提交" → 顺便在元组头上写下 HEAP_XMIN_COMMITTED
  ③ 这一次写入会让页变"脏",进而产生 WAL 与磁盘写

后果:一条纯 SELECT 也可能造成磁盘写入(首次访问冷数据时)

这解释了两个常见现象:

  • 只读查询也可能产生少量 WAL,尤其是首次扫描大量冷数据时。
  • pg_stat_user_tables 里 n_tup_newpage_upd 等指标在只读库上也非零;而只读从库会把提示位写进自己的数据文件(这是允许的例外)。
sql
-- 观察提示位是否已被设置(对比刚插入与已被扫描过的元组)
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读取时顺手清理
页内碎片越读越多读取即整理
表膨胀速度快明显变慢
额外成本无读取时需要判断能否整页收紧
sql
-- 观察 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:

sql
-- 需要回表判断可见性时,Heap Fetches 会很大
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM vis_demo WHERE id < 1000;
text
 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
sql
-- 先 VACUUM 设置 VM,再跑一次,Heap Fetches 归零
VACUUM vis_demo;

EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM vis_demo WHERE id < 1000;
text
 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子事务状态为回滚可见
sql
-- 演示同事务内的 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 扩展从页级看可见性,它回答的是「这个页/这个元组是否对所有事务都可见」,与逐行的快照判断互为补充:

sql
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;
text
 blkno | all_visible | all_frozen
-------+-------------+------------
     0 | t           | f
     1 | t           | f
     2 | f           | f
sql
-- 统计全可见页比例
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):

sql
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;
text
  snapshot_before
------------------
 800:805:801

 ctid  | xmin | xmax |  note
-------+------+------+----------
 (0,1) |  799 |    0 | old-row

规则 A:老元组已提交 → 可见(已验证,上面的查询返回了行)

规则 B:老元组被旧事务删除 → 不可见

sql
-- 会话 A:删除并提交
BEGIN;
DELETE FROM vis_check WHERE id = 1;
SELECT pg_current_xact_id() AS deleter_xid;   -- 记录删除者 XID
COMMIT;
text
 deleter_xid
-------------
         802
sql
-- 会话 A:新快照下查询
SELECT * FROM vis_check;              -- 返回 0 行(规则 B 生效)
SELECT lp, t_xmin, t_xmax FROM heap_page_items(get_raw_page('vis_check', 0));
text
 lp | t_xmin | t_xmax
----+--------+--------
  1 |    799 |    802     ← xmax=802 已提交,故不可见

规则 C:进行中事务插入 → 不可见

sql
-- 会话 A:插入但不提交
BEGIN;
INSERT INTO vis_check VALUES (2, 'uncommitted');
SELECT pg_current_xact_id() AS inserter_xid;
-- 不要 COMMIT
text
 inserter_xid
--------------
          803
sql
-- 会话 B(另一个连接,需另开终端执行):
SELECT pg_current_snapshot() AS snapshot_b;   -- 803 将出现在 xip_list 中
SELECT * FROM vis_check WHERE id = 2;         -- 返回 0 行
text
 snapshot_b
---------------
 799:804:803

(第二行查询返回 0 行)

关键证据:快照的 xip_list 里出现了 803,正是那个未提交的插入者。规则 C 生效。

规则 D:未来事务插入 → 不可见

sql
-- 会话 B:先建快照
SELECT pg_current_snapshot() AS snapshot_c;
text
 snapshot_c
---------------
 799:804:803
sql
-- 会话 A:提交插入(XID 803 提交),然后又一个新事务插入 804
COMMIT;                                        -- 提交 803
BEGIN;
INSERT INTO vis_check VALUES (3, 'future');
SELECT pg_current_xact_id() AS future_xid;     -- 应该是 804
COMMIT;
text
 future_xid
------------
        804
sql
-- 会话 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:进行中事务删除 → 仍然可见

sql
-- 会话 A:删除 id=2 但不提交
BEGIN;
DELETE FROM vis_check WHERE id = 2;
SELECT pg_current_xact_id() AS deleter2_xid;   -- 记录 XID
-- 不提交
sql
-- 会话 B(默认 READ COMMITTED,每次查询取新快照):
SELECT * FROM vis_check ORDER BY id;   -- id=2 仍然可见!

结果解读: 删除者对 id = 2 写下了 t_xmax = <未提交 XID>,但该 XID 在 CLOG 中仍是「进行中」,命中规则 E → 删除未生效 → 元组依然可见。这正是「读写不互相阻塞」的直接体现。

收尾:

sql
-- 会话 A:提交删除
COMMIT;

-- 会话 B:现在再看,id=2 消失了
SELECT * FROM vis_check ORDER BY id;

注意事项 ​

  • 可见性判断只看元组头与快照,不看任何目录表,因此它极快;性能瓶颈往往来自「要判断的元组太多」(顺序扫描)而不是判断本身。
  • ctid 定位仍受可见性约束:WHERE ctid = '(0,1)' 若该元组不可见,返回空行。
sql
-- 不推荐:以为用 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:
sql
-- 找出所有还"钉住"旧版本的事务(水位线来源)
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 与空间回收

参考链接 ​