PostgreSQL 是如何存储数据的

本文介绍 PostgreSQL 的 relation 存储结构。

Oid

每个数据库就对应一个 oid,可以从 pg_database 里查到:

pagila=# select datname, oid from pg_database;
 postgres  | 12994
 testdb    | 16384
 template1 |     1
 template0 | 12993
 pagila    | 17097

所有数据都存储在 $PGDATA 下,默认是 /var/lib/postgresql/data,这个目录下有很多文件,其中 base 目录就是所有数据藏身的地方:

root@ce31a4292dc9:/var/lib/postgresql/data/base# ls
1  12993  12994  16384	17097  pgsql_tmp

oid 全称 Object identifiers,是 PG 内部系统表用来作为唯一标识的,以数字命名的文件夹和上面查出来的 oid 是一一对应的。

Relation

进入到 17097,即 pagila 所在的文件夹,发现里面一些以数字命名的文件,可以猜测,这些也是 oid。

root@ce31a4292dc9:/var/lib/postgresql/data/base/17097# ls
112
113
1247
1247_fsm
1247_vm
1249
1249_fsm
1249_vm
1255
1255_fsm
1255_vm
1259
1259_fsm
1259_vm
12829
12829_fsm
12829_vm
...

这里存的其实是所有 relation 相关的数据文件。relation 是数据库理论中相比 table 更宽泛的概念。在 PG 中,table,index,sequences (本质上是单行的表),materialized views (本质上是记住查询的表),composite types 和 TOAST tables 都称为 relation。

可以从 pg_class 表中找到 relation 的 oid,以 film 表为例:


pagila=# select oid, relfilenode from pg_class where relname = 'film';
  oid  | relfilenode
-------+-------------
 17155 |       17155
(1 row)

准确来说,relfilenode 的值才是文件名中的数字,只是目前这个值等于 oid。有些表可能不会以文件的形式存储,但是还是能在 pg_class 中查到,这时候 relfilenode 的值为零。

从 pg_class 中有两个影响存储结构的指标,reltuples 表示有多少行,relpages 表示存储这些行用了多少数据页。

pagila=# SELECT relname, oid, relpages, reltuples FROM pg_class where relname = 'film';
 relname |  oid  | relpages | reltuples
---------+-------+----------+-----------
 film    | 17155 |       55 |      1000
(1 row)

Folk

一个 relation 通常并不是只对应一个数据文件,还有一些 folk。folk 有几种类型,每一种类型存储一种特定类型的数据。

root@ce31a4292dc9:/var/lib/postgresql/data/base/17097# ls -l | grep 17155
-rw------- 1 postgres postgres  450560 Mar 23 05:39 17155
-rw------- 1 postgres postgres   24576 Mar 23 05:39 17155_fsm
-rw------- 1 postgres postgres       0 Mar 23 05:28 17155_vm

上面列出的就是 film 表的三个 folk:main,fsm,vm。

main folk 即无后缀的文件,存储的就是表的数据,文件大小的默认上限为 1G,超过 1G 会创建新的,后面加上序号作为后缀,如 17155,17155.1,17155.2 ...

img

fsm folk 即 fsm 后缀的 folk,fsm 是 free space map 的缩写,它是用来追踪 page 里面可用空间的,方便为插入的新行快速找个合适的位置。

vm folk 即 vm 后缀的 folk,vm 是 visibility map 的缩写,它是用来追踪 page 里面最新版本的行。

除此之外,还有个 init folk,即 init 后缀的 folk,init 是 initialization 的缩写,只有在表被指定为 UNLOGGED 才会出现。UNLOGGED 的意思是,对它的操作不会为写入 write-ahead log (WAL)。因为不用写 WAL,它的速度很快,缺点是当出现故障时无法保证数据的一致性。

Page

注意上面查查的存储 film 表文件的大小 450560 和 page 数 55 之间的关系:

450560 / 55 = 8192。

在 PG 中,所有磁盘 I/O 是以 page 为单位计算的,默认情况下是 8192 字节(8k) 一个 page,即使只是从一张表里读取一条数据,PG 也会至少读取一个 page。当更新一行数据时,PG 将新的行添加到表的最末端,并把原来的行标记为无效。

不同 folk 中的 page 行为是相似的。page 首先被从磁盘读入 buffer cache,进程可以读取、修改里面的内容,在特定时候再写回磁盘。

page 有下面几个部分:

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

可以使用 pageinspect 插件提供的方法查询各个部分的大小:

pagila=#  CREATE EXTENSION pageinspect;

pagila=# SELECT lower, upper, special, pagesize FROM page_header(get_raw_page('film',0));
 lower | upper | special | pagesize
-------+-------+---------+----------
    96 |   464 |    8192 |     8192
(1 row)

header:其他部分的大小和一些 page 相关的信息

special space:索引相关信息

row versions:行数据真正存储的地方,再加上一些内部信息

array of pointers :到各个 row version 的指针数组

存储行数据的 page 叫 heap page;

存储索引数据数据的 page 叫 index page。

如果一个数据行太大,超过了 8k,一个 page 都存不下,这时候,PG 会将一部分数据写入 TOAST table。TOAST 的全称是 the oversized attribute storage technique。

Page Caching

所有数据库系统都受到下面两个规则的约束:

  • 内存读写快,磁盘读写慢
  • 内存资源紧张,磁盘资源富余

PG 通过将一些常用的数据保存在内存中以达到减少磁盘 I/O 的目的。服务器启动时,内存中会创建一个 buffer cache 的结构,其在组织上也是按 8k 一个 page,和磁盘上的 page 对应。

当从表中读取一行数据时,PG 将 heap page 读取到 buffer cache,如果空间不足,PG 会从 cache 移除一些,如果被移除的 page 有改动,那么它们会被写回磁盘。

Pointer

为什么需要指向 row version 的指针呢?

因为 index row 必须以某种方式找到 row version。找到 row version 需要 file number、page number,可用用 row 在 page 中的偏移量定位到 row version,但是这样不方便。最终的方案是 index 指向 pointer number,pointer 指向 row vertion 在 page 中的位置。

每个 pointer 占据 4 个字节,包含:

  • row version 的位置
  • row version 的大小
  • row version 的状态

参考

[1] How PostgreSQL Organizes Data

[2] Forks, files, pages

[3] pg_class