PostgreSQL的MVCC(3)--Row Versions

我们已经讨论过隔离,并且对底层数据结构做了介绍。现在介绍一下行版本(元组)。

Tuple header

如前所述,数据库中同一行记录在同一时刻可以有多个版本可用。我们需要以某种方式将一个版本与另一个版本区分开。为此,每个版本都标有有效的“time”(xmin)和到期的”time”(xmax)。引号表示使用特殊的递增计数器,而不是时间本身。该计数器是事务标识符。

(通常,实际中更复杂:由于计数器的bit深度有限,事务ID不能总是递增。但是,当我们的讨论陷入僵局时,我们将探索其更多细节。)

创建行时,将xmin的值设置为等于执行INSERT命令的事务的ID,而未填写xmax。

删除一行后,当前版本的xmax值将标记为执行DELETE的事务的ID。

UPDATE命令实际上执行两个后续操作:DELETE和INSERT。在该行的当前版本中,将xmax设置为等于执行UPDATE的事务的ID。然后,创建同一行的新版本,其中xmin的值与先前版本的xmax相同。

xmin和xmax字段包含在行版本的header中。除这些字段外,tuple header还包含其他字段,例如:

·infomask —占用几个bits,用于确定给定元组的属性。后面我们将逐步讨论。

·ctid—对同一行的下一个更新版本的引用。最新的行版本的ctid引用该版本。 该数字采用(x,y)形式,其中x是页面的编号,而y是数组中指针的顺序号。

·NULL位图,用于标记给定版本中包含NULL的列。NULL不是常规的数据类型值,因此,我们必须单独存储此特征。

因此,header看起来非常大:每个元组至少23个字节,但是由于NULL位图而通常更大。如果一个表是“ narrow”(窄)(也就是说,它包含几列),那么开销字节会比有用信息占用更多的空间。

插入(insert)

让我们更详细地了解如何在底层执行对行的操作,我们从插入开始。

为了进行实验,我们将创建一个有两列的新表,其中一列上有一个索引:

=> CREATE TABLE t(
  id serial,
  s text
);
=> CREATE INDEX ON t(s);

启动一个事务插入一行:

=> BEGIN;
=> INSERT INTO t(s) VALUES ('FOO');

我们当前事务的id是:

=> SELECT txid_current();
 txid_current 
--------------
         3664
(1 row)

让我们看看这个页面的内容。来自«pageinspect»扩展的heap_page_items函数使我们能够获得关于指针和行版本的信息:

=> SELECT * FROM heap_page_items(get_raw_page('t',0)) \gx
-[ RECORD 1 ]-------------------
lp          | 1
lp_off      | 8160
lp_flags    | 1
lp_len      | 32
t_xmin      | 3664
t_xmax      | 0
t_field3    | 0
t_ctid      | (0,1)
t_infomask2 | 2
t_infomask  | 2050
t_hoff      | 24
t_bits      | 
t_oid       | 
t_data      | \x0100000009464f4f

注意,PostgreSQL中的单词«heap»表示表。这是该术语的另一种奇怪用法:堆是一种已知的数据结构,与表无关。这个词在这里使用的意义是«所有都被堆积起来»,不像在有序索引中。

这个函数以一种难以理解的格式显示«当前»数据。为了澄清问题,我们只留下部分信息并加以解释:

=> SELECT '(0,'||lp||')' AS ctid,
       CASE lp_flags
         WHEN 0 THEN 'unused'
         WHEN 1 THEN 'normal'
         WHEN 2 THEN 'redirect to '||lp_off
         WHEN 3 THEN 'dead'
       END AS state,
       t_xmin as xmin,
       t_xmax as xmax,
       (t_infomask & 256) > 0  AS xmin_commited,
       (t_infomask & 512) > 0  AS xmin_aborted,
       (t_infomask & 1024) > 0 AS xmax_commited,
       (t_infomask & 2048) > 0 AS xmax_aborted,
       t_ctid
