数据库实际存储数据是通过表来存储的,具体到PostgreSQL中,是通过heap表实现的,它是存储引擎的基础。这里我们分析一下heap表。

数据组织

PostgreSQL逻辑数据组织结构为:数据库-模式(Schema)-表-行数据(tuple) image 物理上存储结构为:表空间/数据库/表文件(segments)

typedef struct RelFileNode
{
	Oid			spcNode;		/* tablespace */
	Oid			dbNode;			/* database */
	Oid			relNode;		/* relation */
} RelFileNode;

一张表被划分为多个表文件(segment)存储。

每个表文件(segment)内部切分为多个页page,默认大小为8KB。在页中存储元组tuple。

image

具体的,按什么规则去切分segment和page呢?

切分segment的规则为:表文件大小超过1GB。

1GB 的文件大小限制历史上是为了支持各种无法处理大文件的文件系统而设定的。在编译PostgreSQL时,你可以通过编译选项更改大小 (./configure –with-segsize)。

如果文件大小超过1G,则会被分割为多个segment,每个segment是一个文件,大小不超过1G。文件名为表的pg_class.relfilenode值,是一个OID类型的值。如果表分割为多个segments,则segments命名为pg_class.relfilenode.x(x为1,2,…n)。

31069       # 表relfilenode,第一个segment
31069.1     # 第二个segment
31069.2     # 第三个segment
...         # ...

需要注意的是,表文件命名是pg_class.relfilenode而不是pg_class.oid。在执行例如TRUNCATE TABLE时,会分配一个新的relfilenode,删掉旧的文件,并创建一个新的文件。

postgres=# select pg_relation_filepath('t6');
 pg_relation_filepath 
----------------------
 base/13010/30864
(1 row)
postgres=# select relname,oid,relfilenode from pg_class where relname='t6';
 relname |  oid  | relfilenode 
---------+-------+-------------
 t6      | 20184 |       30864
(1 row)
postgres=# truncate table t6;
TRUNCATE TABLE
postgres=# select relname,oid,relfilenode from pg_class where relname='t6';
 relname |  oid  | relfilenode 
---------+-------+-------------
 t6      | 20184 |       31093
(1 row)
postgres=# select pg_relation_filepath('t6');
 pg_relation_filepath 
----------------------
 base/13010/31093
(1 row)

页切分规则:固定页大小,默认8KB

页大小也是可以修改的,在编译时可通过编译选项--with-blocksize修改,最大为32KB。

需要注意的是这里的页是逻辑上的,是在表文件内部划分为页,并不是单独一个物理文件。

表空间

表空间实际上是文件系统中的一个目录。可通过CREATE TABLESPACE命令创建表空间。其中LOCATION指定了表空间存放的目录。该目录必需存在(CREATE TABLESPACE将不创建它),应该为空,并且必须由PostgreSQL系统用户拥有。该目录必须用绝对路径指定。

CREATE TABLESPACE tablespace_name
    [ OWNER { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } ]
    LOCATION 'directory'
    [ WITH ( tablespace_option = value [, ... ] ) ]

例如:

postgres=# create tablespace mytablespace LOCATION '/home/postgres/mytablespace';
CREATE TABLESPACE

创建表空间后,可在系统表pg_tablespace中查看表空间信息。

postgres=# select * from pg_tablespace ;
  oid  |   spcname    | spcowner | spcacl | spcoptions 
-------+--------------+----------+--------+------------
  1663 | pg_default   |       10 |        | 
  1664 | pg_global    |       10 |        | 
 31084 | mytablespace |       10 |        | 
(3 rows)

pg_default 位于 PGDATA/base 目录中;除非明确选择了另一个表空间,否则它会被用作默认表空间。 pg_global 位于 PGDATA/global 目录中;它存储着整个集簇公共的系统目录对象。

我们可在PGDATA/pg_tblspc目录下查看所有的表空间。表空间目录命名默认为pg_tblspc/<oid>,同时在创建表空间时,会在表空间目录下创建一下子目录PG_<major_version>_<Catalogue version numbe>

#define TABLESPACE_VERSION_DIRECTORY	"PG_" PG_MAJORVERSION "_" \
									CppAsString2(CATALOG_VERSION_NO)

image

# 在pg_tblspc目录下查看所有的表空间
postgres@slpc:~/pgdata-14/pg_tblspc$ ls
31084
# 在表空间目录下会创建一个目录:PG_<major_version>_<Catalogue version numbe>
postgres@slpc:~/pgdata-14/pg_tblspc/31084$ ls
PG_14_202107181

