PostgreSQL Internals之路 第4章 快照

文摘   科技   2024-09-09 06:18   北京  


4.快照(Snapshots)

4.1 什么是快照?

数据页可以包含同一行的多个不同版本的值,尽管每个事务必须只能看到最多一行。总的来说,所有不同行的可见版本就组成了一个快照。快照只包含当前已经提交 的数据,从而提供了当前时刻的一个致的视图(从ACID的意义上讲)。

为确保隔离性,每个事务使用它自己的快照。这意味着不同的事务在不同时间点可以看到不同的但却是一致的快照。

在读已提交级别下,快照会在每个语句前发生,它也只在语句存续期间有效。

在可重复读和可串行化级别下,快照发生在事务的第一条语句开始的时候,它会一直保持有效,直到整个事务执行完毕。

image-20221002051650780

4.2 行版本可见性

快照并不是所有元组的物理拷贝。相反,它只是由一些数字来定义,元组的可见性则是由一些特殊规则来决定的。

元组可见性是由元组header当中xmin和xmax的值和以及对应的hint二进制位共同定义的 (也就是说,用于执行insert和delete操作的事务的ID)。由于xmin-xmax区间没有交集,各行的所有快照可以只用它的版本号来表示。

由于它们会考虑到各种不同的场景和极端的情况,详细的可见性规则非常复杂。粗略情况下,我们可以将相关规则总结如下:一个元组是可见的,仅当快照中包含xmin的事务的变化 ,但是不包含xmax事务的变化(换句话说,元组已经出现了,但是没有被delete)。

反过来,如果在快照创建之前事务已经提交了,快照中事务改变是可见的。一个特殊的例子,事务可以看到它自己未提交的变化。如果事务被取消了,相关变化则不会在任何快照当中见到。

我们来看一个简单的例子。线段代表着事务(从开始直到提交时刻):

image-20221002052753192

这里可见性规则应用如下:

  • 事务2在快照创建前已经提交了,因此它的变化是可见的。
  • 事务1在快照创建时还处于激活状态,并没有完成,它的变化是不可见的。
  • 事务3是在快照创建以后才开始,因此它的变化也不可见(这个事务是否完成都不起作用)。

4.3 快照结构

不幸的是,前边的解释与PostgreSQL实际看到的这幅图[1]没什么关系。问题在于系统压根不知道事务会在什么时候提交。它们只知道事务在什么时候启动(这个时间是由txid确定的),而事务的完成并没有在任何地方注册。

Commit times can be tracked2 off if you enable the track_commit_timestamp parameter, but they do not participate in visibility checks in any way (although it can still be useful to track them for other purposes, for example, to apply in external replication solutions). 如果启用了track_commit_timestamp参数,则可以关闭跟踪提交时间,但是它们不以任何方式参与可见性检查(尽管为了其他目的跟踪它们仍然很有用,例如,应用于外部复制解决方案)。

Besides, PostgreSQL always logs commit and rollback times in the corresponding WAL entries, but this information is used only for point-in-time recovery.  此外,PostgreSQL总是在相应的WAL条目中记录提交和回滚时间,但此信息仅用于时间点恢复

我们只有从当前的事务状态里了解到东西。这个信息可以从服务器的共享内存里头得到:ProcArray结构包含了所有活动会话以及它们事务的列表。一旦某事务完成,是不可能确定它在快照创建时刻到底是否是活动的。

要创建一个快照,光注册创建的时间点是不够的。同时还需要收集在那一时刻所有事务的状态信息。否则,不可能理解快照中哪些元组是可见的,而哪些需要被排除。

我们可以看下当快照发生时以及再过一段时间(白圈代表活动的事务,黑圈表示已经完成的事务),系统中能够看到的一些信息。

image-20221002054201271

假设我们不知道在快照发生时,第1个事务还正在被执行,而第3个事务没有开始。看起来他们好像中意于第2个事务,那个时间点已经提交了。但是这些都不太可能被挑选出来 。

因为这个原因,PG并不能创建过去任意时刻的关于数据的一致性状态的快照,即使所有需要的元组都出现在堆的数据页里头。结果,我们也不能实现“闪回式”查询(有时也叫瞬间或闪回查询)。

Intriguingly, such functionality was declared as one of the objectives of Postgres and was implemented at the very start, but it was removed from the database system when the project support was passed on to the community.

有趣的是,这种功能被声明为Postgres的目标之一,并在一开始就实现了,但当项目支持传递给社区时,它被从数据库系统中删除了。

这样来看,快照主要还是由创建它时刻存储的一些值来组成。

xmin 是快照的下界,它代表着最老的活动的事务。所有的事务,更小的ID值,意味着要么已经提交了(其改变被包含进快照),要么被取消了(其改变也被忽略)。

xmax 则是快照的上界,比最后提交的事务id大1。它也定义了快照发生时的上边界。

所有事务ID大于等于xmax的事务,要么正在运行,要么还不存在,因此它们的改变是不可见的。

xip_list 这是所有活动事务(不包括虚拟事务)的ID列表。

