MVCC-3。 字符串版本

因此,我们考虑了相关问题 绝缘,并撤退了约 在低层次上组织数据。 最后我们到达了最有趣的部分 - 字符串版本。

产品名称

正如我们已经说过的,每一行可以同时存在于数据库中的多个版本中。 一个版本必须以某种方式与另一个版本区分开来。为此,每个版本都有两个标记来确定该版本的作用“时间”(xmin 和 xmax)。 用引号引起来 - 因为使用的不是时间本身,而是一个特殊的递增计数器。 而这个计数器就是交易号。

(和往常一样,现实更加复杂:由于计数器的位容量有限,交易数量不可能一直增加。但是当我们冻结时,我们会详细查看这些细节。)

创建行时,xmin 设置为发出 INSERT 命令的事务号,xmax 留空。

当删除一行时,当前版本的xmax值被标记为执行DELETE的事务的编号。

当通过 UPDATE 命令修改一行时,实际上执行了两个操作:DELETE 和 INSERT。 行集 xmax 的当前版本等于执行 UPDATE 的事务的数量。 然后创建同一字符串的新版本; 其xmin值与之前版本的xmax值一致。

xmin 和 xmax 字段包含在行版本标头中。 除了这些字段之外,标头还包含其他字段,例如:

  • infomask 是定义该版本属性的一系列位。 它们的数量相当多; 我们将逐渐考虑主要的。
  • ctid 是指向同一行的下一个较新版本的链接。 对于字符串的最新版本,ctid 指的是该版本本身。 该数字的形式为 (x,y),其中 x 是页码,y 是数组中的索引号。
  • 空位图 - 标记给定版本中包含空值 (NULL) 的那些列。 NULL 不是普通数据类型值之一,因此该属性必须单独存储。

因此,标头相当大 - 每个版本的行至少有 23 个字节,而且由于 NULL 位图,通常更多。 如果表很“窄”(即包含很少的列),则开销可能会占用比有用信息更多的信息。

插入

让我们仔细看看低级字符串操作是如何执行的,从插入开始。

为了进行实验,我们创建一个包含两列并在其中一列上建立索引的新表:

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

让我们在开始事务后插入一行。

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

这是我们当前的交易编号:

=> 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 中的“堆”一词指的是表。 这是该术语的另一个奇怪的用法 - 堆是已知的 数据结构,它与表没有任何共同之处。 这里这个词的意思是“所有东西都放在一起”,而不是有序索引。

该函数以难以理解的格式“按原样”显示数据。 为了弄清楚,我们只留下部分信息并破译它:

=> 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 指针的状态。 这里是“正常”——这意味着指针实际上指的是字符串的版本。 稍后我们会看看其他含义。
  • 在所有信息位中,迄今为止仅识别出两对。 xmin_commited 和 xmin_aborted 位指示事务号 xmin 是否已提交(中止)。 两个相似的位指的是交易号xmax。

我们看到了什么? 当您插入一行时,表页中将出现一个索引号 1,指向该行的第一个也是唯一的版本。

在字符串版本中,xmin 字段填充当前交易编号。 事务仍然处于活动状态,因此 xmin_comfilled 和 xmin_aborted 位均未设置。

行版本 ctid 字段引用同一行。 这意味着更新的版本不存在。

xmax 字段填充了虚拟数字 0,因为该版本的行尚未被删除并且是最新的。 事务不会关注这个数字,因为 xmax_aborted 位被设置。

让我们通过向交易编号添加信息位来进一步提高可读性。 让我们创建一个函数,因为我们将多次需要该请求:

=> 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;

在这种形式中,行版本的标题中发生的情况更加清晰:

=> 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)

固定

如果事务成功完成,您需要记住它的状态 - 请注意它已提交。 为此,使用了一个名为 XACT 的结构(在版本 10 之前,它被称为 CLOG(提交日志),这个名称仍然可以在不同的地方找到)。

XACT 不是系统目录表; 这些是 PGDATA/pg_xact 目录中的文件。 每个事务都有两个位:已提交和已中止 - 就像行版本标头中一样。 这些信息被分成几个文件只是为了方便;当我们考虑冻结时,我们将回到这个问题。 与所有其他文件一样,这些文件的处理是逐页进行的。

因此,当在 XACT 中提交事务时,会设置该事务的已提交位。 这就是提交期间发生的所有事情(尽管我们还没有讨论预记录日志)。