实质是在pg_tblspc目录下创建了一个指向表空间目录的软链接

postgres@slpc:~/pgdata-14/pg_tblspc$ ll
total 8
drwx------.  2 postgres postgres 4096  8月  9 06:42 ./
drwx------. 20 postgres postgres 4096  8月  9 06:31 ../
lrwxrwxrwx.  1 postgres postgres   27  8月  9 06:42 31084 -> /home/postgres/mytablespace/

PostgreSQL中所有的数据都是相对于PGDATA目录的。

CREATE DATABASECREATE TABLECREATE INDEX都可以指定表空间,如果没有指定具体的表空间的话,采用默认表空间。

我们可以在CREATE DATABASE创建数据库时指定表空间。

CREATE DATABASE name
    [ WITH ] [ OWNER [=] user_name ]
           [ TEMPLATE [=] template ]
           [ TABLESPACE [=] tablespace_name ]
           ...

此时,数据库会创建在表空间目录下,

-- 创建数据库时指定表空间
postgres=# create database db1 with tablespace=mytablespace;
CREATE DATABASE
-- 查看数据库OID
postgres=# select oid,datname,dattablespace from pg_database where datname='db1';
  oid  | datname | dattablespace 
-------+---------+---------------
 31085 | db1     |         31084
(1 row)

数据库创建在了表空间目录下:

postgres@slpc:~/pgdata-14/pg_tblspc/31084/PG_14_202107181$ ls
31085

我们也可以在创建表时指定表空间:

postgres=# \h create table
Command:     CREATE TABLE
Description: define a new table
Syntax:
CREATE [ [ GLOBAL | LOCAL ] { TEMPORARY | TEMP } | UNLOGGED ] TABLE [ IF NOT EXISTS ] table_name ( [
  { column_name data_type [ COMPRESSION compression_method ] [ COLLATE collation ] [ column_constraint [ ..
. ] ]
    | table_constraint
    | LIKE source_table [ like_option ... ] }
    [, ... ]
] )
[ INHERITS ( parent_table [, ... ] ) ]
[ PARTITION BY { RANGE | LIST | HASH } ( { column_name | ( expression ) } [ COLLATE collation ] [ opclass ]
 [, ... ] ) ]
[ USING method ]
[ WITH ( storage_parameter [= value] [, ... ] ) | WITHOUT OIDS ]
[ ON COMMIT { PRESERVE ROWS | DELETE ROWS | DROP } ]
[ TABLESPACE tablespace_name ]

示例:

postgres=# create table t1(a int, b int default 0) tablespace mytablespace;
CREATE TABLE

该表被创建在mytablespace表空间中。

# 13010为postgres数据库OID,31085为我们新创建的db1数据库oid
postgres@slpc:~/pgdata-14/pg_tblspc/31084/PG_14_202107181$ ls
13010  31085
postgres@slpc:~/pgdata-14/pg_tblspc/31084/PG_14_202107181$ cd 13010/
# 31086为我们在postgres数据库中创建的指定表空间的表t1
postgres@slpc:~/pgdata-14/pg_tblspc/31084/PG_14_202107181/13010$ ls
31086

我们查看表t1具体的存储路径:

postgres=# select pg_relation_filepath('t1');
            pg_relation_filepath             
---------------------------------------------
 pg_tblspc/31084/PG_14_202107181/13010/31086
(1 row)

我们切换数据库,在数据库db1中创建表,并查看表文件路径

postgres=# \c db1
You are now connected to database "db1" as user "postgres".
db1=# create table t1(a int, b int);
CREATE TABLE
db1=# select pg_relation_filepath('t1');
            pg_relation_filepath             
---------------------------------------------
 pg_tblspc/31084/PG_14_202107181/31085/31090
(1 row)

在postgres数据库中查看表t2文件路径,是在默认表空间下。

postgres=# create table t2(a int, b int);
CREATE TABLE
postgres=# select pg_relation_filepath('t2');
 pg_relation_filepath 
----------------------
 base/13010/31062
(1 row)

简要总结:

  1. 表的路径:表空间/数据库/表文件
  2. 数据库可以在一个表空间也可以在多个表空间中,比如数据库中一部分表可以放在表空间A中,另一部分表可以放在表空间B中。

同一个表空间可以被不同数据库使用,并且每个数据库可以在多个表空间中存储数据。逻辑结构和物理数据布局并不相互依赖。

Relation(表,或者关系)文件