FROM heap_page_items(get_raw_page('t',0)) \gx
-[ RECORD 1 ]-+-------
ctid          | (0,1)
state         | normal
xmin          | 3664
xmax          | 0
xmin_commited | f
xmin_aborted  | f
xmax_commited | f
xmax_aborted  | t
t_ctid        | (0,1)

我们做了以下工作:

·在指针编号上添加了零,使其看起来像t_ctid :(页面编号,指针编号)。
·解释了lp_flags指针的状态。这里是«normal»,这意味着指针实际上引用了行版本。稍后我们将讨论其他值。
·到目前为止,在所有信息位(bits)中,我们仅选择了两对。xmin_committed和xmin_aborted位显示ID为xmin的事务是否已提交(回滚)。一对相似的位与事务ID的xmax有关。

我们观察到什么?当插入一行时,在表页面中会出现一个指针,该指针的编号为1,并引用该行的第一个且唯一版本。

元组中的xmin字段填充为当前事务的ID。由于事务仍处于活动状态,因此xmin_committed和xmin_aborted位均未设置。

行版本的ctid字段引用同一行。这意味着没有新版本可用。

由于没有删除元组(即最新),因此xmax字段用常规数字0填充。由于设置了xmax_aborted位,事务将忽略该数字。

通过将信息位附加到事务ID上,我们又向前迈出了一步,以提高可读性。并且创建函数,因为我们将多次查询:

=> CREATE FUNCTION heap_page(relname text, pageno integer)
RETURNS TABLE(ctid tid, state text, xmin text, xmax text, t_ctid tid)
AS $$
SELECT (pageno,lp)::text::tid AS ctid,
       CASE lp_flags
         WHEN 0 THEN 'unused'
         WHEN 1 THEN 'normal'
         WHEN 2 THEN 'redirect to '||lp_off
         WHEN 3 THEN 'dead'
       END AS state,
       t_xmin || CASE
         WHEN (t_infomask & 256) > 0 THEN ' (c)'
         WHEN (t_infomask & 512) > 0 THEN ' (a)'
         ELSE ''
       END AS xmin,
       t_xmax || CASE
         WHEN (t_infomask & 1024) > 0 THEN ' (c)'
         WHEN (t_infomask & 2048) > 0 THEN ' (a)'
         ELSE ''
       END AS xmax,
       t_ctid
FROM heap_page_items(get_raw_page(relname,pageno))
ORDER BY lp;
$$ LANGUAGE SQL;

行版本的header更清楚的形式:

=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | normal | 3664 | 0 (a) | (0,1)
(1 row)

通过使用xmin和xmax伪列,我们可以从表本身获得类似的信息,但远没有那么详细:

=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3664 |    0 |  1 | FOO
(1 row)

提交(commit)

事务成功后,必须记住其状态,即必须将事务标记为已提交。为此,使用了XACT结构。(在版本10之前,它被称为CLOG(提交日志),仍然很可能会遇到此名称)

XACT不是系统目录的表,而是PGDATA /pg_xact目录中的文件。在这些文件中为每个事务分配了两个bits(即«committed»和«aborted»),其方式与元组头中的方式完全相同。此信息只是为了方便而散布在几个文件中。在讨论freezing时,我们将回到这一点。PostgreSQL与所有其他文件一样,逐页处理这些文件。

因此,提交事务后,将在XACT中为此事务设置“ committed”位。这就是提交事务时发生的所有事情(尽管我们还没有提到预写日志)。

当其他事务访问我们刚刚查看的表页面时,前者将不得不回答一些问题:

1.事务xmin是否已完成? 如果没有,则创建的元组必须不可见。通过查看位于实例的共享内存中的另一个结构ProcArray来检查此情况。此结构包含所有活动进程的列表,以及每个进程的当前(活动)事务的ID。

