一、前言
PostgreSQL 是一个设计精良、开源的多用途关系型数据库系统,在全球范围内被广泛使用。
它是一个庞大的系统,由多个集成的子系统组成,每个子系统都具有特定的复杂功能,并且彼此协同工作。尽管理解其内部机制对于 PostgreSQL 的管理和集成至关重要,但其庞大性和复杂性使得这一任务变得困难。
二、数据库集群(database cluster)、数据库(databases)和表(tables)
1.数据库集群的逻辑结构
数据库集群是由 PostgreSQL 服务器管理的一组数据库的集合。如果你是第一次听到这个定义,可能会感到疑惑。在 PostgreSQL 中,“数据库集群”这一术语并不意味着“一组数据库服务器”。PostgreSQL 服务器运行在单个主机上,并管理一个数据库集合。
图 1.1 展示了数据库集群的逻辑结构。数据库是数据库对象的集合。在关系数据库理论中,数据库对象是用于存储或引用数据的数据结构。(堆)表是一个典型的例子,除此之外还有许多其他对象,例如索引、序列、视图、函数等。在 PostgreSQL 中,数据库本身也是数据库对象,并且在逻辑上彼此分离。所有其他数据库对象(例如表、索引等)都属于各自的数据库。

PostgreSQL 中的所有数据库对象在内部都由各自的对象标识符(OID)管理,这些标识符是无符号的 4 字节整数。数据库对象与其对应 OID 之间的关系存储在相应的系统目录中,具体存储位置取决于对象的类型。例如,数据库和堆表的 OID 分别存储在 pg_database 和 pg_class 中。
要查找所需对象的 OID,可以执行以下查询:
sampledb=# SELECT datname, oid FROM pg_database WHERE datname = 'sampledb';
datname | oid
----------+-------
sampledb | 16384
(1 row)
sampledb=# SELECT relname, oid FROM pg_class WHERE relname = 'sampletbl';
relname | oid
-----------+-------
sampletbl | 18740
(1 row)
2.数据库集群的物理结构
数据库集群本质上是一个单独的目录,称为基目录(base directory)。它包含一些子目录和许多文件。当你执行 initdb 实用程序来初始化一个新的数据库集群时,会在指定目录下创建一个base目录。base目录的路径通常设置为环境变量 PGDATA。
图 1.2 展示了 PostgreSQL 中数据库集群的一个示例。数据库是base目录下的一个子目录,而每个表和索引(至少)是一个文件,存储在其所属数据库的子目录下。此外,还有一些子目录包含特定的数据和配置文件。
虽然 PostgreSQL 支持表空间(tablespace),但该术语的含义与其他关系数据库管理系统(RDBMS)不同。在 PostgreSQL 中,表空间是一个单独的目录,包含base目录之外的一些数据。

