跳转到主要内容

4 数据快照

PostgreSQL 18 · 基于俄文 PG18 原版的简体中文译本

4.1 什么是数据快照

数据页面在物理上可能包含同一行的多个版本,但每个事务最多只能看到其中一个。事务所看到的不同数据行的所有版本合在一起,构成一个数据快照(snapshot)。快照提供特定时刻的一致(在 ACID 意义上)数据视图,其中只包含创建快照时已经提交的最新数据。(第 54 页)

为了实现隔离,每个事务都使用自己的快照。因此,不同事务看到的数据可能不同,来自不同的时间点,但仍然保持一致。

在 Read Committed 隔离级别下,快照在事务每条语句开始时创建,并在该语句执行期间保持有效。

在 Repeatable Read 和 Serializable 隔离级别下,快照在事务第一条语句开始时创建一次,并一直保持有效,直到事务结束。

什么是数据快照:快照1、快照2、快照;操作1、操作2、操作1、操作2;xid;Read Committed;Repeatable Read、Serializable
图 4.1 什么是数据快照

图中文字(从左到右):快照1、快照2、快照;操作1、操作2、操作1、操作2;xid;Read Committed;Repeatable Read、Serializable。

4.2 快照中的行版本可见性

当然,数据快照并不是所有所需行版本的物理副本。实际上,快照由几个数字定义,而快照中的行版本可见性则由规则决定。

某个行版本是否在快照中可见,取决于其头部的 xmin 和 xmax 字段(也就是创建它和删除它的事务编号)以及对应的信息位。xmin–xmax 区间不会相交,因此在任何快照中,每行最多由自己的一个版本表示。

精确的可见性规则1相当复杂,还要考虑许多不同的情况和边界条件。简单地说,当 xmin 事务所做的更改在快照中可见,而 xmax 事务所做的更改不可见时,这个行版本就是可见的(换句话说,该行版本已经出现,但还没有被删除)。

相应地,如果事务在创建快照之前已提交,那么该事务的更改在快照中可见。还有一个例外:事务在快照中可以看到自己尚未提交的更改。中止事务的更改当然不会在任何快照中可见。

下面看一个简单例子。图中用线段表示事务(从开始到提交):

快照中的行版本可见性:快照;xid;1、2、3
图 4.2 快照中的行版本可见性

这里:

  • 事务 2 的更改可见,因为它在快照创建之前已结束;

  • 事务 1 的更改不可见,因为创建快照时它仍处于活动状态;

  • 事务 3 的更改不可见,因为它在快照创建之后才开始(无论它是否已结束都不重要)。

4.3 快照由什么组成

遗憾的是,PostgreSQL 实际看到的情况与图中所示完全不同2。原因在于系统不知道事务何时提交。它只知道事务何时开始(由事务编号确定),而事务结束这一事实不会记录在任何地方。(默认为: off)

说明

提交时间会在启用参数 track_commit_timestamp 时被跟踪3,但它完全不参与可见性检查(不过可能有用,例如用于第三方复制方案)。

此外,PostgreSQL 总会在相应的日志记录中保存事务的提交时间和中止时间,但只会在恢复到目标时间点时使用这些信息。

只能知道事务的当前状态。这些信息位于服务器共享内存的 ProcArray 结构中,其中包含所有活动会话及其事务的列表。但事后无法判断某个事务在快照创建时是否处于活动状态。(第 211 页)

因此,创建快照时仅保存创建时刻还不够:还必须记住该时刻每个事务的状态。没有这些状态信息,之后就无法判断哪些行版本应在快照中可见,哪些不应可见。

请比较一下,在快照创建时和一段时间之后,系统分别能获得哪些信息(白色圆圈表示活动事务,黑色圆圈表示已结束事务):

快照由什么组成:xid;1、2、3;创建快照时;一段时间后
图 4.3 快照由什么组成

图中文字(从左到右):创建快照时;一段时间后;xid,以及事务编号 1、2、3。