2.如果事务完成,那么是提交还是回滚? 如果已回滚,则该元组也不得可见。这正是为什么需要XACT。但是,尽管XACT的最后一页存储在共享内存的缓冲区中,但每次检查XACT的开销都很高。 因此,一旦确定了事务状态,就将其写入元组的xmin_committed和xmin_aborted位。如果设置了这些位中的任何一个,则将事务状态视为已知,并且下一个事务将不需要检查XACT。

为什么执行插入的事务未设置这些位? 当执行插入操作时,事务尚不知道它是否将成功完成。在提交时,还不清楚哪些行和哪些页面已更改。这样的页面可能很多,要跟踪它们是不切实际的。此外,某些页面可能从缓冲区高速缓存中被逐出到磁盘上。再次读取它们以更改位将意味着提交的速度大大降低。

节省成本的反面是,更新之后,任何事务(甚至是执行SELECT的事务)都可以开始更改缓冲区高速缓存中的数据页。

因此,我们提交更改:

=> COMMIT;

页面中没有任何更改(但我们知道事务状态已经写入XACT):

=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | normal | 3664 | 0 (a) | (0,1)
(1 row)

现在,第一次访问该页面的事务将需要确定事务xmin的状态,并将其写入信息位(bit):

=> SELECT * FROM t;
 id |  s  
----+-----
  1 | FOO
(1 row)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3664 (c) | 0 (a) | (0,1)
(1 row)

删除(delete)

当删除一行时,当前删除事务的ID会写入最新版本的xmax字段,并且xmax_aborted位将被重置。

请注意,与活动事务相对应的xmax值用作行锁。如果另一个事务要更新或删除该行,则必须等到xmax事务完成。稍后我们将更详细地讨论锁。此时,仅注意行锁的数量根本没有限制。它们不占用内存,并且该数量不会影响系统性能。但是,长时间的事务还有其他缺点,稍后将对此进行讨论。

让我们删除一行。

=> BEGIN;
=> DELETE FROM t;
=> SELECT txid_current();
 txid_current 
--------------
         3665
(1 row)

我们看到事务ID被写到xmax字段,但是信息位未被设置:  

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax | t_ctid 
-------+--------+----------+------+--------
 (0,1) | normal | 3664 (c) | 3665 | (0,1)
(1 row)

中止(abort)

事务的Abort与commit的工作原理类似,只是«aborted»位是在XACT中设置的。中止和提交一样快。尽管该命令被称为ROLLBACK,但更改不会回滚:事务已经更改的所有内容将保持不变。

=> ROLLBACK;
=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax | t_ctid 
-------+--------+----------+------+--------
 (0,1) | normal | 3664 (c) | 3665 | (0,1)
(1 row)

当访问页面时,状态将被检查,提示位xmax_aborted将被设置。虽然数字xmax本身仍然在页面中,但是它不会被查看。

=> SELECT * FROM t;
 id |  s  
----+-----
  1 | FOO
(1 row)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   |   xmax   | t_ctid 
-------+--------+----------+----------+--------
 (0,1) | normal | 3664 (c) | 3665 (a) | (0,1)
(1 row)

更新(update)

更新的工作原理是先删除当前版本,然后再插入新版本。

=> BEGIN;
=> UPDATE t SET s = 'BAR';
=> SELECT txid_current();
 txid_current 
--------------
         3666
(1 row)

查询返回一行(新版本):

=> SELECT * FROM t;
 id |  s  
----+-----
  1 | BAR
(1 row)

但是我们可以在页面上看到两个版本:

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3664 (c) | 3666  | (0,2)
 (0,2) | normal | 3666     | 0 (a) | (0,2)
(2 rows)

删除的版本在xmax字段中用当前事务的ID标记。而且,自从上一个事务回滚以来,这个值已经覆盖了原来的值。并且xmax_aborted位被重置,因为当前事务的状态还未知。

该行的第一个版本现在引用第二个版本作为更新的行。

索引页现在包含第二个指针和第二行,它引用表页中的第二个版本。

与删除操作相同,第一个版本中的xmax值表示该行被锁定。

最后,我们提交事务:

=> COMMIT;

索引(indexes)

