好了,我们已经讨论过
隔离,并且
对低级数据结构做了题外话。 我们终于达到了最令人着迷的东西,即行版本(元组)。
元组头
如前所述,数据库中可以同时使用每行的多个版本。 我们需要以某种方式将一个版本与另一个版本区分开。 为此,每个版本均标有有效的“时间”(
xmin
)和到期的“时间”(
xmax
)。 引号表示使用特殊的递增计数器,而不是时间本身。 这个计数器是
交易标识符 。
(通常,这实际上更加复杂:由于计数器的位深度有限,交易ID不能总是递增。但是,当我们的讨论陷入僵局时,我们将探索其更多细节。)
创建行时,将
xmin
的值设置为等于执行INSERT命令的事务的ID,而未填写
xmax
。
删除一行后,当前版本的
xmax
值将标记有执行DELETE的事务的ID。
UPDATE命令实际上执行两个后续操作:DELETE和INSERT。 在该行的当前版本中,将
xmax
设置为等于执行UPDATE的事务的ID。 然后,创建同一行的新版本,其中
xmin
的值与先前版本的
xmax
相同。
xmin
和
xmax
字段包含在行版本的标题中。 除了这些字段外,元组头还包含其他字段,例如:
infomask
确定给定元组属性的几个位。 其中有很多,我们将逐步讨论。ctid
对同一行的下一个更新版本的引用。 最新的行版本的ctid
引用该版本。 该数字采用(x,y)
形式,其中x
是页面的编号,而y
是数组中指针的顺序号。- NULL位图,用于标记给定版本中包含NULL的那些列。 NULL不是数据类型的常规值,因此,我们必须单独存储此特征。
结果,标题看起来非常大:每个元组至少23个字节,但是由于NULL位图而通常更大。 如果表是“窄”的(即,它包含的列很少),则开销字节会比有用信息占用更多空间。
插入
让我们更详细地看一下如何在低级别执行行操作,并从插入开始。
为了进行试验,我们将创建一个新表,其中包含两列,并在其中一列上具有索引:
=> 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中的“堆”一词表示表。 这是术语的另一种怪异用法:堆是已知的
数据结构 ,与表无关。 此处使用的单词的含义是“全部堆积”,这与有序索引不同。
此功能以难以理解的格式“按原样”显示数据。 为了澄清问题,我们仅保留部分信息并进行解释:
=> 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
位显示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;
在行版本的标题中发生的事情,这种形式更加清楚:
=> 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目录中的文件。 在这些文件中为每个事务分配了两位(“已提交”和“已中止”),其方式与元组头中的方式完全相同。 此信息只是为了方便而散布在几个文件中。 在讨论冻结时,我们将回到这一点。 PostgreSQL与所有其他文件一样,逐页处理这些文件。
因此,提交事务后,将在XACT中为此事务设置“已提交”位。 这就是提交事务时发生的所有事情(尽管我们还没有提到预写日志)。
当其他事务访问我们刚刚查看的表页面时,前者将不得不回答一些问题。
- 交易
xmin
是否已完成? 如果不是,则创建的元组必须不可见。
通过查看位于实例的共享内存中的另一个结构ProcArray可以检查此情况。 此结构包含所有活动进程的列表,以及每个进程的当前(活动)事务的ID。 - 如果交易完成,那么是提交还是回滚? 如果已回滚,则该元组也不得可见。
这正是XACT所需要的。 但是,尽管XACT的最后一页存储在共享内存的缓冲区中,但每次检查XACT的开销都很高。 因此,一旦确定了交易状态, xmin_committed
其写入元组的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
的状态,并将其写入信息位:
=> 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)
删掉
当删除一行时,当前删除事务的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)
中止
事务中止的工作与提交类似,只是在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
字段中标有当前交易的ID。 此外,自从上次事务回滚以来,此值已覆盖了旧值。 由于当前事务的状态尚不清楚,因此将
xmax_aborted
位复位。
该行的第一个版本现在引用第二个版本,作为较新的版本。
索引页现在包含第二个指针和第二行,该行引用表页面中的第二个版本。
与删除相同,第一个版本中的
xmax
值指示该行已锁定。
最后,我们提交交易。
=> COMMIT;
指标
到目前为止,我们仅谈论表页面。 但是索引内部会发生什么?
索引页中的信息高度取决于特定的索引类型。 而且,即使一种类型的索引也可以具有不同种类的页面。 例如:B树具有元数据页面和“常规”页面。
但是,索引页通常具有一个指向行和行本身的指针数组(就像表页一样)。 此外,页面末尾的一些空间还分配给特殊数据。
索引中的行也可以具有不同的结构,具体取决于索引类型。 例如:在B树中,与叶子页相关的行包含索引键的值和对相应表行的引用(
ctid
)。 通常,索引的结构可以完全不同。
要点是,在任何类型的索引中都没有行
版本 。 或者我们可以考虑每一行仅由一个版本表示。 换句话说,索引行的标题不包含
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的分配不需要所有进程之间的同步,因此可以非常快速地执行。 在讨论冻结时,我们将了解使用虚拟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;
子交易
保存点
在SQL中,定义了
保存点 ,这些
保存点允许回滚事务的某些操作而无需完全中止。 但这与上述模型不兼容,因为事务状态是所有更改的结果之一,并且没有物理回滚的数据。
为了实现此功能,带有保存点的事务被分为几个单独的
子事务,其状态可以分别进行管理。
子交易具有自己的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实际上在每个命令之前建立一个隐式保存点,并在失败时向其发起回滚。 默认情况下不使用此模式,因为建立保存点(即使不回滚保存点)也需要大量开销。
继续阅读 。