跳转到内容

快照:可见性判断的完整规则

准备中…

前面几节一直在用「可见」这个词。这一节把它拆开:Postgres 到底怎么判断某个元组对当前事务可见。

SELECT pg_current_snapshot()::text                            AS 快照原文,
     pg_snapshot_xmin(pg_current_snapshot())::text          AS xmin,
     pg_snapshot_xmax(pg_current_snapshot())::text          AS xmax,
     ARRAY(SELECT pg_snapshot_xip(pg_current_snapshot()))::text AS xip_进行中的事务;
⌘/Ctrl + Enter

pg_snapshot_xip 是返回集合的函数,直接放在 SELECT 列表里会让整个查询返回 0 行——必须用 ARRAY(SELECT ...) 包起来。)

快照的文本格式是 xmin:xmax:xip1,xip2,...,三个部分的含义:

字段 含义
xmin 所有小于它的事务都已结束(提交或回滚)
xmax 所有大于等于它的事务在快照建立时还不存在
xip 处于 [xmin, xmax) 区间、但在快照建立时仍在进行中的事务列表

有了这三样,判断一个事务 X 在此快照下算不算「已提交」就是纯粹的算术:

X < xmin → 已结束(再查提交状态即可)
X >= xmax → 未来的事务,不可见
xmin <= X < xmax → 看 X 在不在 xip 列表里;在 = 进行中不可见,不在 = 已结束

拿到快照后,对每个元组走这套判断(简化版,忽略子事务和锁):

flowchart TD
  s(["取到一个元组"]) --> q1{"xmin 对应的事务<br/>在快照下已提交?"}
  q1 -- 否 --> n1["不可见<br/>还没真正插入,或插入方回滚了"]
  q1 -- 是 --> q2{"xmax = 0 ?"}
  q2 -- 是 --> y1["可见<br/>没人删过它"]
  q2 -- 否 --> q3{"xmax 对应的事务<br/>在快照下已提交?"}
  q3 -- 是 --> n2["不可见<br/>快照建立前就被删了"]
  q3 -- 否 --> y2["可见<br/>删除方还没提交,或已回滚"]

  style s fill:#8a8f9c,color:#fff,stroke:none
  style q1 fill:#2a6796,color:#fff,stroke:none
  style q2 fill:#2a6796,color:#fff,stroke:none
  style q3 fill:#2a6796,color:#fff,stroke:none
  style y1 fill:#3b8f6d,color:#fff,stroke:none
  style y2 fill:#3b8f6d,color:#fff,stroke:none
  style n1 fill:#b5504e,color:#fff,stroke:none
  style n2 fill:#b5504e,color:#fff,stroke:none

而「在快照下已提交」这个判断,就是把 xid 和快照的三个字段做算术比较:

flowchart LR
  a["xid &lt; xmin<br/>已结束<br/>再查提交状态即可"]
  b["xmin ≤ xid &lt; xmax<br/>看 xid 在不在 xip 列表里<br/>在 = 进行中 · 不在 = 已结束"]
  c["xid ≥ xmax<br/>快照建立时还不存在<br/>一律不可见"]
  a --- b --- c

  style a fill:#3b8f6d,color:#fff,stroke:none
  style b fill:#d8933a,color:#fff,stroke:none
  style c fill:#8a8f9c,color:#fff,stroke:none

阶段三第一节看到的那两个元组,现在可以完整解释了:旧版本 xmax = 753 且事务 753 已提交,所以规则 3 判它不可见;新版本 xmin = 753(已提交)、xmax = 0,规则 2 判它可见。

一个容易忽略但影响很大的细节:只读事务不消耗事务号

BEGIN;
SELECT pg_current_xact_id_if_assigned() IS NULL AS 还没分配事务号;
⌘/Ctrl + Enter
SELECT count(*) FROM orders WHERE user_id = 42;
SELECT pg_current_xact_id_if_assigned() IS NULL AS 查询之后仍未分配;
⌘/Ctrl + Enter

只有真正写入时才会分配:

CREATE TEMP TABLE IF NOT EXISTS t_probe (x int);
INSERT INTO t_probe VALUES (1);
SELECT pg_current_xact_id_if_assigned()::text AS 现在分配了;
⌘/Ctrl + Enter
ROLLBACK;
⌘/Ctrl + Enter

在此之前,事务用的是虚拟事务号backendID/localXID),它不占用全局 xid 空间。

这件事的意义:

  • 一个只跑 SELECT 的只读副本或报表库,完全不推进 xid,也就完全不产生回卷压力;
  • 只读长事务照样会阻止 VACUUM——因为阻止清理的是「快照」,不是「事务号」。