到目前为止,我们仅谈论表页。但是索引内部会发生什么?

索引页中的信息取决于特定的索引类型。而且,即使一种类型的索引也可以具有不同种类的页。例如:B树具有元数据页面和“普通”页面。

但是,索引页通常具有一个指向行和行本身的指针数组(就像表页一样)。此外,页面末尾的一些空间还分配给特殊数据。

根据索引类型,索引中的行也可以具有不同的结构。例如:在B树中,与叶子页相关的行包含索引键的值和对相应表行的引用(ctid)。通常,索引的结构可以完全不同。

要点是,在任何类型的索引中都没有行版本。或者我们可以考虑每一行仅由一个版本表示。换句话说,索引行的header不包含xmin和xmax字段。现在,我们可以假定从索引指向表行的所有版本的引用。因此,要确定事务中可见哪些行版本,PostgreSQL需要查看表。(像往常一样,这不是全部内容。有时可见性视图可以优化流程,但我们稍后会讨论)

在这里,在索引页面中,我们找到两个版本的指针:最新版本和之前版本:

=> SELECT itemoffset, ctid FROM bt_page_items('t_s_idx',1);
 itemoffset | ctid  
------------+-------
          1 | (0,2)
          2 | (0,1)
(2 rows)

(这里,我做过测试,对表执行vacuum full之后,再次查询就只有一条记录了。至于可见性视图优化流程暂时没有测试!!!)

虚拟事务(virtual transactions)

实际上,PostgreSQL利用了优化的优势,该优化允许“少量”消耗事务ID。

如果事务仅读取数据,则根本不影响元组的可见性。 因此,首先,后端进程将虚拟ID(虚拟xid)分配给事务。该ID由进程标识符和序列号组成。

分配此虚拟ID不需要所有进程之间的同步,因此可以非常快速地执行。 在讨论freezing时,我们将了解使用虚拟ID的另一个原因。

数据快照根本不考虑虚拟ID。

在不同的时间点,系统可以使用已经使用过的id进行虚拟事务,这很好。但是这个ID不能写入数据页,因为当下一次访问该页时,这个ID可能会变得毫无意义。

=> BEGIN;
=> SELECT txid_current_if_assigned();
 txid_current_if_assigned 
--------------------------
                         
(1 row)

但是,如果事务开始更改数据,它将接收一个真实的、惟一的事务ID。

=> UPDATE accounts SET amount = amount - 1.00;
=> SELECT txid_current_if_assigned();
 txid_current_if_assigned 
--------------------------
                     3667
(1 row)

=> COMMIT;

  

 

子事务(Subtransactions)

检查点(savepoints)

在SQL中,定义了savepoint,这些保存点允许回滚事务的某些操作而不会完全中止。但这与上述模型不兼容,因为事务状态是所有更改的结果之一,并且没有物理回滚的数据。

为了实现此功能,带有保存点的事务被分为几个单独的子事务,其状态可以分别进行管理。

子事务具有自己的ID(大于主事务的ID)。子事务的状态以通常的方式写入XACT,但最终状态取决于主事务的状态:如果回滚,则所有子事务也将回滚。

有关子事务嵌套的信息存储在PGDATA/pg_subtrans目录的文件中。这些文件是通过实例共享内存中的缓冲区访问的,这些缓冲区的结构与XACT缓冲区相同。

不要将子事务与匿名事务混淆。匿名事务绝不相互依赖,而子事务却相互依赖。常规PostgreSQL中没有匿名事务:实际上很少需要它们,并且它们在其他DBMS中的可用性会导致滥用,每个人都会遭受痛苦。

让我们清除表,开始事务并插入一行:

=> TRUNCATE TABLE t;
=> BEGIN;
=> INSERT INTO t(s) VALUES ('FOO');
=> SELECT txid_current();
 txid_current 
--------------
         3669
(1 row)

=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
(1 row)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | normal | 3669 | 0 (a) | (0,1)
(1 row)

现在我们建立一个保存点并插入另一行:

