在讨论了
隔离问题并对
低级数据结构进行了论述之后,上次我们探索
行版本并观察了不同的操作如何更改元组标头字段。
现在,我们将研究如何从元组中获取一致的数据快照。
什么是数据快照?
数据页实际上可以包含同一行的多个版本。 但是每笔交易都只能看到每一行的一个(或没有)版本,以便所有交易在特定时间点上构成数据的一致图片(按照ACID的意义)。
PosgreSQL中的隔离基于快照:每个事务都使用其自己的数据快照,该快照“包含”在创建快照之前提交的数据,并且不“包含”在该时刻尚未提交的数据。 我们
已经看到 ,尽管最终的隔离看起来比标准要求的严格,但仍然存在异常。
在“读取已提交”隔离级别,将在每个事务语句的开头创建一个快照。 在执行语句时,此快照处于活动状态。 在图中,快照创建的时刻(我们记得,它由事务ID决定)以蓝色显示。

在“可重复读取”和“可序列化”级别上,快照在第一个事务语句的开始处创建一次。 这样的快照在事务结束之前一直保持活动状态。

快照中元组的可见性
可见性规则
快照当然不是所有必要元组的物理副本。 快照实际上由多个数字指定,并且快照中元组的可见性由规则确定。
元组在快照中是否可见取决于标头中的两个字段,即
xmin
和
xmax
,即创建和删除该元组的事务的ID。 这样的间隔不会重叠,因此,每个快照中的一行代表的版本不超过一个。
确切的可见性规则非常复杂,并考虑了许多不同的情况和极端情况。
您可以通过查看src / backend / utils / time / tqual.c(在版本12中,将检查移至src / backend / access / heap / heapam_visibility.c)轻松地确保这一点。
为简化起见,我们可以说一个元组在快照中可见,而
xmin
事务所做的更改是可见的,而
xmax
事务所做的更改则不可见(换句话说,已经创建了元组,但是尚不清楚是否已删除它)。
关于事务,无论是创建快照的事务(它确实看到自己尚未提交的更改)还是在快照创建之前就提交了事务,快照中的更改都是可见的。
我们可以按段(从开始时间到提交时间)以图形方式表示事务:

在这里:
- 事务2的更改将在创建快照之前完成,因此将可见。
- 事务1的更改将不可见,因为它在创建快照时处于活动状态。
- 事务3的更改将不可见,因为它是在创建快照后开始的(无论它是否完成)。
不幸的是,系统没有意识到事务的提交时间。 仅知道其开始时间(由事务ID确定并在上图中用虚线标记),但是完成的事件未写入任何地方。
我们所能做的就是在创建快照时找出事务的
当前状态。 该信息在服务器的共享内存中的ProcArray结构中可用,该结构包含所有活动会话及其事务的列表。
但是我们将无法确定事后快照创建时某个事务是否处于活动状态。 因此,快照必须存储所有当前活动事务的列表。
从上面可以看出,在PostgreSQL中,
即使表页面中所有必需的元组都可用,
也无法创建快照来显示在特定时间后向一致的数据。 经常会引起一个问题,为什么PostgreSQL缺乏追溯(或时间;或闪回,如Oracle称呼它们)查询-这就是原因之一。
有点可笑的是,此功能最初可用,但随后已从DBMS中删除。 您可以在Joseph M. Hellerstein的文章中阅读有关此内容的信息 。
因此,快照由几个参数确定:
- 创建快照后,更确切地说,是下一个事务的ID,但在系统中不可用(
snapshot.xmax
)。 - 创建
snapshot.xip
的活动(进行中)事务列表( snapshot.xip
)。
为了方便和优化,还将存储最早活动事务的ID(
snapshot.xmin
)。 该值具有重要意义,下面将进行讨论。
快照还存储了一些其他参数,但是这些参数对我们来说并不重要。

