我们已经讨论过隔离,并且对底层数据结构做了介绍。现在介绍一下行版本(元组)。
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/