如果不记住创建快照时第一个事务仍在执行,而第三个事务还不存在,就无法把它们与当时已经提交的第二个事务区分开,也就无法将它们排除在外。

因此,PostgreSQL 无法创建一个显示过去某个时刻一致数据状态的快照,即使表页面中存在所有所需的行版本。因此也无法实现回溯查询(也称时态查询或 flashback 查询)。

说明

有意思的是,这项功能曾被列为 Postgres 的目标之一,并且从一开始就已实现;但项目转由社区维护后,它被从数据库系统中移除了4。

所以,数据快照由创建时保存的几个值组成5:

快照的下边界 xmin:最老的活动事务编号。

编号小于它的所有事务要么已提交,此时它们的更改在快照中可见;要么已中止,此时这些更改会被忽略。(第 134 页)

快照的上边界 xmax:它取最近一次已提交事务编号加一的值。上边界定义了快照创建的时刻。

编号大于或等于 xmax 的所有事务要么尚未结束,要么并不存在,因此这些事务的更改绝不会可见。

活动事务列表 xip_list(xid-in-progress list)包含所有活动事务的编号(小于 xmax),但不包括完全不影响可见性的虚拟事务。(第 91 页)

快照中还会保存几个其他参数,不过现在还不重要。

从图形上看,可以把快照表示为一个覆盖 xmin 到 xmax 之间事务的矩形:

快照由什么组成:xip\_list;xmin、xmax;xid;1、2、3
图 4.4 快照由什么组成

为了看看快照如何决定可见性,我们在 accounts 表上重现上面场景。

=> TRUNCATE TABLE accounts;

第一个事务向表中插入第一行,并保持活动状态:

=> BEGIN;
=> INSERT INTO accounts VALUES (1, 'alice', 1000.00);
=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                809
(1 row)

第二个事务插入第二行并立即提交:

=> BEGIN;
=> INSERT INTO accounts VALUES (2, 'bob', 100.00);
=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                810
(1 row)
=> COMMIT;

此时,在另一个会话中创建新快照。为此执行任意查询就足够了,但我们直接使用专用函数查看该快照:

=> BEGIN ISOLATION LEVEL REPEATABLE READ;
=> --  txid_current_snapshot() до v.13
SELECT pg_current_snapshot();
 pg_current_snapshot
---------------------
 809:811:809
(1 row)

该函数用冒号分隔输出快照的三个组成部分:xmin、xmax 和 xip_list(本例中只包含一个编号)。

快照创建后,提交第一个事务:

=> COMMIT;

第三个事务在快照创建后开始执行,并修改第二行。于是出现了新版本:

=> BEGIN;
=> UPDATE accounts SET amount = amount + 100 WHERE id = 2;
=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                811
(1 row)
=> COMMIT;

创建的快照中只有一个版本可见:

=> SELECT ctid, * FROM accounts;
 ctid  | id | client | amount
-------+----+--------+--------
 (0,2) |  2 | bob    | 100.00
(1 row)

但表中有三个版本:

=> SELECT * FROM heap_page('accounts',0);
 ctid  | state  | xmin  | xmax
-------+--------+-------+-------
 (0,1) | normal | 809 c | 0 a
 (0,2) | normal | 810 c | 811 c
 (0,3) | normal | 811 c | 0 a
(3 rows)

那么 PostgreSQL 如何判断要显示哪些版本?按照上面的规则,只有以下已提交事务的更改会出现在快照中:

  • 编号 xid < xmin 的事务:其更改无条件可见(例如创建 accounts 表的事务);

  • xmin ⩽ xid < xmax 的事务:其更改可见,但位于 xip_list 中的除外。

第一行(0,1)不可见,因为创建它的事务编号位于活动事务列表中(虽然它也位于快照范围内)。

第二行的最后一个版本(0,3)不可见,因为创建它的事务编号超出了快照的上边界。

不过,第二行的第一个版本(0,2)可见:创建它的事务编号落在快照范围内但不在活动事务列表中(插入可见),而删除它的事务编号超出了快照的上边界(删除不可见)。