每个 SAVEPOINT 都会开启一个子事务,并分配自己的事务号。

pg_current_xact_id() 看不到这件事——它始终返回顶层事务号。但元组的 xmin 系统列会暴露真相:

DROP TABLE IF EXISTS sub_demo;
CREATE TABLE sub_demo (id int, tag text);
BEGIN;
INSERT INTO sub_demo VALUES (1, '顶层事务写的');
SELECT pg_current_xact_id()::text AS 顶层事务号;
⌘/Ctrl + Enter
SAVEPOINT s1;
INSERT INTO sub_demo VALUES (2, '第一个 SAVEPOINT 里写的');
SAVEPOINT s2;
INSERT INTO sub_demo VALUES (3, '第二个 SAVEPOINT 里写的');
SELECT pg_current_xact_id()::text AS "pg_current_xact_id 仍返回顶层";
⌘/Ctrl + Enter
COMMIT;
⌘/Ctrl + Enter
SELECT id, tag, xmin::text AS 这行实际的事务号 FROM sub_demo ORDER BY id;
⌘/Ctrl + Enter

三行的 xmin三个连续但不同的事务号——顶层一个,每个 SAVEPOINT 各一个。它们都属于同一个顶层事务,一起提交、一起回滚。

SAVEPOINT 的价值在于部分回滚

DROP TABLE IF EXISTS sp;
CREATE TABLE sp (id int);
BEGIN;
INSERT INTO sp VALUES (1);
SAVEPOINT a;
INSERT INTO sp VALUES (2);
ROLLBACK TO SAVEPOINT a;
INSERT INTO sp VALUES (3);
COMMIT;
SELECT id FROM sp ORDER BY id;
⌘/Ctrl + Enter

只有 2 被撤销了。

代价是可见性判断变复杂:判断一个 xid 是否提交时,要先查它是不是某个事务的子事务,再去看顶层事务的状态。

这个映射关系缓存在每个后端的 PGPROC 里,但只有 64 个槽位。一旦某个事务的子事务超过 64 个,缓存溢出(subxid overflow),所有其他会话的可见性判断都要退回去查磁盘上的 pg_subtrans——整个实例的性能会断崖式下跌

现在可以给出隔离级别的精确定义了——它们的区别只在于什么时候取快照

隔离级别 取快照的时机
Read Committed 每条语句开始时取一个新的
Repeatable Read 事务的第一条语句时取一个,之后一直用它
Serializable 同 Repeatable Read,外加读写依赖跟踪
BEGIN;
SELECT pg_current_snapshot()::text AS 第一条语句的快照;
⌘/Ctrl + Enter
SELECT pg_current_snapshot()::text AS 第二条语句的快照;
⌘/Ctrl + Enter
COMMIT;
⌘/Ctrl + Enter

在默认的 Read Committed 下,两次调用可能拿到不同的快照(单连接环境里看不出差别,因为没有其他事务在推进 xid)。把第一个块改成 BEGIN ISOLATION LEVEL REPEATABLE READ; 再跑一遍,快照就被钉死了。

阶段五会用双会话时间线把这个差异演示得更清楚。

先自己回答,再点开对照。

快照的三个字段各是什么含义?给定一个 xid,怎么判断它在某快照下是否已提交?

快照的文本格式是 xmin:xmax:xip1,xip2,...

字段 含义
xmin 所有小于它的事务都已结束(提交或回滚)
xmax 所有大于等于它的事务在快照建立时还不存在
xip 处于 [xmin, xmax) 区间、但在快照建立时仍在进行中的事务列表

判断纯粹是算术:

X < xmin → 已结束(再查提交状态即可)
X >= xmax → 未来的事务,不可见
xmin <= X < xmax → 看 X 在不在 xip 列表里;在 = 进行中不可见,不在 = 已结束

常见错误:把快照的 xmin / xmax 和元组头上的 t_xmin / t_xmax 当成同一回事。名字撞了,含义毫无关系——元组上的是「谁插入的 / 谁删除的」两个具体事务号,快照里的是一个区间的两个边界。顺带一个更细的坑:快照的 xmax 不是「当前最大的活跃事务号」,而是一个开区间上界,它自己那个号并不属于这个快照。按闭区间去理解,边界上的那个事务就会被判反。

为什么 Postgres 的事务回滚几乎零成本?代价转移到哪里去了?

回滚时 Postgres 什么都不用做——不撤销已写入的元组,不需要回滚段。它只要把自己的事务状态记成 ABORTED,可见性规则的第一步(「xmin 对应的事务在快照下已提交?」)就自动判否,这个事务插入过的所有版本对所有人瞬间不可见。

