本系列的内容主要翻译自Postgresql官方博客,为了便于理解,对于其中部分涉及到的知识,我在查阅相关资料的基础上做了补充。

原文: MVCC in PostgreSQL — 2. Forks, files, pages

系列文章索引

上一篇我们讲到了数据的一致性,从使用者的角度来观察在不同事务隔离级别下的不同表现。并且说明了为什么了解这些内容是必要的。现在,我们开始去探索PostgreSQL是怎么实现快照隔离和多版本并发的。

这篇文章中,我们将会去研究数据是如何在文件和页面中布局的。这篇文章是独立的内容,但是为了理解后面的内容,这样的“跑题”也是需要的。我们需要弄清楚数据存储在底层是如何组织的。

关系

如果你看过表和索引的内部接口,就会发现这两个的内部组织是很相似的。两者都是数据库对象,都包含一些由行组成的对象。

毫无疑问,表是由行组成的,但是在索引上,就没那么明显了。然而,想象一下B树:它由被索引的值和指向其它节点或者表行的节点组成的。这些节点可以当成索引中的行,事实上,它们就是行。

实际上,还有一些对象也是以类似的方式组织的:序列(基本上是单行表)、物化视图(记住查询的结果的表)。其它一些的常规的视图,虽然不存储数据,但是其它方面也和表相似。

PostgreSQL中的所有这些对象,都被叫做关系(relation)。这个词是十分不恰当的,因为这是一个来自关系理论的术语。你可以认为关系是表(视图),但是关系和索引之间却很难建立其联系。在我看来,应该是表和视图最先被叫做了关系,后面其它的对象也就被当成了关系。

为了让事情简单,我们后面将只讨论表和索引,但是其它的关系也是以同样的方式被组织的。

Forks和文件

对于每个关系,通常都会有几个Forks。Forks有几种不同的类型,每种类型都包含着一种类型的数据。

每个Fork都有专门的一个文件。文件的名字是一个数字标识和表示Fork类型的后缀组成。

文件的大小会不断的增长,当其增加到1GB的时候,这个Fork就会创建一个新的文件(像这样的文件有时候也被称为 段)。段的序列好会被追加到文件名后面。

1GB的限制是因为以前要支持不同的文件系统,当时有些文件系统不能够处理超过1G的文件。这个值可以修改,只需要在构建的时候指定./configure –with-segsize即可。

所以,一个关系可以在磁盘上有多个对应的文件。例如,一个小的表,就将会有三个文件。

属于一个表空间和一个数据库的所有文件对象都会被存在同一个目录中。需要记住的是,如果一个目录中有大量的文件的话,文件系统可能不能正常的工作。

文件将会被进一步划分为页面(或者叫做块),通常是8KB,我们后面将会进一步讨论页面的内部结构。

现在我们来看一下fork的类型。

main fork是数据本身:表和索引的行。除了不包含数据的常规视图,其余的所有关系都有main fork。

main fork的文件名只有数字。例如,我们上一篇讲的表的路径:

1
=> SELECT pg_relation_filepath('accounts');
1
2
3
4
5
 pg_relation_filepath 
----------------------
base/16384/193225
(1 row)

这个路径是怎么产生的?base是默认的表空间”pg_default”对应的目录,下一个子目录对应的数据库的oid:

1
=> SELECT oid FROM pg_database WHERE datname = 'mydb';
1
2
3
4
  oid  
-------
16384
(1 row)

文件名则对应关系的oid

1
=> SELECT relfilenode FROM pg_class WHERE relname = 'accounts';
1
2
3
4
 relfilenode 
-------------
193225
(1 row)

这个路径是相对路径,父目录是PGDATA。此外,PostgreSQL中几乎所有的路径都是在PGDATA里。因此,可以安全的将PGDATA文件移到不同的目录,没有任何限制(可能不能设置在LD_LIBRARY_PATH指定的目录里)。

最后,看一下文件系统

1
ls -l --time-style=+ /opt/pgdata/base/16384/175821
1
-rw------- 1 postgres postgres 0  /opt/pgdata/base/16384/175821

initialization fork 只有unlogged的表(创建的时候指定UNLOGGED)以及它的索引有这个文件。这样的表和常规的不同的地方是没有预写日志(即WAL)。因为这个原因,它运行的速度快,但是当因为数据库崩溃要恢复数据库的时候不能保证数据一致性。因此,在PostgreSQL恢复这样的数据的时候,会用initialization fork直接替换掉main fork文件,这就导致表中的数据全部被清空。我们会在另外的章节中讨论细节。

UNLOGGED的表在遇到数据库故障后,表中的数据会全部丢失

“accounts”表是logged的,因此,没有initialization fork。但是为了实验,我们可以将其WAL关闭

1
2
=> ALTER TABLE accounts SET UNLOGGED;
=> SELECT pg_relation_filepath('accounts');
1
2
3
 pg_relation_filepath 
----------------------
base/16384/193233

这个例子也说明了在运行的过程中打开和关闭关系的日志功能,会改变数据文件的名字。

再查看base/16384目录,就能看到除了数据文件以外,还多了一个_init结尾的文件

