PostgreSQL-2中的MVCC。 叉子,文件,页面

上一次我们讨论数据一致性时,从用户的角度看了事务隔离级别之间的差异,并弄清了为什么了解这一点很重要。 现在,我们开始探索PostgreSQL如何实现快照隔离和多版本并发。

在本文中,我们将研究数据在文件和页面中的物理布局方式。 这使我们无需讨论隔离,但是这种离题对于理解接下来的内容是必要的。 我们将需要弄清楚数据存储的底层组织方式。

关系


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

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

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

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

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

叉子和文件


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

如果有叉子,则首先由only file表示。 文件名是数字标识符,可以在其后附加与分叉名称相对应的结尾。

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

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

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

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

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



现在让我们看一下fork类型。

主要的分支是数据本身:表和索引行。 主派生可用于任何关系(不包含数据的视图除外)。

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

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

这些标识符从何而来? “基本”目录对应于“ 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 

初始化派生仅适用于未记录的表(使用指定的UNLOGGED创建)及其索引。 像这样的对象与常规对象没有什么不同,除了它们的操作未记录在预写日志(WAL)中。 因此,使用它们的速度更快,但是如果出现故障,则不可能以一致的状态恢复数据。 因此,在恢复过程中,PostgreSQL只是删除了这些对象的所有分支,并写了初始化分支来代替主分支。 这将导致一个空对象。 我们将在另一个系列中详细讨论日志记录。

由于记录了“帐户”表,因此它没有初始化派生。 但是要进行实验,我们可以关闭登录:

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

该示例阐明了即时打开和关闭日志记录的可能性与将数据重写到具有不同名称的文件有关。

初始化派生叉与主派生叉具有相同的名称,但后缀为“ _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 

可用空间图是一个分支,用于跟踪页面内可用空间的可用性。 该空间在不断变化:添加新版本的行时该空间减小,而在清理期间则增大。 在插入新行版本期间使用了可用空间图,以便快速找到合适的页面,要添加的数据将适合该页面。

可用空间图的名称带有“ _fsm”后缀。 但是此文件不会立即显示,而仅在需要时显示。 实现此目的的最简单方法是对表进行清理(我们将解释为什么会出现这种情况):

 => 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 

可见性地图是一个分叉,其中仅包含最新行版本的页面用一位标记。 粗略地讲,这意味着当事务尝试从此类页面读取行时,可以在不检查其可见性的情况下显示该行。 在接下来的文章中,我们将详细讨论这种情况。

 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 

页数


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

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

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

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

        0 + ----------------------------------- +
           | 标头|
       24 + ----------------------------------- +
           | 指向行版本的指针数组|
   较低+ ----------------------------------- +
           | 自由空间|
   上层+ ----------------------------------- +
           | 行版本|
 特殊+ ----------------------------------- +
           | 特殊空间|
页面大小+ ----------------------------------- +

您可以使用“ 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) 

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

页面底部有特殊空间 ,在这种情况下为空白。 它仅用于索引,甚至不用于所有索引。 “在底部”反映了图片中的内容; 说“在高位”可能更准确。

在特殊空间之后,将找到行版本 ,即,我们存储在表中的数据以及一些内部信息。

目录的顶部是页面顶部,紧随标题之后:该页面中包含指向行版本的指针数组

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

指针


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

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

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

资料格式


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

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

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

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

行版本和TOAST


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

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

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

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

初始策略由列数据类型定义。 您可以使用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) 

这些策略的名称表示:

  • 普通-未使用TOAST(用于已知为short的数据类型,例如“整数”)。
  • 扩展-压缩和存储都可以在单独的TOAST表中进行
  • 外部-长值不压缩就存储在TOAST表中。
  • main-首先压缩long值,如果压缩无济于事,则只进入TOAST表。

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

  1. 首先,我们从“最长”属性到“最短”属性,通过“外部”和“扩展”策略来遍历属性。 “扩展”属性被压缩(如果有效),并且如果值本身超过页面的四分之一,它将立即进入TOAST表。 “外部”属性的处理方式相同,但未压缩。
  2. 如果在第一次通过后,行版本仍不适合该页面,则将带有“外部”和“扩展”策略的其余属性传输到TOAST表。
  3. 如果这也没有帮助,我们尝试使用“主要”策略压缩属性,但将其保留在表页面中。
  4. 而且只有在此之后,该行还不够短时,“ main”属性才能进入TOAST表。

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

 => 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_temp_N”,使用“ pg_toast_temp_N”模式。

当然,如果您喜欢,没人会阻碍您对程序内部机制的监视。 假设在“帐户”表中有三个潜在的长属性,因此,必须有一个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 

将“普通”策略应用于行被切片的吐司是合理的:没有第二级的TOAST。

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" 

“客户”列使用“扩展”策略:其值将被压缩。 让我们检查一下:

 => 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字节的块。

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

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

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

请注意,TOAST仅适用于表,不适用于索引。 这对要索引的键的大小施加了限制。
有关内部数据结构的更多详细信息,请阅读文档

继续阅读

Source: https://habr.com/ru/post/zh-CN469087/


All Articles