快照还包含其它几个参数,这里我暂时将其忽略。

使用图形来表示,快照可以表示成一个矩形,它由xmin到xmax之间的事务所组成:

image-20221002055553534

为了理解可见性规则是如何定义的,我们可以用accunt表来重现上述场景:

mydb=# TRUNCATE TABLE accounts;
TRUNCATE TABLE

第1个事务插入一行,并且维护打开状态:

mydb=# BEGIN;
BEGIN
mydb=*# INSERT INTO accounts VALUES (1, 'alice', 1000.00);
INSERT 0 1
mydb=*# SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
               1191

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

mydb=# BEGIN;
BEGIN
mydb=*# INSERT INTO accounts VALUES (2, 'bob', 100.00);
INSERT 0 1
mydb=*# SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
               1193
(1 row)
mydb=*# commit;
COMMIT

此时,我们在另一个会话窗口里头,创建一个快照。只是简单的运行一个查询,我们也可以使用特殊的函数来看看这个快照:

mydb=# BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
mydb=*# SELECT pg_current_snapshot();
 pg_current_snapshot
---------------------
 1191:1194:1191
(1 row)

该函数显示了快照的成员,用逗号分隔开:xmin, xmax, xip_list(它是一个活动事务列表,这里它只包含一个值)

一旦快照被生成,再提交下第1个事务:

COMMIT;

第3个事务会在快照生成以后提交。它修改了第2行,于是一个新元组会出现:

mydb=# BEGIN;
BEGIN
mydb=*# UPDATE accounts SET amount = amount + 100 WHERE id = 2;
UPDATE 1
mydb=*# SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
               1194
(1 row)

mydb=*# commit;
COMMIT

我们的快照看到的只有一个元组:

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

但是该表实际上包含了三个:

mydb=*# SELECT * FROM heap_page('accounts',0);
 ctid  | state  |  xmin  | xmax
-------+--------+--------+------
 (0,1) | normal | 1192   | 0 a
 (0,2) | normal | 1193 c | 1194
 (0,3) | normal | 1194   | 0 a
(3 rows)

那么PG是如何选择版本号来显示的呢?采用上边提到的规则,改变要包含到快照里,只有他们被事务提交,该提交的事务要满足下述条件:

  • 如果xid < xmin,那些改变无条件显示 (比如创建account表的早期事务)
  • 如果xmin<=xid<xmax,只有对应的事务不出现在xip_list时才会显示改变

第一行(0, 1)不可见,是因为它被(xip_list)中的一个事务插入,即使这人睁一只眼务也在快照范围以内。第2行的最新版本(0,3)不可见,是因为它刚好是快照的事务id的上界。

但是(0, 2) 是可见的:它位于快照范围内,而且没有出现在xip_list里头(insertion是可见的)。但是delete操作的事务id超出范围,因此不可见。

4.4 事务自身变化 的可见性

当事务自身发生变化时,其可见性就变得更复杂了。在某些情况下,只有部分变化是可见的。例如,在某个点打开的游标,必须能看到后来发生的改变,不管当时是什么事务隔离级。

为描述这种情况,元组的header提供了一个特殊的域(cmin和cmax伪列)来显示事务内部操作的序号。cmin描述insert,而cmax描述delete操作。为节省空间,这些值会存到元组头的单个域,而不是弄成两个域。这里假定同一行永远不会在一个事务里头即insert又delete(如果真的发生了,那PG会在此域里写一个特殊的组合标识,而真实的cmin,cmax值会由backend进行存储)。

我们来看一个例子:

mydb=# BEGIN;
BEGIN
mydb=*# INSERT INTO accounts VALUES (3, 'charlie', 100.00);
INSERT 0 1
mydb=*# SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
               1195
(1 row)

mydb=*# DECLARE c CURSOR FOR SELECT count(*) FROM accounts;
DECLARE CURSOR
mydb=*# INSERT INTO accounts VALUES (4, 'charlie', 200.00);
INSERT 0 1

我们先插入一行,再开一游标 ,再插入一行,

现在我们把输入扩展一下,看看相关的值:

mydb=*# SELECT xmin, CASE WHEN xmin = 1195 THEN cmin END cmin, * FROM accounts;
 xmin | cmin | id | client  | amount
------+------+----+---------+---------
 1192 |      |  1 | alice   | 1000.00
 1194 |      |  2 | bob     |  200.00
 1195 |    0 |  3 | charlie |  100.00
 1195 |    1 |  4 | charlie |  200.00
(4 rows)

游标查询只能看到3行;游标打开时插入的那行并没有进快照,因为cmin < 1的条件并不满足:

自然地,这个cmin值也会存到快照里头,但是没法子用任何SQL来显示。

数据库杂记
数据库技术专家,PostgreSQL ACE,SAP HANA,Sybase ASE/ASA,Oracle,MySQL,SQLite各类数据库, SAP BTP云计算技术, 以及陈式太极拳教学倾情分享。出版过三本技术图书,武术6段。
 最新文章