代价推给了 VACUUM:这些永远不可能再被任何人看到的元组仍然占着空间,得有人来收拾。

常见错误:把别的引擎的成本模型迁过来——「回滚很贵,所以要尽量避免大事务回滚」。在 Postgres 里方向是反的:回滚免费,清理才是账单,而且账单在事务结束之后才寄到。一个回滚掉的百万行 UPDATE,从应用侧看是瞬间返回的,看起来「什么都没发生」;但它在表里留下了一百万个死元组,接下来 autovacuum 要花很久去清,期间表还在膨胀。真正的成本发生在你以为事情已经结束之后——这也是为什么它很难被归因,监控上那段 I/O 尖峰和那次回滚在时间上并不重合。

详见表膨胀与 VACUUM

只读事务会消耗事务号吗?它会阻止 VACUUM 吗?为什么这两个答案不一样?

不消耗;但照样阻止。

不消耗,是因为事务号是延迟分配的:只有真正写入时才分配,在此之前用的是虚拟事务号(backendID/localXID),不占全局 xid 空间。实测 BEGIN 之后 pg_current_xact_id_if_assigned() 返回 NULL,跑完一条 SELECT 仍然是 NULL,直到 INSERT 才拿到号。

照样阻止,是因为钉住清理的是「快照」,不是「事务号」。快照在事务的第一条语句时就取好了,只要它还活着,VACUUM 就不能清掉任何在它之后产生的死元组。

推论是:一个只跑 SELECT 的只读副本或报表库完全不推进 xid,也就完全不产生回卷压力;但它上面一个开着不放的只读事务,能把表钉到持续膨胀。

常见错误:把这两条合并成一句「只读事务无害」。它在回卷这个维度上确实无害——而恰恰是这一点让人放松警惕:age(datfrozenxid) 纹丝不动,看起来一切正常,膨胀却在另一个维度上悄悄发生。危险的从来不是它读了什么,而是它开着多久

子事务超过 64 个会发生什么?哪些常见写法会隐式产生大量子事务?

每个 SAVEPOINT 都会开启一个子事务并分配自己的事务号。子事务到顶层事务的映射缓存在每个后端的 PGPROC 里,只有 64 个槽位。超过就是 subxid overflow:所有其他会话的可见性判断都要退回去查磁盘上的 pg_subtrans,整个实例的性能断崖式下跌。

隐式产生大量子事务的写法:

  • PL/pgSQL 里带 EXCEPTION 的块——每进入一次就是一个子事务,放在循环里,几千次迭代就是几千个;
  • ORM 的嵌套事务——Django 的 atomic()、Rails 的 requires_new,默认都用 SAVEPOINT 实现;
  • 循环里「试着插入,冲突就跳过」的逻辑——改用 ON CONFLICT DO NOTHING,它不产生子事务。

常见错误一:以为「我的代码里根本没写 SAVEPOINT」就安全。上面三种写法没有一个出现 SAVEPOINT 这个词,但它们在底层就是 SAVEPOINT——尤其是 EXCEPTION 块,它在语义上是「捕获异常」,几乎没人会把它和事务号联系起来。

常见错误二:以为溢出只拖慢那个会话自己。受害的是整个实例:一个后台脚本在循环里跑 EXCEPTION 块,前台所有正常查询一起变慢。故障现场的特征也因此很特别——找不到任何一条慢查询,而是所有查询都慢了一点点,按 P99 去抓凶手永远抓不到。

用「取快照的时机」重新表述三个隔离级别的区别。
隔离级别 取快照的时机
Read Committed 每条语句开始时取一个新的
Repeatable Read 事务的第一条语句时取一个,之后一直用它
Serializable 同 Repeatable Read,外加读写依赖跟踪

也就是说,隔离级别不是三套不同的可见性算法——可见性规则只有一套,区别只在于你手上那份快照是什么时候拍的

常见错误一:以为 Read Committed 下「同一个事务里读到的数据是一致的」。不是。它每条语句换一份新快照,同一个事务里两次完全相同的 SELECT 可能返回不同结果。跨语句的一致性要靠 Repeatable Read,而这一点在单机低并发下几乎不会暴露——正好在负载上来之后才开始出现。

常见错误二:以为 Serializable 只是「更严格一档的 Repeatable Read」,快照拍法一样、只是更保守。它多出来的不是快照,是读写依赖跟踪:检测到危险的依赖环时会在提交时抛序列化失败。这意味着用 Serializable 的代码必须自带重试逻辑——这是它和另外两个级别在使用方式上的根本差异,不是调一个参数就能享受的。

详见隔离级别Serializable