1
ll | grep  193233
1
2
-rw------- 1 postgres postgres          0 Jan 13 14:04 193233
-rw------- 1 postgres postgres 0 Jan 13 14:04 193233_init

free space map是用来跟踪页面内可用空间的可用性的。这个空间是不断变化的:当增加行的新版本时,这个空间会减小,当执行清理的时候(vacuuming),它会增加。再插入新的行的版本的时候,就会用到空闲空间映射表来找到合适的页面中合适的位置。

空闲空间映射表的文件名以 “_fsm” 结尾。但是这个文件并不是立即出现的,而是在需要的时候才会出现。最简单的方法就是清理表(我们会在合适的时候讲到原因)

visibility map是将标记那些只包含最新的行版本的页面的fork。粗略的可以认为,当事务从这样的页面中读取行数据的时候,可以不必检查其可见性。我们在下一篇文章中将会详细讨论这种情况。

页面

前面已经提到,文件被划分为页面。

每个页面的大小大概为8KB。这个尺寸可以在编译构建的时候来修改为16KB或者32KB(./configure –with-blocksize)。但是一个构建后并运行的实例只能在相同大小的页面上工作。

无论是哪个fork的文件,数据库都以相同的方式使用页面。页面首先被读入缓冲区,在缓冲区中进程可以读取并修改它们;然后再需要的时候,再将数据写入到磁盘中。

每一个页面都有内部的分区,一般来说,包含下面几个部分

1
2
3
4
5
6
7
8
9
10
11
       0  +-----------------------------------+
| header |
24 +-----------------------------------+
| array of pointers to row versions |
lower +-----------------------------------+
| free space |
upper +-----------------------------------+
| row versions |
special +-----------------------------------+
| special space |
pagesize +-----------------------------------+

可以使用”pageinspect”扩展来查看这些分区

1
2
CREATE EXTENSION pageinspect;
SELECT lower, upper, special, pagesize FROM page_header(get_raw_page('accounts',0));
1
2
3
4
 lower | upper | special | pagesize 
-------+-------+---------+----------
28 | 8152 | 8192 | 8192
(1 row)

这里我们看到的是表中第一个页面的header。除了其它区域的大小外,header还有页面的其它一些信息。

页面的底部是special space,在这个例子中是空的。它只在部分索引中有用的。.比起”At the bottom”, “in high addresses”更准确一点。

在特殊空间的上面,是row versions的位置,这个就是我们在表中存储的数据了,同时也包含了一些额外的信息。

在页面的顶部和header的下面之间的区域,是页面中行版本数据的指针的数组。

Free space是行版本和指针数组中间的区域(也是空闲空间映射文件要跟踪的空间)。请注意,页面中没有内存碎片-所有的空闲空间都是连续的一块区域。

指针

为什么需要行版本的指针?原因是因为索引的行必须以某种方式来指向表中的行版本。很明显引用中必须包含文件的编号、页面在文件中的编号以及某种行版本的标记。我们当然可以使用行版本在页面中偏移位置来指定行版本,但是这样会很不方便。我们将不能够移动行版本的位置(Vacuum),因为这个会破坏引用。不能移动行版本的位置又会导致空间碎片以及其它麻烦的后果。因此,索引引用了指针的编号,而指针引用了页面中行版本的位置,这就是间接寻址。

每个指针中是四个字节,其中包含:

  • 行版本的引用
  • 行版本的大小
  • 表明行版本状态的字节

数据格式

磁盘中的数据格式和内存中的数据格式完全一致。页面被原样的不做任何改变的读取到buffer内存中。因此,一个平台的数据文件和另外一个平台的数据文件并不兼容

例如,在X86架构下,字节的顺序是从低位在低地址,高位在高地址(小端表示法),在z/Architecture下,字节的顺序是相反的(大端表示法),在ARM中,两者是可以互换的。

许多架构都提供了字节的对齐功能。例如,在32位的x86系统上,整数类型会在4字节的边界上对齐,和双精度的对齐方式一样。而在64位系统中,双精度的数字将在8个字节的边界上对齐。这也是不兼容的原因。

由于对齐的原因,表行的大小取决于字段的顺序。通常来说,这种影响不是很明显的。但是有的时候也会导致大小会有明显的增长。例如,如果char(1)类型的字段和integer类型的字段交错排列,就会浪费掉它们之间的三个字节的空间。

行版本和Toast

我们会在后面讨论行版本的内部细节。目前我们需要直到的是,一个行版本需要能够放在一个页面中:PostgreSQL不能够跨页面来放数据。遇到大小超过一个页面的行版本, 需要用到数据压缩技术。

TOAST 有几种策略。我们可以将一个大的值分成几个小的块存储在内部表中,也可以将数据压缩到能够放在页面中,或者,可以先执行压缩,再进行分块。

对于每一个主表,如果需要的话,单独的TOAST表就会自动被创建,其对应所有的可能需要被压缩的属性,包含上面的索引。如果一个字段的属性暗示了其可能会存长值(变长类型),那么就会创建Toast表。例如,一个表中有numeric字段或者text字段,即使没有存储长值,也会自动创建Toast表。