对(Relation)表,除了存储数据的表文件,还会存在fsm以及vm文件。

  • 表文件:存储实际数据。
  • fsm文件:空闲空间映射表(Free Space Map,FSM),用于记录表的空闲空间信息。
  • vm文件:vm文件记录了页面可见性信息以及是否需要被清理。

image 示例:

31069        # 表relfilenode, 第1个segment
31069.1      # 第2个segment
31069.2      # 第3个segment
31069_fsm    # fsm文件
31069_vm     # vm文件

在获取Relation文件路径时,需要指定分支ForkNumber,来访问不同的分支。

typedef enum ForkNumber
{
	InvalidForkNumber = -1,      // 无效
	MAIN_FORKNUM = 0,            // 主分支
	FSM_FORKNUM,                 // 空闲空间映射表
	VISIBILITYMAP_FORKNUM,       // 可见性映射表
	INIT_FORKNUM                 // 初始化分支,_init结尾,比较特殊,用于unlogged表,当unlogged表在崩溃后,无法恢复一致的数据,因此,在恢复期间,内核会简单的删除此类对象的所有分支,并用初始化分支覆盖主分支
} ForkNumber;

在内核中,读取Relation文件时需要获取文件的物理路径,是通过函数GetRelationPath实现的。

/*
 * 获取Relation文件路径
 * 参数:
 *    dbNode: 数据库OID
 *    spcNode: 表空间OID
 *    relNode: 表OID
 *    backendId: 后台进程ID,临时表使用
 *    forkNumber: 分支编号
 * 返回值: Relation文件路径
 */
extern char *GetRelationPath(Oid dbNode, Oid spcNode, Oid relNode,
							 int backendId, ForkNumber forkNumber);

以默认空间为例,命名格式如下: 主分支 "base/%u/%u", dbNode, relNode FSM分支:主分支 + _fsm VM分支:主分支 + _vm 临时表:"base/%u/t%d_%u",dbNode, backendId, relNode 临时表比较特殊,需要后台进程ID以及t标识

详细命名规则看GetRelationPath源码。

如何定位到页?

PostgreSQL中,每个数据页都被分配一个唯一的标签BufferTag,缓冲区标签。RelFileNode以及ForkNumber决定了关系具体分支文件,BlockNumber则决定了页面偏移量。

typedef struct buftag
{
	RelFileNode rnode;			/* physical relation identifier */
	ForkNumber	forkNum;
	BlockNumber blockNum;		/* blknum relative to begin of reln */
} BufferTag;

在heap中,每个页为8KB(默认大小),块号顺序递增,超过1G则切分新的segment。

|             segments 0             |   segments 1 | ... |
| page0 | page 1 | ... | page 131072 | page 131073  | ... |

根据这个规则,很容易通过块号定位到是那个segment,以及在segment中的偏移量。

#define RELSEG_SIZE 131072  // 每个segment最多131072个页 = 1G/8k
// 获取segment号,对应那个segment文件
targetseg = blkno / ((BlockNumber) RELSEG_SIZE);
// 获取segment中的偏移量
seekpos = (off_t) BLCKSZ * (blocknum % ((BlockNumber) RELSEG_SIZE));

在我们需要访问某个页时,可通过函数ReadBufferExtended将页读入缓冲池中。

extern Buffer ReadBufferExtended(Relation reln, ForkNumber forkNum,
								 BlockNumber blockNum, ReadBufferMode mode,
								 BufferAccessStrategy strategy);

image

关于缓冲池的部分,这里不进行赘述。

在调用ReadBufferExtended读取页时,在没有缓冲池没有命中的情况下会通过读取文件获取页,与物理文件交互的过程在PostgreSQL中增加了一层抽象存储管理器smgr。目前仅有“磁盘”存储管理器,(并不是仅支持磁盘,而是支持任何提供标准文件系统的设备)。

// 对读操作进行抽象
void		(*smgr_read) (SMgrRelation reln, ForkNumber forknum,
							  BlockNumber blocknum, char *buffer);
// 读操作
void smgrread(SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, char *buffer)
{
    // 由底层(例如:md)具体实现读操作
	  smgrsw[reln->smgr_which].smgr_read(reln, forknum, blocknum, buffer);
}
// md实现具体的文件读操作
extern void mdread(SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, char *buffer);

调用栈:

heapgetpage    // 获取页
  ReadBufferExtended
    ReadBuffer_common
      smgrread
        mdread
          FileRead
            pread   // 系统调用