例子
为了了解快照如何确定可见性,让我们通过三个事务重现上面的示例。 该表将具有三行,其中:
- 第一个是由在快照创建之前开始但在快照创建之后完成的事务添加的。
- 第二个是通过在快照创建之前开始并完成的事务添加的。
- 第三个是在创建快照后添加的。
=> TRUNCATE TABLE accounts;
第一笔交易(尚未完成):
=> BEGIN; => INSERT INTO accounts VALUES (1, '1001', 'alice', 1000.00); => SELECT txid_current();
=> SELECT txid_current(); txid_current -------------- 3695 (1 row)
第二笔交易(在创建快照之前完成):
| => BEGIN; | => INSERT INTO accounts VALUES (2, '2001', 'bob', 100.00); | => SELECT txid_current();
| txid_current | -------------- | 3696 | (1 row)
| => COMMIT;
在另一个会话的事务中创建快照。
|| => BEGIN ISOLATION LEVEL REPEATABLE READ; || => SELECT xmin, xmax, * FROM accounts;
|| xmin | xmax | id | number | client | amount || ------+------+----+--------+--------+-------- || 3696 | 0 | 2 | 2001 | bob | 100.00 || (1 row)
创建快照后提交第一个事务:
=> COMMIT;
第三笔交易(在创建快照后出现):
| => BEGIN; | => INSERT INTO accounts VALUES (3, '2002', 'bob', 900.00); | => SELECT txid_current();
| txid_current | -------------- | 3697 | (1 row)
| => COMMIT;
显然,快照中仅可见一行:
|| => SELECT xmin, xmax, * FROM accounts;
|| xmin | xmax | id | number | client | amount || ------+------+----+--------+--------+-------- || 3696 | 0 | 2 | 2001 | bob | 100.00 || (1 row)
问题是Postgres如何理解这一点。
全部由快照确定。 让我们看一下:
|| => SELECT txid_current_snapshot();
|| txid_current_snapshot || ----------------------- || 3695:3697:3695 || (1 row)
这里列出了
snapshot.xmin
,
snapshot.xmax
和
snapshot.xip
,以冒号分隔(在这种情况下,
snapshot.xip
是一个数字,但通常是一个列表)。
根据上述规则,在快照中,那些ID为
xid
的事务所做的更改必须是可见的,使得
snapshot.xmin <= xid < snapshot.xmax
除外。 让我们看一下所有表行(在新快照中):
=> SELECT xmin, xmax, * FROM accounts ORDER BY id;
xmin | xmax | id | number | client | amount ------+------+----+--------+--------+--------- 3695 | 0 | 1 | 1001 | alice | 1000.00 3696 | 0 | 2 | 2001 | bob | 100.00 3697 | 0 | 3 | 2002 | bob | 900.00 (3 rows)
第一行不可见:它是由活动事务(
xip
)列表上的事务创建的。
第二行可见:它是由快照范围内的事务创建的。
第三行不可见:它是由快照范围之外的事务创建的。
|| => COMMIT;
交易本身的变化
确定交易本身变更的可见性会使情况复杂化。 在这种情况下,可能仅需要查看部分此类更改。 例如:在任何隔离级别,在某个时间点打开的游标一定不能看到以后所做的更改。
为此,元组标头具有一个特殊字段(在
cmin
和
cmax
伪列中表示),该字段显示事务内部的订单号。
cmin
是要插入的数字,
cmin
是要删除的数字,但是为了节省元组头中的空间,这实际上是一个字段,而不是两个不同的字段。 假定事务很少插入和删除同一行。
但是,如果确实发生这种情况,
combocid
在同一字段中插入一个特殊的组合命令id(
combocid
),并且后端进程会记住该
cmin
的实际
cmin
和
combocid
。 但这完全是异国情调。
这是一个简单的例子。 让我们开始一个事务并在表中添加一行:
=> BEGIN; => SELECT txid_current();
txid_current -------------- 3698 (1 row)
INSERT INTO accounts(id, number, client, amount) VALUES (4, 3001, 'charlie', 100.00);
让我们输出表的内容以及
cmin
字段(但仅适用于事务添加的行-对于其他行则没有意义):
=> SELECT xmin, CASE WHEN xmin = 3698 THEN cmin END cmin, * FROM accounts;
xmin | cmin | id | number | client | amount ------+------+----+--------+---------+--------- 3695 | | 1 | 1001 | alice | 1000.00 3696 | | 2 | 2001 | bob | 100.00 3697 | | 3 | 2002 | bob | 900.00 3698 | 0 | 4 | 3001 | charlie | 100.00 (4 rows)
现在,我们为查询打开一个游标,该查询返回表中的行数。
=> DECLARE c CURSOR FOR SELECT count(*) FROM accounts;
然后,我们添加另一行:
=> INSERT INTO accounts(id, number, client, amount) VALUES (5, 3002, 'charlie', 200.00);
查询返回4-打开游标后添加的行未进入数据快照:
=> FETCH c;
count ------- 4 (1 row)
怎么了 因为快照仅考虑
cmin < 1
元组。
=> SELECT xmin, CASE WHEN xmin = 3698 THEN cmin END cmin, * FROM accounts;
xmin | cmin | id | number | client | amount ------+------+----+--------+---------+--------- 3695 | | 1 | 1001 | alice | 1000.00 3696 | | 2 | 2001 | bob | 100.00 3697 | | 3 | 2002 | bob | 900.00 3698 | 0 | 4 | 3001 | charlie | 100.00 3698 | 1 | 5 | 3002 | charlie | 200.00 (5 rows)
=> ROLLBACK;
活动范围
最早的活动事务的ID(
snapshot.xmin
)具有重要意义:它确定事务的“事件范围”。 也就是说,超出其范围,该事务始终只能看到最新的行版本。
实际上,仅当尚未完成的事务创建了最新的(死)行版本时,才需要看到该行版本,因此尚不可见。 但是,毫无疑问,所有“超越地平线”的交易都已经完成。