既然TOAST表本质上也是一个表,那么它就有同样数量的forks。这就会让一个表对应的文件数量又多了一倍。

初始的数据策略是由列的数据类型定义的,可以使用\d+ [表名] 命令来查看。由于这个命令还会输出一些别的信息,这里我们直接查询系统目录

1
2
3
4
5
6
7
8
=> 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;
1
2
3
4
5
6
7
 attname | atttypid | storage  
---------+----------+----------
id | integer | plain
number | text | extended
client | text | extended
amount | numeric | main
(4 rows)

策略的名字分别达标的意思是:

  • plain 不使用TOAST
  • extended 允许压缩并存储在TOAST表中
  • external 长值不压缩直接存储在TOAST表中
  • main 长值首先被压缩,在压缩没有帮助的情况下在存储到TOAST表中

一般情况下,数据库使用下面的逻辑:PostgreSQL的目标是一个页面至少放入四个行数据,因此,如果一个行的尺寸超过了页面的1/4(除去header部分大概是2040个字节),就必须在部分值上应用TOAST了。执行的过程如下所示,

  1. 首先对所有的指定了extended 或者external 策略的长值从最长到最短依次进行检查。extended的值会被进行压缩,压缩后的值如果大于页面的四分之一,其就会被存储在Toast表中。external 的值也是这样,但是不需要压缩
  2. 如果经过了第一步,行版本的长度仍然超过了页面的四分之一,那么extended 或者external 策略的长值就会全部被存储到Toast表中
  3. 如果经历了第二步,仍然不符合要求。数据库就会压缩那些指定了”main”策略的值,但是数据还保留在行版本中
  4. 如果经历了第三步,仍然不符合要求,那么被压缩的”main”策略的值也会被存储在Toast中。

有时候我们可能需要更改数据库的Toast的策略。例如,如果我们直到某些列的数据不能够被压缩,那么就可以指定其为“external”策略来禁止压缩从而节省时间。修改的方式如下

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

重新执行查询,结果如下

1
2
3
4
5
6
 attname | atttypid | storage  
---------+----------+----------
id | integer | plain
number | text | external
client | text | extended
amount | numeric | main

Toast表和其索引位于单独的pg_toast空间内,默认是不可见的。

当然,如果原因的话,可以查看这个过程的内部机制。例如,在“账户”表中有三个潜在的长属性。因此,其会有一个TOAST表。用下面的命令可以查询到:

1
2
3
4
=> SELECT relnamespace::regnamespace, relname
FROM pg_class WHERE oid = (
SELECT reltoastrelid FROM pg_class WHERE relname = 'accounts'
);

结果是

1
2
3
4
 relnamespace |    relname     
--------------+----------------
pg_toast | pg_toast_23486
(1 row)
1
=> \d+ pg_toast.pg_toast_23486
1
2
3
4
5
6
7
8
9
10
TOAST table "pg_toast.pg_toast_23486"
Column | Type | Storage
------------+---------+---------
chunk_id | oid | plain
chunk_seq | integer | plain
chunk_data | bytea | plain
Owning table: "public.accounts"
Indexes:
"pg_toast_23486_index" PRIMARY KEY, btree (chunk_id, chunk_seq)
Access method: heap
1
=> \d pg_toast_23486_index
1
2
3
4
5
6
  Index "pg_toast.pg_toast_23486_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_23486"

accounts表中的”client”字段是”extended”,因此其应该被压缩。我们先来检查一下

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

Toast表中是空的,因为重复的字符可以被压缩的很小,压缩后的值可以填充到页面中。

下面我们测试用随机字符来生成客户名称

1
2
3
4
5
=> 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);
1
2
3
4
        ?column?         
-------------------------
FOHXQQMNCE...PHIBENODHR
(1 row)

像这样的字符串很难被压缩,然后就会进入到Toast表中

1
2
3
4
5
6
7
=> 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_23486;
1
2
3
4
5
 chunk_id | chunk_seq | length |        ?column?         
----------+-----------+--------+-------------------------
193281 | 0 | 1996 | FOHXQQMNCE...MFXRREBWGM
193281 | 1 | 1004 | LWZZIBFYXR...PHIBENODHR
(2 rows)

我们能够看到,数据被分割成了上线接近2000字节的2部分

当需要访问长值时,PostgreSQL会自动的从TOAST表中查询并还原成长值然后返回给客户端。这一切对客户端来说时透明的。

当然,压缩和分解,然后再恢复,这个是相当耗费资源的。因此在PostgreSQL中存储大量的数据并不一定是明智的。特别是这些数据如果被使用的也很频繁的时候。一个更好的选择是将其存储在文件系统中,把文件名存储在DBMS中。

TOAST表仅用于长值。另外,TOAST也支持分流并发性:除非更新的是长值,新的行版本让仍然会引用TOAST表中相同的值,这样就会节省空间。

另外要注意的是,TOAST只能用在表上,不能用在索引上。这样的话,索引的键值的大小就受到了限制(不能超过页面的1/4)

关于内部数据结构的更多信息,可以参考官方文档