代码改变世界

PostgreSQL的MVCC(2)--Forks, files, pages

2020-08-24 12:00  abce  阅读(549)  评论(0编辑  收藏  举报

上次我们讨论了数据一致性,从用户的角度分析了事务隔离级别之间的差异,并指出了了解这些的重要性。现在我们开始探索PostgreSQL如何实现快照隔离和多版本并发。

在本文中,我们将了解数据是如何在文件和页面中进行物理布局的。这使我们脱离了孤立的讨论,但这样的离题对于理解接下来的内容是必要的。我们需要弄清楚数据存储在底层是如何组织的。

Relations

如果你查看表和索引的内部,结果发现它们是以类似的方式组织的。两者都是数据库对象,其中包含一些由行组成的数据。

毫无疑问,表是由行组成的,但是对于索引来说,这不太明显。但是,想象一下B树:它由包含索引值和对其他节点或表行的引用的节点组成。可以将这些节点视为索引行,实际上,它们就是索引行。

实际上,还有一些其他对象以类似的方式组织:序列(本质上是单行表)和物化视图。还有常规视图,它们本身不存储数据,但在所有其他意义上都类似于表。

PostgreSQL中的所有这些对象称为Relation。这个词非常不恰当,因为它是关系理论中的术语。你可以在关系和表(视图)之间绘制相似之处,但是当然不能在关系和索引之间绘制相似之处。但这恰好发生了:PostgreSQL的学术渊源得以体现。在我看来,首先调用的是表和视图,其余的随着时间的推移而膨胀。

为简单起见,我们将进一步讨论表和索引,但是其他关系的组织方式完全相同。

Forks and files

通常每个关系对应几个forks。forks可以有几种类型,并且每种类型都包含某种类型的数据。

如果有forks,则首先由唯一的文件表示。文件名是数字标识符,可以在其后附加与fork名称相对应的结尾。

该文件逐渐增长,当其大小达到1 GB时,将创建一个相同fork的新文件(此类文件有时称为段)。段的序号附加在文件名的末尾。

从历史上看,文件大小的1 GB限制是为了支持不同的文件系统,其中一些文件系统无法处理较大大小的文件。您可以在构建PostgreSQL(./configure --with-segsize)时更改此限制。

因此,磁盘上的几个文件可以对应一个关系。例如,对于一张小表,将有三个。

属于一个表空间和一个数据库的对象的所有文件都将存储在一个目录中。您需要牢记这一点,因为文件系统通常无法正常处理目录中的大量文件。

在此请注意,文件又被分为页(或块),通常为8 KB。我们将进一步讨论页面的内部结构。

 

现在让我们来看看forks的类型。

main fork是数据本身:即表和索引行。main fork可用于任何关系(不包含数据的视图除外)。

main fork的文件名称由唯一的数字标识符组成。例如,这是我们上次创建的表的路径:

=> SELECT pg_relation_filepath('accounts');
 pg_relation_filepath 
----------------------
 base/41493/41496
(1 row)

这些标识符从何而来?目录的«base»对应于«pg_default»表空间。下一个子目录,对应于数据库,是感兴趣的文件的位置:

=> SELECT oid FROM pg_database WHERE datname = 'test';
  oid  
-------
 41493
(1 row)

=> SELECT relfilenode FROM pg_class WHERE relname = 'accounts';
 relfilenode 
-------------
       41496
(1 row)

路径是相对的,它是从数据目录(PGDATA)开始指定的。而且,实际上PostgreSQL中的所有路径都是从PGDATA开始指定的。由于这一点,您可以安全地将PGDATA移动到不同的位置—没有任何限制(除了需要设置LD_LIBRARY_PATH中的库的路径)。

进一步,看看文件系统:

postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41496
-rw------- 1 postgres postgres 8192  /var/lib/postgresql/11/main/base/41493/41496

 

initialization fork

initialization fork仅对未记录日志的表(使用指定的UNLOGGED创建)及其索引可用。这类对象与常规对象没有任何区别,只是对它们的操作没有记录在写前日志(WAL)中。因此,使用它们会更快,但在出现故障时,不可能恢复处于一致状态的数据。因此,在恢复过程中,PostgreSQL只删除这些对象的所有forks,并在main fork的位置写入initialization fork。这将导致一个空对象。

该«accounts»表被开启了日志,因此,它没有initialization fork。但是为了试验,我们可以关闭日志:

=> ALTER TABLE accounts SET UNLOGGED;
=> SELECT pg_relation_filepath('accounts');
 pg_relation_filepath 
----------------------
 base/41493/41507
(1 row)

这个示例说明动态打开和关闭日志的可能性与将数据重写为不同名称的文件有关。

initialization forkmain fork名称相同,但后缀为“_init”:

postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41507_init
-rw------- 1 postgres postgres 0  /var/lib/postgresql/11/main/base/41493/41507_init

free space map

free space map是一个fork,用于跟踪页面内部空闲空间的可用性。这个空间在不断变化:当添加新版本的行时,它会减少;而在vacuuming期间,它会增加。在插入新行时使用空闲空间映射,以便快速找到合适的页面,添加数据。

空闲空间映射的名称具有“_fsm”后缀。但是该文件不会立即出现,而只在需要时出现。最简单的方法就是vacuum表(到时候我们会解释原因):

=> VACUUM accounts;

postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41507_fsm
-rw------- 1 postgres postgres 24576  /var/lib/postgresql/11/main/base/41493/41507_fsm

visibility map

visibility map是一个fork,其中仅包含最新行版本的页面,用一个bit标记。粗略地说,这意味着当事务尝试从这样的页面读取一行时,可以在不检查其可见性的情况下显示该行。在下一篇文章中,我们将详细讨论这是如何发生的。

postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41507_vm
-rw------- 1 postgres postgres 8192  /var/lib/postgresql/11/main/base/41493/41507_vm

pages

如前所述,文件在逻辑上分成页。

页面的大小通常为8 KB。可以在一定限制(16 KB或32 KB)内更改大小,但只能在build过程中更改(./configure --with-blocksize)。生成并运行的实例只能使用相同大小的页面。

无论文件属于哪个fork,服务器都以非常相似的方式使用它们。页首先被读取到缓冲区高速缓存中,进程可以在其中读取和更改它们。然后,根据需要将它们逐出磁盘。

每个页都有内部分区,并且通常包含以下分区:

       0  +-----------------------------------+
          | header                            |
      24  +-----------------------------------+
          | array of pointers to row versions |
   lower  +-----------------------------------+
          | free space                        |
   upper  +-----------------------------------+
          | row versions                      |
 special  +-----------------------------------+
          | special space                     |
pagesize  +-----------------------------------+

 你可以通过使用«research»扩展pageinspect很容易地了解这些分区的大小:

=> CREATE EXTENSION pageinspect;
=> SELECT lower, upper, special, pagesize FROM page_header(get_raw_page('accounts',0));
 lower | upper | special | pagesize 
-------+-------+---------+----------
    40 |  8016 |    8192 |     8192
(1 row)

在这里,我们看表的第一页(零)的header。除了其他区域的大小之外,header还具有关于页面的不同信息,我们对此不感兴趣。

postgres=# select * from page_header(get_raw_page('accounts',0));
 lsn | checksum | flags | lower | upper | special | pagesize | version | prune_xid 
-----+----------+-------+-------+-------+---------+----------+---------+-----------
 0/0 |        0 |     4 |    40 |  8008 |    8192 |     8192 |       4 |         0
(1 row)

postgres=# 

页面底部有special space,在这种情况下为空白。它仅用于索引,甚至不用于所有索引。«At the bottom»反映了图片中的内容; 说«in high addresses»可能更准确。

在special space之后,将找到row versions,即我们存储在表中的数据以及一些内部信息。

页面顶部,紧随header之后:指向页中可用行版本的指针数组。

行版本和指针之间可以留有Free space(此可用空间在可用空间映射中保持跟踪)。 请注意,页面内没有内存碎片-所有可用空间都由一个连续区域表示。

pointers

为什么需要指向行版本的指针? 问题是索引行必须以某种方式引用表中的行版本。显然,引用必须包含文件编号,文件中的页面编号以及行版本的某些指示。我们可以使用从页面开始的偏移量作为指示,但这很不方便。 我们将无法在页面内移动行版本,因为它将破坏可用的引用。 这将导致页面内部空间的碎片化和其他麻烦的后果。 因此,索引引用指针编号,而指针引用页面中行版本的当前位置。 这是间接寻址。

每个指针正好占据四个字节,并包含:

·对行版本的引用 ·该行版本的大小 ·几个字节来确定行版本的状态

data format

磁盘上的数据格式与RAM中的数据表示形式完全相同。该页面被“按原样”读入缓冲区高速缓存,而无需进行任何转换。因此,来自一个平台的数据文件与其他平台不兼容。

例如,在X86体系结构中,字节顺序是从最低有效字节到最高有效字节(小端),z / Architecture使用相反的顺序(大端),在ARM中可以交换顺序。

许多体系结构提供了在机器字边界上的数据对齐。例如,在32位x86系统上,整数(占4个字节的“整数”类型)将在4字节字的边界上对齐,双精度数(“双精度”类型,占用8个字节)也是如此。在64位系统上,双精度数字将在8字节字的边界上对齐。这是另一个不兼容的原因。

由于对齐,表行的大小取决于字段顺序。通常,这种影响不是很明显,但有时可能会导致大小的显着增长。例如,如果对类型为“ char(1)”和“ integer”的字段进行交叉,则通常浪费它们之间的3个字节。有关更多详细信息,请查看Nikolay Shaplov的演示文稿“ Tuple internals”。

Row versions and TOAST

下次,我们将讨论行版本内部结构的详细信息。在这里,对我们来说重要的是要知道每个版本必须完全放入一页:PostgreSQL无法将一行“扩展”到下一页。而是使用超大属性存储技术(TOAST)。名称本身暗示可以将一行切成多个片。

TOAST暗示了几种策略。在将长属性值分解成小的toast chunks之后,我们可以将它们传递到单独的内部表中。另一种选择是压缩值,以使行版本适合常规页面。我们可以同时做这两个事情:首先压缩,然后分解并传输。

对于每个主表,如果需要,可以创建一个单独的TOAST表,其中一个表用于所有属性(及其上的索引)。潜在的长属性的可用性决定了这一需求。例如,如果一个表的列类型为 «numeric»或«text»,则即使不使用长值,也会立即创建TOAST表。

由于TOAST表本质上是一个常规表,因此它具有相同的forks。这会使对应于表的文件数量增加一倍。

初始策略由列数据类型定义。可以使用psql中的\d +命令查看它们,但是由于它还会输出很多其他信息,因此我们将查询系统目录:

=> SELECT attname, atttypid::regtype, CASE attstorage
  WHEN 'p' THEN 'plain'
  WHEN 'e' THEN 'external'
  WHEN 'm' THEN 'main'
  WHEN 'x' THEN 'extended'
END AS storage
FROM pg_attribute
WHERE attrelid = 'accounts'::regclass AND attnum > 0;
 attname | atttypid | storage  
---------+----------+----------
 id      | integer  | plain
 number  | text     | extended
 client  | text     | extended
 amount  | numeric  | main
(4 rows)

这些策略的名称表示:

·plain— TOAST未使用(用于已知为短数据类型,例如«integer»)。

·extended-压缩和存储都可以在单独的TOAST表中进行

·external—长值不压缩就存储在TOAST表中。

·main —长值首先被压缩,并且如果压缩无济于事,则进入TOAST表。

通常,算法如下。PostgreSQL的目标是至少一页有四行。因此,如果行大小超过页面的四分之一,则考虑header(对于常规的8K页为2040字节),必须将TOAST应用于部分值。我们遵循以下描述的顺序,并在该行不再超过阈值时立即停止:

1.首先,我们从“最长”属性到“最短”属性,通过“external”和“extended”策略来遍历属性。«extended»属性被压缩(如果有效的话),并且如果值本身超过页面的四分之一,它将立即进入TOAST表。 «External»属性的处理方式相同,但未压缩。

2.如果在第一遍之后行版本仍不适合该页面,则将带有“external”和“extended”策略的其余属性传输到TOAST表。

3.如果这也没有帮助,我们尝试使用«main»策略压缩属性,但将其保留在表的页中。

4.在3之后,该行仍然不够短时,«main»属性才能进入TOAST表。

有时,更改某些列的策略可能很有用。例如,如果事先知道无法压缩列中的数据,则可以为其设置“external”策略,这样可以避免不必要的压缩尝试,从而节省时间。这样做如下:

=> ALTER TABLE accounts ALTER COLUMN number SET STORAGE external;

重新运行查询,我们得到:

 attname | atttypid | storage  
---------+----------+----------
 id      | integer  | plain
 number  | text     | external
 client  | text     | extended
 amount  | numeric  | main

TOAST表和索引位于单独的pg_toast模式中,因此通常不可见。对于临时表,«pg_toast_temp_N»模式的使用类似于通常的«pg_temp_N»。

当然,如果你喜欢,没有人会阻止你侦查这个过程的内部机制。假设,在«accounts»表中有三个可能很长的属性,因此,必须有一个TOAST表。这里是:

=> SELECT relnamespace::regnamespace, relname
FROM pg_class WHERE oid = (
  SELECT reltoastrelid FROM pg_class WHERE relname = 'accounts'
);
 relnamespace |    relname     
--------------+----------------
 pg_toast     | pg_toast_33953
(1 row)

=> \d+ pg_toast.pg_toast_33953
TOAST table "pg_toast.pg_toast_33953"
   Column   |  Type   | Storage 
------------+---------+---------
 chunk_id   | oid     | plain
 chunk_seq  | integer | plain
 chunk_data | bytea   | plain

将“plain”策略应用于切成toasts 是合理的:没有第二级的toasts 。

PostgreSQL更好地隐藏索引,但是也不难找到:

=> SELECT indexrelid::regclass FROM pg_index
WHERE indrelid = (
  SELECT oid FROM pg_class WHERE relname = 'pg_toast_33953'
);
          indexrelid           
-------------------------------
 pg_toast.pg_toast_33953_index
(1 row)

=> \d pg_toast.pg_toast_33953_index
Unlogged index "pg_toast.pg_toast_33953_index"
  Column   |  Type   | Key? | Definition 
-----------+---------+------+------------
 chunk_id  | oid     | yes  | chunk_id
 chunk_seq | integer | yes  | chunk_seq
primary key, btree, for table "pg_toast.pg_toast_33953"

«client»列使用«extended»策略:它的值将被压缩。让我们检查:

=> UPDATE accounts SET client = repeat('A',3000) WHERE id = 1;
=> SELECT * FROM pg_toast.pg_toast_33953;
 chunk_id | chunk_seq | chunk_data 
----------+-----------+------------
(0 rows)

在TOAST表中什么也没有:重复字符被压缩得很好,压缩后的值适合通常的表页。

现在让客户端名称由随机字符组成:

=> UPDATE accounts SET client = (
  SELECT string_agg( chr(trunc(65+random()*26)::integer), '') FROM generate_series(1,3000)
)
WHERE id = 1
RETURNING left(client,10) || '...' || right(client,10);
        ?column?         
-------------------------
 TCKGKZZSLI...RHQIOLWRRX
(1 row)

这样的序列不能被压缩,它进入到toast表:

=> SELECT chunk_id,
  chunk_seq,
  length(chunk_data),
  left(encode(chunk_data,'escape')::text, 10) ||
  '...' ||
  right(encode(chunk_data,'escape')::text, 10) 
FROM pg_toast.pg_toast_33953;
 chunk_id | chunk_seq | length |        ?column?         
----------+-----------+--------+-------------------------
    34000 |         0 |   2000 | TCKGKZZSLI...ZIPFLOXDIW
    34000 |         1 |   1000 | DDXNNBQQYH...RHQIOLWRRX
(2 rows)

我们可以看到数据被分解为2000字节的chunks。

当访问长值时,PostgreSQL会为应用程序自动透明地恢复原始值并将其返回给客户端。

当然,压缩和分解然后再还原非常耗费资源。因此,将大量数据存储在PostgreSQL中并不是最好的主意,特别是如果它们经常使用且使用情况不需要事务逻辑(例如:原始会计凭证的扫描)时尤其如此。一个更有益的替代方法是使用文件名存储在DBMS中的文件系统上存储此类数据。

TOAST表仅用于访问长值。此外,TOAST表支持它自己的多版本转换并发:除非数据更新达到一个长值,否则新的行版本将引用TOAST表中的相同值,从而节省了空间。

请注意,TOAST仅适用于表,不适用于索引。这对要索引的键的大小施加了限制。

 

 

原文地址:https://habr.com/en/company/postgrespro/blog/469087/