您可以在系统目录中看到事务范围:
=> BEGIN; => SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid();
backend_xmin -------------- 3699 (1 row)
我们还可以在数据库级别定义范围。 为此,我们需要获取所有活动快照并在其中找到最旧的
xmin
。 它将定义范围,超过该范围,数据库中的死元组将永远对任何事务都不可见。
这样的元组可以被抽走 -这就是为什么从实际的角度来看,地平线的概念如此重要的原因。
如果某个事务长时间保存快照,那么它也将保存数据库范围。 此外,即使事务本身不保存快照,仅存在未完成的事务也将保留视野。
这意味着无法清除DB中的死元组。 此外,“长期”事务有可能根本不会与其他事务相交,但是这并不重要,因为所有事务共享一个数据库范围。
如果现在我们使一个段代表快照(从
snapshot.xmin
到
snapshot.xmax
)而不是事务,则可以将情况可视化如下:

在此图中,最低的快照与未完成的事务有关,在其他快照中,
snapshot.xmin
不能大于事务ID。
在我们的示例中,事务是从“读取已提交”隔离级别开始的。 即使它没有任何活动的数据快照,它也会继续保持发展:
| => BEGIN; | => UPDATE accounts SET amount = amount + 1.00; | => COMMIT;
=> SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid();
backend_xmin -------------- 3699 (1 row)
并且仅在事务完成之后,地平线才向前移动,这可以清除死元组:
=> COMMIT; => SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid();
backend_xmin -------------- 3700 (1 row)
如果所描述的情况确实导致问题,并且无法在应用程序级别上解决,则从9.6版开始可以使用两个参数:
old_snapshot_threshold
确定快照的最大生存期。 这段时间过后,服务器将有资格清理死元组,并且如果“长期播放”事务仍然需要它们,它将收到“快照太旧”错误。idle_in_transaction_session_timeout
确定空闲事务的最大生存期。 经过这段时间后,事务中止。
快照导出
有时会出现必须保证多个并发事务才能看到相同数据的情况。 一个示例是
pg_dump
实用程序,它可以在并行模式下工作:所有工作进程都必须以相同的状态查看数据库,以使备份副本保持一致。
当然,我们不能依靠这样的信念,即仅仅因为交易是“同时”开始的,它们就能看到相同的数据。 为此,可以导出和导入快照。
pg_export_snapshot
函数返回快照ID,可以将其传递给另一个事务(使用DBMS外部的工具)。
=> BEGIN ISOLATION LEVEL REPEATABLE READ; => SELECT count(*) FROM accounts;
count ------- 3 (1 row)
=> SELECT pg_export_snapshot();
pg_export_snapshot --------------------- 00000004-00000E7B-1 (1 row)
另一个事务可以在执行第一个查询之前使用SET TRANSACTION SNAPSHOT命令导入快照。 由于在“读取已提交”级别,语句将使用其自己的快照,因此还应该在指定“可重复读取”或“可序列化”隔离级别之前。
| => DELETE FROM accounts; | => BEGIN ISOLATION LEVEL REPEATABLE READ; | => SET TRANSACTION SNAPSHOT '00000004-00000E7B-1';
现在,第二个事务将与第一个事务的快照配合使用,因此,请参见三行(而不是零行):
| => SELECT count(*) FROM accounts;
| count | ------- | 3 | (1 row)
导出快照的生存期与导出事务的生存期相同。
| => COMMIT; => COMMIT;
继续阅读 。