我们已经讨论过隔离,并且对底层数据结构做了介绍。现在介绍一下行版本(tuple(元组))。
Tuple header
如前所述,数据库中同一行记录在同一时刻可以有多个版本可用。我们需要以某种方式将一个版本与另一个版本区分开。为此,每个版本都标有有效的“时间”(xmin)和到期的”时间”(xmax)。引号表示使用特殊的递增计数器,而不是真正的时间。该计数器是事务标识符。
(实际中更复杂:由于计数器的bit深度有限,事务ID不能总是递增。但是,当我们的讨论freezing时,我们将探索其更多细节。)
创建行时,xmin
的值设置为执行INSERT命令的事务的ID,而xmax
则不填写。
删除行时,当前版本的xmax
值将标记为执行删除的事务的ID。
UPDATE命令实际上执行了两个操作:DELETE 和 INSERT。在当前版本的行中,xmax
设置为与执行UPDATE的事务ID相等。然后创建同一行的新版本,其中xmin
的值与前一版本的xmax
相同。
xmin
和xmax
字段包含在行版本的header中。除这些字段外,tuple header还包含其他字段,例如:
infomask
-- 占用几个bits,用于确定给定元组的属性。后面我们将逐步讨论。ctid
-- 对同一行的下一个更新版本的引用。最新的行版本的ctid
引用该版本。该数字采用(x,y)
形式,其中x
是page的编号,而y
是数组中指针的顺序号。NULL位图,用于标记给定版本中包含NULL的列。NULL不是常规的数据类型值,因此,我们必须单独存储此特征。
因此,header看起来非常大:每个元组至少23个字节,但是由于NULL位图而通常更大。如果一个表比较窄(也就是说,它包含几列),那么开销字节会比有用信息占用更多的空间。
Insert
让我们更详细地了解如何在较低级别上执行对行的操作,并从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
:(page号,指针编号)。解释了
lp_flags
指针的状态。这里是“normal”,这意味着指针实际上引用了行版本。我们稍后将讨论其他值。到目前为止,在所有信息位(bits)中,我们仅选择了两对。
xmin_committed
和xmin_aborted
位显示ID为xmin
的事务是否已提交(回滚)。还有一对相似的位(xmax_commited
、xmax_aborted
)与事务ID的xmax
有关。
我们观察到了什么?插入行时,在表page中会出现一个指针,该指针的编号为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目录中的文件。在这些文件中为每个事务分配了两个位——“committed”和“aborted”——与元组header中的方式完全相同。这些信息仅为方便起见而分散在多个文件中;当我们讨论freezing时,我们会回到这个问题上来。PostgreSQL与所有其他文件一样,逐页处理这些文件。
因此,在提交事务时,将在XACT中为此事务设置“committed”位。这就是提交事务时发生的一切(尽管我们还没有提到预写日志)。
当其他事务访问我们刚才看到的表page时,前者必须回答几个问题。
事务
xmin
是否已完成?否则,创建的元组必不可见。这可以通过查看另一个结构来检查,该结构位于实例的共享内存中,名为"ProcArray"。此结构保存所有活动进程的列表,以及每个进程的当前(活动)事务ID。
如果事务已完成,那么它是提交还是回滚?如果已回滚,则元组也不可见。
这正是XACT所需要的。尽管XACT的最后一页存储在共享内存的缓冲区中,但每次检查XACT的代价很高。因此,一旦计算出来,事务状态将写入元组的
xmin_committed
和xmin_aborted
位。如果设置了这些位中的任何一位,则事务状态被视为已知,并且下一个事务将不需要检查XACT。
为什么执行insert 的事务不设置这些位?当执行insert 时,事务还不知道是否会成功完成。在提交时,已经不清楚哪些行和哪些page发生了更改。这样的page可能很多,跟踪它们是不切实际的。此外,一些page可以从缓冲区缓存中逐出到磁盘;为了更改位而再次读取它们将意味着提交的速度大大减慢。
节省成本的另一方面是,在更新之后,任何事务(即使是执行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)
复制
现在,第一次访问该page的事务将需要确定事务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
事务中止的工作方式与提交类似,只是“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)
复制
访问page时,将检查状态,并设置提示位xmax_aborted
。虽然数字xmax
本身仍将在page中,但不会被查看。
=> 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
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
位。
行的第一个版本现在引用第二个版本,作为更新版本。
索引页现在包含第二个指针和第二行,它们引用表页中的第二个版本。
与delete相同,第一个版本中的值xmax
表示该行已锁定。
最后,我们提交事务。
=> COMMIT;
复制
Indexes
到目前为止,我们只讨论了表页。但索引内部会发生什么呢?
索引页中的信息在很大程度上取决于特定的索引类型。此外,即使是一种类型的索引也可以有不同类型的页。例如:B-tree有元数据页和“普通”页。
然而,索引页通常有一个指向行和行本身的指针数组(就像表页一样)。此外,页末尾的一些空间分配给特殊数据。
索引中的行也可以具有不同的结构,具体取决于索引类型。例如:在B-tree中,与叶子页相关的行包含索引键的值和对相应表行的引用(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)
复制
虚拟事务
实际上,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;
复制
子事务
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也会报告ROLLBACK:
=> 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实际上在每个命令之前建立一个隐式保存点,并在失败时对其发起回滚。默认情况下不使用此模式,因为建立保存点(即使不回滚保存点)会带来很大的开销。