=> SAVEPOINT sp;
=> INSERT INTO t(s) VALUES ('XYZ');
=> SELECT txid_current();
 txid_current 
--------------
         3669
(1 row)

请注意,txid_current函数返回主事务的ID,而不是子事务的ID。

=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
 3670 |    0 |  3 | XYZ
(2 rows)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | normal | 3669 | 0 (a) | (0,1)
 (0,2) | normal | 3670 | 0 (a) | (0,2)
(2 rows)

让我们回滚到保存点并插入第三行:

=> ROLLBACK TO sp;
=> INSERT INTO t VALUES ('BAR');
=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
 3671 |    0 |  4 | BAR
(2 rows)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3669     | 0 (a) | (0,1)
 (0,2) | normal | 3670 (a) | 0 (a) | (0,2)
 (0,3) | normal | 3671     | 0 (a) | (0,3)
(3 rows)

在该页中,我们继续看到回滚子事务添加的行。

提交更改:

=> COMMIT;
=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
 3671 |    0 |  4 | BAR
(2 rows)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3669 (c) | 0 (a) | (0,1)
 (0,2) | normal | 3670 (a) | 0 (a) | (0,2)
 (0,3) | normal | 3671 (c) | 0 (a) | (0,3)
(3 rows)

现在可以清楚地看到,每个子事务都有自己的状态。

注意,SQL不允许显式使用子事务,也就是说,在完成当前事务之前不能启动新事务。在使用保存点、处理PL/pgSQL异常以及其他一些更奇怪的情况下,这种技术会隐式地涉及到。

=> BEGIN;
BEGIN
=> BEGIN;
WARNING:  there is already a transaction in progress
BEGIN
=> COMMIT;
COMMIT
=> COMMIT;
WARNING:  there is no transaction in progress
COMMIT

 

错误和原子性操作

如果在执行操作时发生错误,会发生什么?例如,像这样:

=> BEGIN;
=> SELECT * FROM t;
 id |  s  
----+-----
  2 | FOO
  4 | BAR
(2 rows)

=> UPDATE t SET s = repeat('X', 1/(id-4));
ERROR:  division by zero

一个错误发生。现在事务被视为中止,不允许任何操作:

=> SELECT * FROM t;
ERROR:  current transaction is aborted, commands ignored until end of transaction block

即使我们尝试提交更改,PostgreSQL也会报告回滚:

=> COMMIT;
ROLLBACK

为什么失败后无法继续执行事务? 问题是可能发生错误,以至我们可以访问部分更改,也就是说,不仅对于事务,甚至对于单个操作,原子性都将被破坏。例如,在我们的示例中,操作可以在发生错误之前更新一行:

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3669 (c) | 3672  | (0,4)
 (0,2) | normal | 3670 (a) | 0 (a) | (0,2)
 (0,3) | normal | 3671 (c) | 0 (a) | (0,3)
 (0,4) | normal | 3672     | 0 (a) | (0,4)
(4 rows)

值得注意的是,psql有一种模式,它允许在失败后继续事务,就像错误操作符的影响被回滚一样。

=> \set ON_ERROR_ROLLBACK on
=> BEGIN;
=> SELECT * FROM t;
 id |  s  
----+-----
  2 | FOO
  4 | BAR
(2 rows)

=> UPDATE t SET s = repeat('X', 1/(id-4));
ERROR:  division by zero

=> SELECT * FROM t;
 id |  s  
----+-----
  2 | FOO
  4 | BAR
(2 rows)

=> COMMIT;

很容易看出,在这种模式下,psql实际上在每个命令之前建立一个隐式保存点,并在失败时对其发起回滚。默认情况下不使用此模式,因为建立保存点(即使不回滚保存点)会带来很大的开销。

 

 

原文地址:

https://habr.com/en/company/postgrespro/blog/477648/

上一篇:Python 7种超实用的数据清洗方法,这你一定要掌握


下一篇:oracle默认 用户名 / 密码 登录身份 说明