当另一个事务访问我们刚刚查看的表页时,它将必须回答几个问题。

  1. xmin交易完成了吗? 如果不是,那么创建的字符串版本不应该是可见的。
    此检查是通过查看另一个结构来执行的,该结构位于实例的共享内存中,称为 ProcArray。 它包含所有活动进程的列表,并且为每个进程指示其当前(活动)事务的数量。
  2. 如果完成了,那么如何——提交或取消? 如果取消,则行版本也不应该可见。
    这正是 XACT 的用途。 但是,尽管 XACT 的最后一页存储在 RAM 的缓冲区中,但每次检查 XACT 的成本仍然很高。 因此,一旦确定了事务状态,就会将其写入字符串版本的 xmin_comfilled 和 xmin_aborted 位。 如果设置了其中一个位,则事务 xmin 的状态被视为已知,并且下一个事务将不必访问 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 事务状态并将其写入信息位:

=> 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)

切除

当删除一行时,当前删除事务的编号被写入当前版本的xmax字段,并且xmax_aborted位被清除。

注意,活动事务对应的xmax设置值起到了行锁的作用。 如果另一个事务想要更新或删除该行,它将被迫等待事务xmax完成。 稍后我们将详细讨论阻塞。 现在,我们只注意到行锁的数量是无限的。 它们不占用 RAM 空间,并且系统性能不会因其数量而受到影响。 诚然,“长”交易还有其他缺点,稍后会详细介绍。

让我们删除该行。

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

我们看到xmax字段中写入了交易编号,但未设置信息位:

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

消除

中止更改的工作方式与提交类似,只是在 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)

更新

更新的工作方式就好像它首先删除了行的当前版本,然后插入了新版本。

=> 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 字段中标记有当前交易编号。 此外,由于前一笔交易已取消,因此该值会覆盖旧值。 并且 xmax_aborted 位被清除,因为当前事务的状态尚不清楚。

该行的第一个版本现在将第二个(t_ctid 字段)称为较新的版本。

第二个索引出现在索引页中,第二行引用表页中的第二个版本。

与删除一样,该行的第一个版本中的 xmax 值表明该行已被锁定。

好吧,让我们完成交易吧。

=> COMMIT;

指数

到目前为止我们只讨论了表页。 索引内部会发生什么?

索引页中的信息根据索引的具体类型而有很大差异。 即使一种类型的索引也有不同类型的页面。 例如,B 树具有元数据页面和“常规”页面。

然而,页面通常有一个指向行和行本身的指针数组(就像表页面一样)。 此外,在页面末尾还有用于特殊数据的空间。

根据索引的类型,索引中的行也可以具有非常不同的结构。 例如,对于 B 树,与叶页相关的行包含索引键值和对相应表行的引用 (ctid)。 一般来说,索引可以以完全不同的方式构建。

最重要的一点是任何类型的索引中都没有行版本。 好吧,或者我们可以假设每一行都由一个版本表示。 换句话说,索引行标题中没有 xmin 和 xmax 字段。 我们可以假设索引中的链接指向行的所有表版本 - 因此您可以仅通过查看表来确定事务将看到哪个版本。 (一如既往,这并不是全部事实。在某些情况下,可见性图可以优化流程,但我们稍后会更详细地讨论这一点。)

同时,在索引页中我们找到了指向两个版本的指针,包括当前版本和旧版本:

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

虚拟交易

在实践中,PostgreSQL 使用优化来“保存”事务数。

如果事务仅读取数据,则对行版本的可见性没有影响。 因此,服务进程首先给事务下发一个虚拟xid。 该编号由进程 ID 和序列号组成。

发出这个号码不需要所有进程之间的同步,因此速度非常快。 当我们谈论冻结时,我们将了解使用虚拟号码的另一个原因。

数据快照中不会以任何方式考虑虚拟数字。

在不同的时间点,系统中很可能存在已经使用过的号码的虚拟交易,这是正常的。 但这样的数字不能写入数据页,因为下次访问该页时它可能会失去所有意义。

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

如果一笔交易开始改变数据,它就会被赋予一个真实的、唯一的交易号。

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

=> COMMIT;

嵌套事务

保存积分

在 SQL 中定义 保存积分 (保存点),它允许您取消部分事务而不完全中断它。 但这并不符合上图,因为事务的所有更改都具有相同的状态,并且物理上没有数据回滚。

为了实现此功能,带有保存点的事务被分成多个单独的事务 嵌套事务 (子事务),其状态可以单独管理。

嵌套事务有自己的编号(高于主事务的编号)。 嵌套事务的状态在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() 函数返回主交易编号,而不是嵌套交易编号。

=> 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(s) 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 实际上会在每个命令之前放置一个隐式保存点,并在失败时启动回滚到它。 默认情况下不使用此模式,因为设置保存点(即使不回滚到它们)会涉及大量开销。

续。

来源: habr.com

添加评论