在云原生数据库的实现中,因为存算分离架构,此处就是计算与存储的分界点之一。需要在此处实现云原生存储相关的处理。另一个比较重要的点是WAL日志。某种程度上,从软件架构上你可以理解为有状态的服务和无状态的服务,存储就是有状态服务,无状态服务往往提供的是计算相关的逻辑,可以比较容易的进行水平扩展。

PostgreSQL一个表最大可以有多少个页?

在PostgreSQL中,BlockNumber由32位无符号整型表示,最大值为2^32-1,这个特殊的值用来标识ReadBufferExtended创建新的页,实际最大值为2^32-2,每个页8KB,理论上表最大的大小为:(2^32-2) * 8KB = 32T

typedef uint32 BlockNumber;

#define InvalidBlockNumber		((BlockNumber) 0xFFFFFFFF)
#define MaxBlockNumber			((BlockNumber) 0xFFFFFFFE)  // 2^32 - 2 = 32T 

页面布局

页面布局可参考下图:

image

主要包括三部分:

  • 页头:PageHeaderData占24字节,保存着页面元数据信息。
  • 行指针数组:每个行指针ItemIdData占4字节,保存着指向堆元组的指针,包含<偏移量,长度>信息。
  • 空闲空间: 未使用的页面空间。
  • 堆元组:数据记录本身,从页面底部倒序堆叠。
  • 特殊空间:特殊空间special space,在某些索引页中会使用到该字段,在普通表(非索引)中该区域为空。(特殊空间是仅由索引使用的特殊数据区域,包含特定的数据,具体内容依索引的类型而定)

页面布局如下:

/*
 * +----------------+---------------------------------+
 * | PageHeaderData | linp1 linp2 linp3 ...           |
 * +-----------+----+---------------------------------+
 * | ... linpN |									  |
 * +-----------+--------------------------------------+
 * |		   ^ pd_lower							  |
 * |												        |
 * |			 v pd_upper							  |
 * +-------------+------------------------------------+
 * |			 | tupleN ...                         |
 * +-------------+------------------+-----------------+
 * |	   ... tuple3 tuple2 tuple1 | "special space" |
 * +--------------------------------+-----------------+
 *									^ pd_special
 */
页头

页头存储了页的元数据信息,

typedef struct PageHeaderData
{
	PageXLogRecPtr pd_lsn;		      /* 该页面最后一次修改所写入的XLOG记录的LSN */
	uint16		pd_checksum;	        /* 页面校验和 checksum */
	uint16		pd_flags;		          /* 标识位 */
	LocationIndex pd_lower;		      /* 空闲空间的起始位置 */
	LocationIndex pd_upper;		      /* 空闲空间的结束位置 */
	LocationIndex pd_special;	      /* 特殊空间的起始位置 */
	uint16		pd_pagesize_version;  /* 页面大小,页面布局版本号 */
	TransactionId pd_prune_xid;     /* 页面上最老的可修剪XID,如果没有则为0 */
	ItemIdData	pd_linp[FLEXIBLE_ARRAY_MEMBER]; /* 行指针数组 */
} PageHeaderData;

typedef PageHeaderData *PageHeader;

pg_flags标识位如下:

#define PD_HAS_FREE_LINES	  0x0001	/* 是否存在任何未使用的行指针? */
#define PD_PAGE_FULL		    0x0002	/* 如果UPDATE操作在页面中找不到足够的空闲空间来存放其新元组版本,则设置PD_PAGE_FULL */
#define PD_ALL_VISIBLE		  0x0004	/* 所有行都是可见的 */

LSN被缓冲区管理器用于强制执行WAL的基本规则:“WAL先于数据写入”。在xlog刷盘位置超过本页面LSN之前,不允许将缓冲区的脏页刷入磁盘。

行指针

行指针定义:

typedef struct ItemIdData
{
	unsigned	lp_off:15,		/* 元组从页面开始的偏移量 */
				lp_flags:2,		/* 状态位 */
				lp_len:15;		/* 元组长度 */
} ItemIdData;

typedef ItemIdData *ItemId;

获取页内的行数,计算方法:(pd_lowder - 页头) / 行指针的大小

#define PageGetMaxOffsetNumber(page) \
	(((PageHeader) (page))->pd_lower <= SizeOfPageHeaderData ? 0 : \
	 ((((PageHeader) (page))->pd_lower - SizeOfPageHeaderData) \
	  / sizeof(ItemIdData)))

根据行指针获取行数据:

#define PageGetItem(page, itemId) \
( \
	AssertMacro(PageIsValid(page)), \
	AssertMacro(ItemIdHasStorage(itemId)), \
	(Item)(((char *)(page)) + ItemIdGetOffset(itemId)) \
)