=> COMMIT;

4.4 事务自身更改的可见性

确定事务自身更改的可见性时,情况会更复杂,因为有时只应看到其中一部分。例如,在某个时刻打开的游标,无论隔离级别如何,都不能看到之后发生的更改。

为此,行版本头部包含一个特殊字段(显示为伪列 cmin 和 cmax),用于表示事务内操作的序号。cmin 表示插入操作的编号,cmax 表示删除操作的编号;不过为节省行版本头部的空间,实际上它们共用一个字段,而不是两个不同字段。一般认为,在同一个事务中插入并删除同一行的情况很少。(如果确实发生了,系统会在同一个字段中写入一个特殊的“组合编号”,后台进程会为它记录真实的 cmin 和 cmax6。)

举个例子,先开启事务并向表中插入一行:

=> BEGIN;
=> INSERT INTO accounts VALUES (3, 'charlie', 100.00);
=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                812
(1 row)

现在打开一个游标,用于执行返回表中行数的查询:

=> DECLARE c CURSOR FOR SELECT count(*) FROM accounts;

然后再插入一行:

=> INSERT INTO accounts VALUES (4, 'charlie', 200.00);

现在显示表的内容,并增加一列 cmin,只为本事务插入的行显示该值(对其他行来说,这个值没有意义):

=> SELECT xmin, CASE WHEN xmin = 812 THEN cmin END cmin, *
FROM accounts;
 xmin | cmin | id | client  | amount
------+------+----+---------+---------
  809 |      |  1 | alice   | 1000.00
  811 |      |  2 | bob     |  200.00
  812 |    0 |  3 | charlie |  100.00
  812 |    1 |  4 | charlie |  200.00
(4 rows)

游标查询会得到三行而不是四行——游标打开后插入的行不会进入快照,因为快照只考虑满足 cmin < 1 的行版本:

=> FETCH c;
 count
-------
     3
(1 row)

当然,这个 cmin 编号也会保存在快照中,但无法通过 SQL 显示。

4.5 事务视界

快照的下边界(创建快照时处于活动状态的最老事务的 xmin 编号)具有重要含义——它定义了使用该快照的事务的视界。

没有活动快照的事务(例如,Read Committed 隔离级别下两条语句执行之间的事务)的视界由其自身编号决定,前提是它已经分配到编号。

位于视界之外的所有事务(即编号 xid < xmin 的事务)都已经确定提交。这意味着,在自己的视界之外,事务总是只能看到当前的行版本。

说明

这个名称当然来自物理学中的事件视界概念。

PostgreSQL 跟踪所有进程当前的事务视界;事务可以在 pg_stat_activity 表中查看自己的视界:

=> BEGIN;
=> SELECT backend_xmin FROM pg_stat_activity
WHERE pid = pg_backend_pid();
 backend_xmin
--------------
          812
(1 row)

虚拟事务虽然没有真正的编号,但也像普通事务一样使用快照,因此同样拥有自己的视界。例外是没有活动快照的虚拟事务:对它们而言,视界这个概念没有意义;从数据快照和可见性的角度看,它们对系统完全“透明”(即使 pg_stat_activity.backend_xmin 中可能还保留着旧快照的编号)。

类似地,可以定义数据库视界。为此,取在该数据库中运行的所有事务的视界,找出其中“最远”的一个,即 xmin 最老的那个7。这就是数据库视界:在它之外,该数据库中的任何事务都不可能再看到过期的行版本。这些行版本可以由清理安全删除——这就是视界概念在实践中如此重要的原因。

由此可以得出:

  • 如果处于 Repeatable Read 或 Serializable 隔离级别的事务(无论是真实事务还是虚拟事务)长时间运行,它就会保持数据库视界,从而阻碍清理;

  • Read Committed 隔离级别下的真实事务同样会保持数据库视界,无论它是在执行语句,还是只是处于空闲状态(idle in transaction);

  • Read Committed 隔离级别下的虚拟事务只在执行语句期间保持视界。