在以下小节中,将介绍 PostgreSQL 中数据库集群的布局、数据库、与表和索引相关的文件以及表空间的内容。
(1)数据库集群的布局
数据库集群的布局已在官方文档(https://www.postgresql.org/docs/current/storage-file-layout.html)中进行了描述。
本文档中列出的主要文件和子目录如表 1.1 所示:
| files | description |
|---|---|
| PG_VERSION | 一个包含 PostgreSQL 主版本号的文件 |
| current_logfiles | 用于记录由日志收集器当前写入的日志文件的文件 |
| pg_hba.conf | 用于控制 PostgreSQL 客户端认证的文件。 |
| pg_ident.conf | 用于控制 PostgreSQL 用户名映射的文件 |
| postgresql.conf | 用于设置配置参数的文件。 |
| postgresql.auto.conf | 用于存储通过 ALTER SYSTEM 设置的配置参数的文件。(版本 9.4 或更高) |
| postmaster.opts | 记录服务器上次启动时使用的命令行选项的文件。 |
| subdirectories | description |
| base/ | 包含每个数据库子目录的子目录。 |
| global/ | 包含集群范围表的子目录,例如 pg_database 和 pg_control。 |
| pg_commit_ts/ | 包含事务提交时间戳数据的子目录。(版本 9.5 或更高) |
| pg_clog/ | (versions 9.6 or earlier) 包含事务提交状态数据的子目录。在版本 10 中更名为 pg_xact。 |
| pg_dynshmem/ | 包含动态共享内存子系统使用的文件的子目录。(版本 9.4 或更高) |
| pg_logical/ | 包含逻辑解码状态数据的子目录。(版本 9.4 或更高) |
| pg_multixact/ | 包含多事务状态数据的子目录。(用于共享行锁) |
| pg_notify/ | 包含 LISTEN/NOTIFY 状态数据的子目录。 |
| pg_repslot/ | 包含复制槽数据的子目录。(版本 9.4 或更高) |
| pg_serial/ | 包含已提交可序列化事务信息的子目录。 |
| pg_snapshots/ | 包含导出快照的子目录。PostgreSQL 的函数 pg_export_snapshot 在此子目录中创建快照信息文件。(版本 9.2 或更高) |
| pg_stat/ | 包含统计子系统的永久文件的子目录。 |
| pg_stat_tmp/ | 包含统计子系统的临时文件的子目录。 |
| pg_subtrans/ | 包含子事务状态数据的子目录。 |
| pg_tblspc/ | 包含指向表空间的符号链接的子目录。 |
| pg_twophase/ | 包含预备事务状态文件的子目录。 |
| pg_wal/ | (versions 10 or later) 包含 WAL(预写日志)段文件的子目录。在版本 10 中从 pg_xlog 更名为pg_wal。 |
| pg_xact/ | (versions 10 or later) 包含事务提交状态数据的子目录。在版本 10 中从 |
| pg_xlog/ | (versions 9.6 or earlier) 包含 WAL(预写日志)段文件的子目录。在版本 10 中更名为 pg_wal |
(2)数据库布局
一个数据库就是base子目录下的一个子目录。数据库目录的名称与各自的OID(对象标识符)相同。例如,当数据库“sampledb”的OID为16384时,其子目录名称就是16384。
$ cd $PGDATA
$ ls -ld base/16384
drwx------ 213 postgres postgres 7242 8 26 16:33 16384
(3)与表和索引相关的文件布局
每个大小小于1GB的表或索引都存储在其所属数据库目录下的单个文件中。表和索引在内部由各自的OID(对象标识符)进行管理,而它们的数据文件则由变量relfilenode管理。表和索引的relfilenode值通常但不总是与各自的OID相匹配,具体细节如下所述。
例如,展示表’sampletbl’的OID和relfilenode:
sampledb=# SELECT relname, oid, relfilenode FROM pg_class WHERE relname = 'sampletbl';
relname | oid | relfilenode
-----------+-------+-------------
sampletbl | 18740 | 18740
(1 row)
在这种情况下,oid 和 relfilenode 的值是相等的。此外,表 ‘sampletbl’ 的数据文件路径为 base/16384/18740:
$ cd $PGDATA
$ ls -la base/16384/18740
-rw------- 1 postgres postgres 8192 Apr 21 10:21 base/16384/18740
表和索引的 relfilenode 值可能会因执行某些命令而改变,例如 TRUNCATE、REINDEX、CLUSTER。例如,如果我们对表 ‘sampletbl’ 执行 TRUNCATE 操作,PostgreSQL 会为该表分配一个新的 relfilenode(例如 18812),删除旧的数据文件(18740),并创建一个新的数据文件(18812)。
sampledb=# TRUNCATE sampletbl;
TRUNCATE TABLE
sampledb=# SELECT relname, oid, relfilenode FROM pg_class WHERE relname = 'sampletbl';
relname | oid | relfilenode
-----------+-------+-------------
sampletbl | 18740 | 18812
(1 row)
sampledb=# SELECT pg_relation_filepath('sampletbl');
pg_relation_filepath
----------------------
base/16384/18812
(1 row)
$ cd $PGDATA
$ ls -la -h base/16384/19427*
-rw------- 1 postgres postgres 1.0G Apr 21 11:16 data/base/16384/19427
-rw------- 1 postgres postgres 45M Apr 21 11:20 data/base/16384/19427.1
通过检查数据库子目录可以发现,每个表都有两个关联的文件,后缀分别为 _fsm 和 _vm。它们分别称为空闲空间映射表(Free Space Map)和可见性映射表(Visibility Map)。
- 空闲空间映射表 存储了表文件中每个页面的空闲空间信息。
- 可见性映射表 存储了表文件中每个页面的可见性信息。
索引只有单独的空闲空间映射表,而没有可见性映射表。
以下是一个具体的示例:
$ cd $PGDATA
$ ls -la base/16384/18751*
-rw------- 1 postgres postgres 8192 Apr 21 10:21 base/16384/18751
-rw------- 1 postgres postgres 24576 Apr 21 10:18 base/16384/18751_fsm
-rw------- 1 postgres postgres 8192 Apr 21 10:18 base/16384/18751_vm
空闲空间映射表和可见性映射表在内部也可能被称为每个关系(relation)的分支(forks)。
- 空闲空间映射表是表/索引数据文件的第一个分支(分支编号为 1),
- 可见性映射表是表数据文件的第二个分支(分支编号为 2)。
- 数据文件本身的分支编号为 0。
(4)表空间
在 PostgreSQL 中,表空间(tablespace)是base目录之外的额外数据区域。此功能是在 8.0 版本中实现的。
图 1.3 展示了表空间的内部布局及其与主数据区域的关系。

表空间是在执行 CREATE TABLESPACE 语句时指定的目录中创建的。在该目录下,会创建一个与版本相关的子目录(例如 PG_14_202011044)。版本相关子目录的命名规则如下所示:
PG _ 'Major version' _ 'Catalogue version number'
创建表空间:
CREATE TABLESPACE tablespace_name
[ OWNER { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } ]
LOCATION 'directory'
[ WITH ( tablespace_option = value [, ... ] ) ]
例如,在 /home/postgres/tblspc 目录下创建一个名为 new_tblspc 的表空间,其 OID 为 16386,此时会在表空间目录下创建一个名为 PG_14_202011044 的子目录。
$ ls -l /home/postgres/tblspc/
total 4
drwx------ 2 postgres postgres 4096 Apr 21 10:08 PG_14_202011044
表空间目录通过 pg_tblspc 子目录中的符号链接进行寻址。链接名称与表空间的 OID 值相同。
$ ls -l $PGDATA/pg_tblspc/
total 0
lrwxrwxrwx 1 postgres postgres 21 Apr 21 10:08 16386 -> /home/postgres/tblspc
当在表空间中创建一个新数据库(OID 为 16387)时,其目录会被放置在版本相关的子目录下:
$ ls -l /home/postgres/tblspc/PG_14_202011044/
total 4
drwx------ 2 postgres postgres 4096 Apr 21 10:10 16387
如果在位于base目录中的数据库内创建了一个新表,首先会在版本相关的子目录下创建一个新目录,该目录的名称根据数据库的 OID 命名。然后,新表文件会被放置在这个新创建的目录中:
sampledb=# CREATE TABLE newtbl (.....) TABLESPACE new_tblspc;
sampledb=# SELECT pg_relation_filepath('newtbl');
pg_relation_filepath
---------------------------------------------
pg_tblspc/16386/PG_14_202011044/16384/18894
3.堆表文件的内部布局
在数据文件(堆表、索引、空闲空间映射和可见性映射)中,文件被划分为固定长度的页(或块),默认情况下为8192字节(8 KB)。每个文件中的页从0开始按顺序编号,这些编号称为块号。如果文件已满,PostgreSQL会在文件末尾添加一个新的空页以增加文件大小。
页面的内部布局取决于数据文件的类型。在本节中,将描述表的布局。

表中的页面包含三种数据:
-
(1)堆元组(heap tuple)
堆元组是记录数据本身。堆元组从页面的底部开始依次堆叠。
元组的内部结构将在第5.2节和第9章中描述,因为这需要了解PostgreSQL中的并发控制(CC)和预写日志(WAL)机制。 -
(2)行指针(line pointer)
行指针长度为4字节,保存指向每个堆元组的指针。它也被称为项指针(item pointer)。
行指针形成一个简单的数组,充当元组的索引。每个索引从1开始顺序编号,称为偏移号(offset number)。当向页面添加新元组时,一个新的行指针也会被推入数组,以指向新元组。 -
(3)头部数据(header data)
在页面开头分配了由PageHeaderData结构定义的头部数据。它占24字节,包含页面的通用信息。
该结构的主要变量如下:
– pd_lsn
该变量存储由该页面最后一次更改写入的XLOG记录的LSN(日志序列号)。它是一个8字节的无符号整数,与WAL(预写日志)机制相关。详细信息在第9.1.2节中描述。
– pd_checksum
该变量存储该页面的校验和值。(注意,该变量在9.3或更高版本中支持;在早期版本中,这部分存储的是pd_tli,即页面的时间线ID。)
– pd_lower 和 pd_upper
pd_lower指向行指针的末尾,pd_upper指向最新堆元组的起始位置。
– pd_special
该变量用于索引。在表的页面中,它指向页面的末尾。(在索引的页面中,它指向特殊空间(special space)的起始位置,这是仅由索引持有的数据区域,包含根据索引类型(如B树、GiST、GiN等)特定的数据。)
行指针末尾和最新元组起始位置之间的空白空间称为空闲空间(free space)或空洞(hole)。
为了标识表中的元组,内部使用元组标识符(TID)。TID由一对值组成:包含该元组的页面的块号,以及指向该元组的行指针的偏移号。它的典型用法是在索引中。
在计算机科学领域,这种类型的页面被称为分槽页面(slotted page),而行指针对应于槽数组(slot array)。
此外,大小超过约2 KB(约8 KB的1/4)的堆元组会使用一种称为TOAST(The Oversized-Attribute Storage Technique,超大属性存储技术)的方法进行存储和管理。详细信息可以参考PostgreSQL的官方文档https://www.postgresql.org/docs/current/storage-toast.html。
4.读写元组的方法
(1)写入堆元组
假设一个表由一个页面组成,且该页面仅包含一个堆元组。该页面的pd_lower指向第一个行指针,而行指针和pd_upper都指向第一个堆元组。如图1.5(a)所示。
当插入第二个元组时,它会被放置在第一个元组之后。第二个行指针被追加到第一个行指针之后,并指向第二个元组。pd_lower会更新为指向第二个行指针,而pd_upper会更新为指向第二个堆元组。如图1.5(b)所示。页面中的其他头部数据(例如pd_lsn、pg_checksum、pg_flag等)也会被更新为适当的值;

(2)读取堆元组
这里概述了两种典型的访问方法:顺序扫描和B树索引扫描:
-
顺序扫描(Sequential scan)
它通过依次扫描每个页面中的所有行指针,顺序读取所有页面中的所有元组。如图1.6(a)所示。 -
B树索引扫描(B-tree index scan)
它读取包含索引元组的索引文件,每个索引元组由一个索引键和一个指向目标堆元组的TID(元组标识符)组成。
如果找到了包含目标键的索引元组,PostgreSQL会使用获取的TID值读取所需的堆元组。
例如,在图1.6(b)中,获取的索引元组的TID值为(block = 7, Offset = 2)。这意味着目标堆元组是表中第7页的第2个元组,因此PostgreSQL可以直接读取所需的堆元组,而无需在页面中进行不必要的扫描。

PostgreSQL 还支持 TID 扫描(TID-Scan)、位图扫描(Bitmap-Scan) 和 仅索引扫描(Index-Only-Scan)。
TID 扫描 是一种通过使用目标元组的 TID 直接访问元组的方法。例如,要查找表中第 0 页的第 1 个元组,可以发出以下查询:
sampledb=# SELECT ctid, data FROM sampletbl WHERE ctid = '(0,1)';
ctid | data
-------+-----------
(0,1) | AAAAAAAAA
(1 row)
本文译自https://www.interdb.jp/pg/pgsql01.html,更多详细信息请参考原文。




