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中的堆(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指针的状态。 在这里它是“正常的”-这意味着指针实际上是指字符串的版本。 其他值将在以后考虑。
  • 到目前为止,在所有信息位中,仅分配了两对。 xmin_committed和xmin_aborted位指示是否已提交(取消)具有xmin编号的事务。 两个相似的位表示事务编号xmax。

我们看到了什么? 在表格页面中插入行时,将出现一个指针,其编号为1,表示该行的第一个也是唯一的版本。

在该行的版本中,xmin字段填充有当前事务的编号。 事务仍处于活动状态,因此未同时设置xmin_committed和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_committed和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)交易。 该数字由一个过程标识符和一个序号组成。

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

数据快照中不考虑虚拟数字。

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

 => 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实际上在每个命令的前面设置了一个隐式保存点,并且在失败的情况下,它会发起回滚。 默认情况下不使用此模式,因为设置保存点(即使不回滚到保存点)也会带来大量开销。

待续。

Source: https://habr.com/ru/post/zh-CN445820/


All Articles