事务视界:数据库视界;过期行版本;可以清理;xid;1、2、3、4、5、6、7、8、9、10
图 4.5 事务视界

图中文字:数据库视界、过期行版本、可以清理、xid,以及事务编号 1 至 10。

不过,整个数据库只有一个视界;如果某个事务持有这个视界,它就不允许清理该视界以内的数据,即使事务从未访问过这些数据。

说明

对于集群范围的系统目录表,会使用一个单独的视界,该视界会考虑所有数据库中的事务。相反,对于临时表,除了当前进程中正在执行的事务外,无需考虑任何事务。

继续这个例子。第一个会话中的事务仍在执行,并继续持有视界;可以通过增加事务计数器来验证:

=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                813
(1 row)
=> SELECT backend_xmin FROM pg_stat_activity
WHERE pid = pg_backend_pid();
 backend_xmin
--------------
          812
(1 row)

只有在事务结束后,视界才会向前推进,清理才能删除过期的行版本:

=> COMMIT;
=> SELECT backend_xmin FROM pg_stat_activity
WHERE pid = pg_backend_pid();
 backend_xmin
--------------
          814
(1 row)

理想情况下,不应把产生新行版本的更新操作与长事务结合起来,因为这会导致表和索引膨胀。(第 178 页)

4.6 系统目录的快照

虽然系统目录也是由普通表组成的,但访问它们时不能使用事务或语句所用的同一个数据快照。快照必须足够“新”,能够包含所有最新更改,否则事务可能看到已经过时的表列定义,或错过新建的完整性约束。

下面是一个简单例子:

=> BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
=> SELECT 1; -- создан снимок для транзакции
=> ALTER TABLE accounts ALTER amount SET NOT NULL;
=> INSERT INTO accounts(client, amount) VALUES ('alice', NULL);
ERROR:  null value in column "amount" of relation "accounts" violates
not-null constraint
DETAIL:  Failing row contains (1, alice, null).
=> ROLLBACK;

INSERT 命令“看到了”在事务快照创建后才出现的完整性约束。看起来这种行为似乎破坏了隔离性,但如果事务已经访问了 accounts 表,ALTER TABLE 命令就会一直阻塞到该事务结束。(第 261 页)

总的来说,系统的行为就像为每次访问系统目录都创建一个新快照。不过,实现当然更复杂8,因为不断创建新快照会影响性能;此外还要考虑许多系统目录对象都被缓存这一事实。

4.7 导出数据快照

有时,多个并行事务必须确保看到完全相同的数据视图。例如,pg_dump 工具可以以并行模式运行:所有工作进程都必须看到数据库的同一状态,才能生成一致的备份。

当然,不能只因为事务“同时”启动,就指望它们的视图相同。快照导出和导入机制可以提供这样的保证。

函数 pg_export_snapshot 返回一个快照 ID,可以通过数据库系统外部的手段传递给另一个事务:

=> BEGIN ISOLATION LEVEL REPEATABLE READ;
=> SELECT count(*) FROM accounts;
 count
-------
     4
(1 row)
=> SELECT pg_export_snapshot();
 pg_export_snapshot
---------------------
 00000011-00000008-1
(1 row)

在执行第一条语句之前,另一个事务可以通过执行 SET TRANSACTION SNAPSHOT 命令导入这个快照。必须先将隔离级别设置为 Repeatable Read 或 Serializable,因为在 Read Committed 级别下,每条语句都会使用自己的快照:

=> DELETE FROM accounts;
=> BEGIN ISOLATION LEVEL REPEATABLE READ;
=> SET TRANSACTION SNAPSHOT '00000011-00000008-1';

现在第二个事务会使用第一个事务的快照,因此它会看到四行(而不是零行):

=> SELECT count(*) FROM accounts;
 count
-------
     4
(1 row)

显然,第一个事务在导出快照后所做的更改不会被第二个事务看到,反之亦然:仍然遵循通常的可见性规则。

导出快照的生命周期与导出它的事务相同。

=> COMMIT;
=> COMMIT;