暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

PostgreSQL的MVCC — 3.行版本

飞象数据 2022-06-10
233

我们已经讨论过隔离,并且对底层数据结构做了介绍。现在介绍一下行版本(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时,前者必须回答几个问题。

                  1. 事务xmin
                    是否已完成?否则,创建的元组必不可见。

                    这可以通过查看另一个结构来检查,该结构位于实例的共享内存中,名为"ProcArray"。此结构保存所有活动进程的列表,以及每个进程的当前(活动)事务ID。

                  2. 如果事务已完成,那么它是提交还是回滚?如果已回滚,则元组也不可见。

                    这正是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实际上在每个命令之前建立一个隐式保存点,并在失败时对其发起回滚。默认情况下不使用此模式,因为建立保存点(即使不回滚保存点)会带来很大的开销。

                                                                    文章转载自飞象数据,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                                                                    评论