《Postgresql 内幕探索》读书笔记 - 第一章:集簇、表空间、元组
引言
个人建议本章节自己搭建一个Postgresql数据库边实战边阅读更容易理解。
思维导图
链接:https://pan.baidu.com/s/1mMpcMOSOVxcZ7gcxXWzrUQ?pwd=mv3j
提取码:mv3j
一、数据库集群的逻辑结构
1.1 天然集群
PostgreSQL天然集群,多个集群可以组成集簇,有点类似军队的连、团、旅这样的组织规则。对于我们日常学习使用的单节点则是单个集簇单个集群,自己就是集群。
PostgreSQL如何管理这种集群规则?答案是通过一个无符号4个字节的标识进行管理,一个对象就是集群里的一个数据库。
1.2 数据库对象和对象符号标识
数据库对象和对象符号标识可以通过 pg database和 pg classs 查询,代表数据库和对象之间映射。
另外集群在物理磁盘中通过文件目录形式展示,一个目录对应一个数据库,也就是一个base下子目录中有一个目录就是有一个数据库。
base 目录一个文件对应一个数据库,个人实验的映射如下:
1:template1
14485:template0
14486:postgres
数据库和堆表的OIDs分别存储在pg_database和pg_class中,可以利用下面的SQL语句查询OIDs。
数据库的OIDs
select * from pg_database;
postgres=# select * from pg_database;
oid | datname | datdba | encoding | datcollate | datctype | datistemplate | datallowconn | datconnlimit | datlastsysoid | datfrozenxid | datminmxid | dattablespace |
datacl
-------+-----------+--------+----------+-------------+-------------+---------------+--------------+--------------+---------------+--------------+------------+---------------+-------------
------------------------
14486 | postgres | 10 | 6 | en_US.UTF-8 | en_US.UTF-8 | f | t | -1 | 14485 | 727 | 1 | 1663 |
1 | template1 | 10 | 6 | en_US.UTF-8 | en_US.UTF-8 | t | t | -1 | 14485 | 727 | 1 | 1663 | {=c/postgres
,postgres=CTc/postgres}
14485 | template0 | 10 | 6 | en_US.UTF-8 | en_US.UTF-8 | t | f | -1 | 14485 | 727 | 1 | 1663 | {=c/postgres
,postgres=CTc/postgres}
(3 rows)
堆表的OIDs
select relname,oid from pg_class;
postgres=# select relname,oid from pg_class;
relname | oid
-----------------------------------------------+-------
pg_statistic | 2619
pg_type | 1247
pg_toast_1255 | 2836
pg_toast_1255_index | 2837
pg_toast_1247 | 4171
pg_toast_1247_index | 4172
pg_toast_2604 | 2830
pg_toast_2604_index | 2831
pg_toast_2606 | 2832
pg_toast_2606_index | 2833
pg_toast_2612 | 4157
pg_toast_2612_index | 4158
pg_toast_2600 | 4159
pg_toast_2600_index | 4160
pg_toast_2619 | 2840
pg_toast_2619_index | 2841
pg_toast_3381 | 3439
pg_toast_3381_index | 3440
pg_toast_3429 | 3430
pg_toast_3429_index | 3431
1.3 一切皆文件
观察PostgreSQL的目录结构就能发现和Linux有相似的地方,就是一切细节都藏在物理文件。根据数据库充当一个目录的规则,Postgresql根据数据目录、配置文件和端口号文件来创建实例。
其中包含版本号,日志,索引,事务状态等等一切相关信息,对于Postgresql来说都有相关文件进行管理和标识,所以可以说Postgresql的底层细节全部展示在数据目录文件当中。
二、数据库集群的物理结构
Postgresql数据库集群都有叫做基础目录的目录,通常在安装Postgresql之后执行 initdb 命令可以初始化生成新的数据库集群。
初始化通常生成在 PGDATA 目录。
sudo /usr/pgsql-14/bin/postgresql-14-setup initdb
以Postgresql-14版本为例,初始化之后的基础目录生成在下面的位置。
[root@localhost 14]# pwd
/var/lib/pgsql/14
这里使用ll
观察一下数据文件排列。
[root@localhost 14]# ll data/
total 68
drwx------ 5 postgres postgres 41 Jun 22 02:41 base
-rw------- 1 postgres postgres 30 Jun 22 02:41 current_logfiles
drwx------ 2 postgres postgres 4096 Jun 22 02:44 global
drwx------ 2 postgres postgres 32 Jun 22 02:41 log
drwx------ 2 postgres postgres 6 Jun 22 02:41 pg_commit_ts
drwx------ 2 postgres postgres 6 Jun 22 02:41 pg_dynshmem
-rw------- 1 postgres postgres 4577 Jun 22 02:41 pg_hba.conf
-rw------- 1 postgres postgres 1636 Jun 22 02:41 pg_ident.conf
drwx------ 4 postgres postgres 68 Jun 22 02:46 pg_logical
drwx------ 4 postgres postgres 36 Jun 22 02:41 pg_multixact
drwx------ 2 postgres postgres 6 Jun 22 02:41 pg_notify
drwx------ 2 postgres postgres 6 Jun 22 02:41 pg_replslot
drwx------ 2 postgres postgres 6 Jun 22 02:41 pg_serial
drwx------ 2 postgres postgres 6 Jun 22 02:41 pg_snapshots
drwx------ 2 postgres postgres 6 Jun 22 02:41 pg_stat
drwx------ 2 postgres postgres 63 Jun 22 03:30 pg_stat_tmp
drwx------ 2 postgres postgres 18 Jun 22 02:41 pg_subtrans
drwx------ 2 postgres postgres 6 Jun 22 02:41 pg_tblspc
drwx------ 2 postgres postgres 6 Jun 22 02:41 pg_twophase
-rw------- 1 postgres postgres 3 Jun 22 02:41 PG_VERSION
drwx------ 3 postgres postgres 60 Jun 22 02:41 pg_wal
drwx------ 2 postgres postgres 18 Jun 22 02:41 pg_xact
-rw------- 1 postgres postgres 88 Jun 22 02:41 postgresql.auto.conf
-rw------- 1 postgres postgres 28776 Jun 22 02:41 postgresql.conf
-rw------- 1 postgres postgres 58 Jun 22 02:41 postmaster.opts
-rw------- 1 postgres postgres 103 Jun 22 02:41 postmaster.pid
2.1 数据库集簇的布局
书中的版本为 Postgresql-9,这里用比较新的 Postgresql-14 版本实验。
PostgreSQL: Documentation: 14: 70.1. Database File Layout
Postgresql-14的官方文档中的各个文件含义如下。
表 70.1. PGDATA 的内容`
Item | Description | 中文简介 |
---|---|---|
PG_VERSION | A file containing the major version number of PostgreSQL | PostgreSQL主要版本号 |
base | Subdirectory containing per-database subdirectories | 包含每个数据库子目录的子目录 |
current_logfiles | File recording the log file(s) currently written to by the logging collector | 记录当前由日志收集器写入的日志文件的文件 |
global | Subdirectory containing cluster-wide tables, such as pg_database | 包含集群范围的表的子目录,如pg_database |
pg_commit_ts | Subdirectory containing transaction commit timestamp data | 包含事务提交时间戳数据的子目录 |
pg_dynshmem | Subdirectory containing files used by the dynamic shared memory subsystem | 动态共享内存子系统使用的文件的子目录 |
pg_logical | Subdirectory containing status data for logical decoding | 逻辑解码的状态数据的子目录 |
pg_multixact | Subdirectory containing multitransaction status data (used for shared row locks) | 子目录包含多事务状态数据(用于共享行锁) |
pg_notify | Subdirectory containing LISTEN/NOTIFY status data | LISTEN/NOTIFY状态数据的子目录 |
pg_replslot | Subdirectory containing replication slot data | 复制槽数据的子目录 |
pg_serial | Subdirectory containing information about committed serializable transactions | 已提交的可序列化事务信息的子目录 |
pg_snapshots | Subdirectory containing exported snapshots | 导出的快照的子目录 |
pg_stat | Subdirectory containing permanent files for the statistics subsystem | 统计子系统的永久文件的子目录 |
pg_stat_tmp | Subdirectory containing temporary files for the statistics subsystem | 统计子系统的临时文件的子目录 |
pg_subtrans | Subdirectory containing subtransaction status data | 子交易状态数据的子目录 |
pg_tblspc | Subdirectory containing symbolic links to tablespaces | 表空间符号链接的子目录 |
pg_twophase | Subdirectory containing state files for prepared transactions | 准备好的事务的状态文件的子目录 |
pg_wal | Subdirectory containing WAL (Write Ahead Log) files | WAL(提前写入日志)文件的子目录 |
pg_xact | Subdirectory containing transaction commit status data | 交易提交状态数据的子目录 |
postgresql.auto.conf | A file used for storing configuration parameters that are set by ALTER SYSTEM | 存储由ALTER SYSTEM设置的配置参数的文件 |
postmaster.opts | A file recording the command-line options the server was last started with | 服务器最后启动时的命令行选项的文件 |
postmaster.pid | A lock file recording the current postmaster process ID (PID), cluster data directory path, postmaster start timestamp, port number, Unix-domain socket directory path (could be empty), first valid listen_address (IP address or * , or empty if not listening on TCP), and shared memory segment ID (this file is not present after server shutdown) | 记录当前postmaster进程ID(PID)、集群数据目录路径、postmaster启动时间戳、端口号、Unix域套接字目录路径(可以为空)、第一个有效的listen_address(IP地址或*,如果不在TCP上监听则为空)和共享内存段ID的锁文件(服务器关闭后此文件不存在) |
2.2 数据库布局
根据上表可以知道数据表存储在base
目录下。
2.3 表和索引相关的文件的布局
2.3.1 oid 和 relfilenode
大小小于1GB的表或索引是单独的文件,存储在它所属的数据库目录下。
数据库内部表和索引作为数据库对象是通过OID来管理的,而里面的具体内容则是通过变量 relfilenode 产生关联,大部分情况下oid
和relfilenode
通常会相等,但是也有例外,比如表和索引的relfilenode
值会被一些命令(例如TRUNCATE
,REINDEX
,CLUSTER
)所改变。
比如 TRUNCATE
一个表会重新分配 relfilenode。下面使用案例验证重新分配 relfilenode,我们先创建一个测试表:
create table db_test(id int primary key, name varchar(50), age int);
postgres=# select * from pg_class where relname ='db_test';
oid | relname | relnamespace | reltype | reloftype | relowner | relam | relfilenode | reltablespace | relpages | reltuples | relallvisible | reltoastrelid | relhasindex | relisshared | relpersistence
| relkind | relnatts | relchecks | relhasrules | relhastriggers | relhassubclass | relrowsecurity | relforcerowsecurity | relispopulated | relreplident | relispartition | relrewrite | relfrozenxid | re
lminmxid | relacl | reloptions | relpartbound
-------+---------+--------------+---------+-----------+----------+-------+-------------+---------------+----------+-----------+---------------+---------------+-------------+-------------+---------------
-+---------+----------+-----------+-------------+----------------+----------------+----------------+---------------------+----------------+--------------+----------------+------------+--------------+---
---------+--------+------------+--------------
16384 | db_test | 2200 | 16386 | 0 | 10 | 2 | 16384 | 0 | 0 | -1 | 0 | 0 | t | f | p
| r | 3 | 0 | f | f | f | f | f | t | d | f | 0 | 734 |
1 | | |
(1 row)
可以看到这里的 relfilenode 为 16384。下面我们执行 truncate 命令查看这个值是否改变。
postgres=# truncate db_test;
TRUNCATE TABLE
postgres=# select * from pg_class where relname ='db_test';
oid | relname | relnamespace | reltype | reloftype | relowner | relam | relfilenode | reltablespace | relpages | reltuples | relallvisible | reltoastrelid | relhasindex | relisshared | relpersistence
| relkind | relnatts | relchecks | relhasrules | relhastriggers | relhassubclass | relrowsecurity | relforcerowsecurity | relispopulated | relreplident | relispartition | relrewrite | relfrozenxid | re
lminmxid | relacl | reloptions | relpartbound
-------+---------+--------------+---------+-----------+----------+-------+-------------+---------------+----------+-----------+---------------+---------------+-------------+-------------+---------------
-+---------+----------+-----------+-------------+----------------+----------------+----------------+---------------------+----------------+--------------+----------------+------------+--------------+---
---------+--------+------------+--------------
16384 | db_test | 2200 | 16386 | 0 | 10 | 2 | 16389 | 0 | 0 | -1 | 0 | 0 | t | f | p
| r | 3 | 0 | f | f | f | f | f | t | d | f | 0 | 735 |
1 | | |
(1 row)
可以看到 oid 虽然还是 16384,但是 relfilenode 变成了 16389。
2.3.2 内建函数pg_relation_filepath
内建函数pg_relation_filepath
能够根据OID或名称返回关系对应的文件路径。比如下面的案例:
select oid,relname,relfilenode from pg_class limit 10;
oid | relname | relfilenode
------+---------------------+-------------
2619 | pg_statistic | 2619
1247 | pg_type | 0
2836 | pg_toast_1255 | 0
2837 | pg_toast_1255_index | 0
4171 | pg_toast_1247 | 0
4172 | pg_toast_1247_index | 0
2830 | pg_toast_2604 | 2830
2831 | pg_toast_2604_index | 2831
2832 | pg_toast_2606 | 2832
2833 | pg_toast_2606_index | 2833
使用pg_relation_filepath
函数返回关系对应的文件路径:
select pg_relation_filepath('pg_statistic') from pg_class limit 20;
pg_relation_filepath
----------------------
base/14486/2619
base/14486/2619
base/14486/2619
base/14486/2619
base/14486/2619
base/14486/2619
base/14486/2619
2.3.3 relfilenode.1
规则
如果一个数据文件的大小超过1GB,PostgreSQL会创建并使用一个名为relfilenode.1
的新文件,如新文件再次被写满,则创建下一个名为relfilenode.2
的新文件。注意这条规则适用于索引文件和数据文件,目的是防止单个文件过度扩张。
这个例子需要实操一下,为了在本地验证这一点,这里需要构建一个千万数据的表。
下面构建千万表的资料来自于网络。
- 首先创建序列:
CREATE SEQUENCE upms_log_id_seq START 10;
执行结果如下:
postgres=# CREATE SEQUENCE upms_log_id_seq START 10;
CREATE SEQUENCE
- 创建测试表
CREATE TABLE "public"."t_user" (
"id" int8 NOT NULL DEFAULT nextval( 'upms_log_id_seq' :: regclass ),
"name" VARCHAR ( 255 ) COLLATE "pg_catalog"."default",
"phone" VARCHAR ( 255 ) COLLATE "pg_catalog"."default",
"birthday" TIMESTAMP ( 6 ),
"sex" VARCHAR ( 64 ) COLLATE "pg_catalog"."default",
CONSTRAINT "t_user_pkey" PRIMARY KEY ( "id" )
);
同时修改表的归属用户。
ALTER TABLE "public"."t_user" OWNER TO "postgres";
- 设置字段随机值:
select substr( 'abcdefghijklmnopqrstuvwxyz', 1, ( random( ) * 26 ) :: INTEGER );
- hone使用11位字符串:
SELECT lpad( ( random( ) * 138 ) :: INT :: TEXT, 11, '0' );
- birthday 使用字符串日期:
SELECT date(generate_series(now(), now() + '1 week', '1 day'));
- sex 使用0,1表示男女。
SELECT lpad( ( random( ) * 1 ) :: INT :: text, 1, '0' );
- 使用explain插入数据:
EXPLAIN ANALYZE INSERT INTO t_user
SELECT generate_series ( 1, 10000000 ),
substr( 'abcdefghijklmnopqrstuvwxyz', 1, ( random( ) * 26 ) :: INTEGER ),
lpad( ( random( ) * 138 ) :: INT :: TEXT, 11, '0' ),
DATE ( generate_series ( now( ), now( ) + '1 week', '1 day' ) ),
lpad( ( random( ) * 1 ) :: INT :: TEXT, 1, '0' );
运行结果如下:
postgres=# EXPLAIN ANALYZE INSERT INTO t_user
postgres-# SELECT generate_series ( 1, 10000000 ),
postgres-# substr( 'abcdefghijklmnopqrstuvwxyz', 1, ( random( ) * 26 ) :: INTEGER ),
postgres-# lpad( ( random( ) * 138 ) :: INT :: TEXT, 11, '0' ),
postgres-# DATE ( generate_series ( now( ), now( ) + '1 week', '1 day' ) ),
postgres-# lpad( ( random( ) * 1 ) :: INT :: TEXT, 1, '0' );
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------
Insert on t_user (cost=0.00..925000.03 rows=0 width=0) (actual time=53327.150..53327.162 rows=0 loops=1)
-> Subquery Scan on "*SELECT*" (cost=0.00..925000.03 rows=10000000 width=1194) (actual time=0.067..19638.272 rows=10000000 loops=1)
-> Result (cost=0.00..700000.03 rows=10000000 width=104) (actual time=0.059..16112.101 rows=10000000 loops=1)
-> ProjectSet (cost=0.00..50000.03 rows=10000000 width=12) (actual time=0.017..2277.500 rows=10000000 loops=1)
-> Result (cost=0.00..0.01 rows=1 width=0) (actual time=0.003..0.010 rows=1 loops=1)
Planning Time: 1.396 ms
Execution Time: 53335.404 ms
(7 rows)
- 上面的指令执行之后,我们先使用函数查找数据文件的路径:
postgres=# select pg_relation_filepath('t_user');
pg_relation_filepath
----------------------
base/14486/16398
(1 row)
- 观察
relfilenode.1
文件,发现并没有找到,这里怀疑数据量不够大又造了一千万数据。执行之后查看数据目录发现了.1
这个文件。
[root@localhost 14486]# pwd
/var/lib/pgsql/14/data/base/14486
-rw------- 1 postgres postgres 1073741824 Jun 22 17:35 16398
-rw------- 1 postgres postgres 282771456 Jun 22 17:36 16398.1
-rw------- 1 postgres postgres 352256 Jun 22 17:35 16398_fsm
-rw------- 1 postgres postgres 24576 Jun 22 17:35 16398_vm
可以通过启动参数 --with-segsize
更改表和索引的最大文件大小。
2.3.4 _fsm
和 _vm
文件
仔细观察目录列表,会发现很多文件都会带有 _fsm
and _vm
为后缀的相关文件,这些文件叫做 free space map
(空闲空间映射)和 visibility map
(可见性映射)。数据文件或者索引文件存在下面的差别。
数据文件:
- 空闲空间映射
free space map
:存储free space capacity
(表文件每个页面上的空闲空间信息)。 - 可见性映射
visibility map
:存储 表文件中每一页的可见性信息。
索引文件:
- 只有单独的
free space map
(空闲空间映射),没有 可见性映射visibility map
。
上面的例子中就有类似的文件产生:
[root@localhost 14486]# pwd
/var/lib/pgsql/14/data/base/14486
-rw------- 1 postgres postgres 1073741824 Jun 22 17:35 16398
-rw------- 1 postgres postgres 282771456 Jun 22 17:36 16398.1
-rw------- 1 postgres postgres 352256 Jun 22 17:35 16398_fsm
-rw------- 1 postgres postgres 24576 Jun 22 17:35 16398_vm
主体数据文件,空闲空间映射文件,可见性映射文件等这些文件在Postgresql的术语中被叫做“分支”,通常这些分支的排布规则如下:
- 数据文件分支编号为 1。
- 空闲空间映射/索引数据文件 分支的第一个编号为 1。
- 可见性映射表为数据文件第二个分支 2。
这些规则概念比较复杂,只需要知道1号分支fsm
保存了main
分支中空闲空间的信息,2号分支vm
保存了main
分支中可见性的信息即可。
此外3号分支init
是很少见的特殊分支,主要存储不被日志记录(unlogged)的表与索引。同时为了防止单个分支文件过大,PostgreSQL会将过大的分支文件切分为若干段,段的大小为 1GB,也就是类似上面数据文件的 relfilenode 分隔方式。
三、表空间
Postgresql的表空间可以看作是外部数据文件,和很多常见的RDBMS的设计理念不一样。表空间有点类似基础数据的一个映射,在基础数据中建立映射会按照版本和文件夹命名规则建立对应的表空间映射,用于存储基础数据以外的内容。
数据库集簇的表空间结构图如下:
3.1 创建表空间
如何创建表空间?答案是使用 CREATE TABLESPACE 语句,这个语句会在特定的目录下面创建表空间,并且会构建特定的子目录。构建规则如下:
PG_主版本号_目录版本号
构建表空间并且指定特定位置命令如下,需要注意指定位置之前需要确保对应位置存在,同时还需要注意权限问题:
postgres=# create tablespace tbs_test owner postgres location '/opt/postgres/tbs_test';
ERROR: directory "/opt/postgres/tbs_test" does not exist
postgres=# create tablespace tbs_test owner postgres location '/opt/postgres/tbs_test';
ERROR: could not set permissions on directory "/opt/postgres/tbs_test": Operation not permitted
创建对应表空间目录以及权限设置:
[root@localhost 14486]# mkdir -p /opt/postgres/tbs_test
[root@localhost 14486]# chown postgres:postgres /opt/postgres/
[root@localhost 14486]# ll /opt
total 0
drwxr-xr-x 3 postgres postgres 22 Jun 22 18:20 postgres
创建完成之后可以在对应的data目录下面看到一个新增的目录:
postgres=# create tablespace tbs_test owner postgres location '/opt/postgres/tbs_test';
CREATE TABLESPACE
[root@localhost data]# pwd
/var/lib/pgsql/14/data
[root@localhost data]# ll
total 72
......
drwx------ 2 postgres postgres 19 Jun 22 18:22 pg_tblspc
.....
新增的目录pg_tblspc下有一个连接文件16408,指向到/usr/local/pgdata
下,这里用tree
命令观察结果。
[root@localhost data]# tree pg_tblspc/
pg_tblspc/
└── 16408 -> /opt/postgres/tbs_test
我们访问/opt/postgres/tbs_test
查看具体文件内容。
[root@localhost data]# ll /opt/postgres/tbs_test
total 0
drwx------ 2 postgres postgres 6 Jun 22 18:22 PG_14_202107181
这里的文件确实对应上面提到的PG_主版本号_目录版本号规则,这里的202107181个人认为是发布时间(最后一个1个人认为为版本号)。
3.2 新建表到表空间
特别注意,如果在该表空间内创建一个新表,但新表所属的数据库却创建在基础目录下,那么PG会首先在版本特定的子目录下创建名称与现有数据库OID相同的新目录,然后将新表文件放置在刚创建的目录下。
比如下面的方法构建一个新表并且表空间指向tbs_test。
postgres=# create table newtab(id int) tablespace tbs_test;
CREATE TABLE
通过下面的指令可以看到新表被创建在之前创建的表空间下面。
postgres=# select pg_relation_filepath('newtab');
pg_relation_filepath
---------------------------------------------
pg_tblspc/16408/PG_14_202107181/14486/16409
(1 row)
可以查找 pg_class 表有对应的oid进一步验证。
postgres=# select relname,oid from pg_class where relname='newtab';
relname | oid
---------+-------
newtab | 16409
(1 row)
3.3 删除表空间
删除表空间前必须要删除该表空间下的所有数据库对象,否则会有下面的报错:
ERROR: tablespace "tbs_test" is not empty
删除数据表对象之后,再删除对应的表空进啊
postgres=# drop table if exists newtab;
DROP TABLE
postgres=# drop tablespace if exists tbs_test;
DROP TABLESPACE
通过下面的命令查看发现数据物理文件已经被删除了。
[root@localhost data]# ll /opt/postgres/tbs_test/
total 0
四、堆表文件的内部布局
4.1 堆表和索引组织表对比
Postgresql 的数据组织方式和Mysql完全不同,初次接触可能比较蒙圈。这里简单总结一下两者设计上的区别:
堆表:
- 数据存储在表中,索引存储在索引里,两者分开的。
- 数据在堆中是无序的,索引让键值有序,但数据还是无序的。
- 堆表中主键索引和普通索引一样的,都是存放指向堆表中数据的指针。
索引组织表:
- 数据存储在聚簇索引中,数据按照主键的顺序来组织数据,两者合二为一。
- 主键索引,叶子节点存放整行数据。
- 其他索引称为辅助索引(二级索引),叶子节点存放键值和主键值。
两者数据结构的主要区别为:堆表索引和实际数据分开,索引组织表则通常非叶子节点为索引,叶子节点为数据,所以数据和索引是直接在一块存储的。
4.2 堆表基础结构介绍
在堆表,索引,也包括空闲空间映射和可见性映射内部结构包含下面几项。
- 页(pages) 或者叫 块 (block):默认大小 8192字节(8KB)
- 页按照 0 编号,这些数字可以叫做 区块号(block numbers),如果一个区块页面被写满,则会自动追加一个新的空页面来存储增长文件。
上图中包含三种类型的数据:
堆元组(heap tuples):也就是数据本身,类似栈结构从底部开始堆叠。数据库内部是用元组标识符(tuple identifier, TID) 标识堆元组。
- TID 有多个值组成: 区块号 + 行指针偏移号。(用于索引)。
行指针(line pointer):也叫做项目指针(item pointer)。每个行指针占用4个字节,这些指针都是指向堆元组的。
- 行指针的结构是简单的线性数组设计,充当堆元组的索引,注意索引是从1开始不是0开始,这些索引被叫做偏移号(offset number),偏移号和堆元组意义对应。
首部数据(header data):页面的起始位置是PageHeaderData 首部数据,固定大小为24个字节,首部数据组成如下:
pd_lsn
:8字节的无符号整数,代表当前页面最后一次更新XLOG记录的LSN,主要和WAL机制有关。pd_checksum
:校验和,在 9.3 版本之前存储时间线标识。pd_lower
,pd_upper
:分别代表行指针的末尾和最新堆元组的起始位置。从结构图可以看出,它用来标识空闲空间的的范围。(空余空间称为空闲空间(free space)或空洞(hole))pd_special
:索引页中会用到该字段(指向特殊空间的起始位置)。而堆表页中则指向页尾。
特殊空间指的是索引使用的特殊区域,具体内容根据索引类型而定,如B树,GiST,GiN。
理解堆元组结构对于理解PostgreSQL并发控制与WAL机制是必须的。
4.3 源码解读
这部分设计可以阅读 postgres/src/include/storage/bufpage.h at master · postgres/postgres · GitHub 源码了解。
4.3.1 基础结构介绍
我们根据堆表的结构图以及源码注释了解基础结构,首先从头部结构开始:
disk page organization
:磁盘页面布局space management information generic to any page
:对任何页面都适用的通用空间管理信息
pd_lsn: identifies xlog record for last change to this page
:最近变更对应xlog记录的标识。
pd_checksum:如果设置则为校验和。
pd_flags:标记位。
pd_lower :行指针的末尾。
pd_upper :最新堆元组的起始位置。
pd_special :堆表页中则指向页尾。索引中代表特殊空间开始位置。
pd_pagesize_version :页面的大小,以及页面布局的版本号
pd_prune_xid :可以修剪的最老的元组中的XID(MVCC使用)
下面介绍关键参数的作用。
LSN值
The LSN is used by the buffer manager to enforce the basic rule of WAL thou shalt write xlog befor data". A dirty buffer cannot be dumped to disk until xlog has been flushed at least as far as the page'
- xlog至少被刷到该页的LSN甚至操作才允许缓冲区脏页刷新到磁盘
- 缓冲区管理器使用LSN来执行WAL的基本规则
校验和 pd_checksum
pd_checksum stores the page checksum, if it has been set for this page; zero is a valid value for a checksum.
- 0是合法的校验和值,pd_checksum 存储着页面的校验和。
If a checksum is not in use then we leave the field unset.
- 为了向前兼容,没有使用校验和这个字段不会有值。
This will typically mean the field is zero though non-zero values may also be present if databases have been pg_upgraded from releases prior to 9.3, when the same byte offset was used to store the current timelineid when the page was last updated.
- 这样的原因是因为 9.3 版本之前存在非0的“校验和”,因为这个字段在9.3之前是最后更新时的时间线标识。
Note that there is no indication on a page as to whether the checksum is valid or not, a deliberate design choice which avoids the problem of relying on the page contents to decide whether to verify it. Hence there are no flag bits relating to checksums
注意 页面上没有显示校验和是否有效,所以也就没有与校验和有关的标志位,这里故意这样设计是避免依靠校验和决定是否验证这一个问题。
pd_prune_xid is a hint field that helps determine whether pruning will be useful. It is currently unused in index pages.
pd_prune_xid
- pd_prune_xid 是一个提示字段,有助于确定修剪是否有用。(注意索引页暂时没有使用此字段)
The page version number and page size are packed together into a single uint16 field. This is for historical reasons: before PostgreSQL 7.3, there was no concept of a page version number, and doing it this way lets us pretend that pre-7.3 databases have page version number zero. We constrain page sizes to be multiples of 256, leaving the low eight bits available for a version number.
- 在PostgreSQL 7.3之前,没有页面版本号的概念,为了兼容假设版本号为0。
- 页面版本号和页面大小被打包到一个uint16字段中。
- 约束页面的尺寸必须为256的倍数,留下低8位用于页面版本编号。
Minimum possible page size is perhaps 64B to fit page header, opaque space and a minimal tuple; of course, in reality you want it much bigger, so the constraint on pagesize mod 256 is not an important restriction. On the high end, we can only support pages up to 32KB because lp_off/lp_len are 15 bits.
- 最小的可行页面大小可能是64字节,能放下页的首部,空闲空间,以及一个最小的元组。
- pagesize mod 256的限制并不是一个重要的限制。
- 只能支持最大32KB的页面,因为lp_off/lp_len是15位
4.3.2 PageHeaderData 结构
本部分是接着缓冲页结构介绍的,PageHeaderData 的结构定义网址如下:
postgres/src/include/storage/bufpage.h at master · postgres/postgres · GitHub
typedef struct PageHeaderData
{
// XXX LSN是任何块的成员,不仅是页面组织的成员。
/* XXX LSN is member of *any* block, not only page-organized ones */
//本页面最近变更对应xlog记录的标识
// 用于记录该页的最后一次变化
PageXLogRecPtr pd_lsn; /* LSN: next byte after last byte of xlog
* record for last change to this page */
// 校验和
uint16 pd_checksum; /* checksum */
// 标记位
uint16 pd_flags; /* flag bits, see below */
// 空闲空间起始位置
LocationIndex pd_lower; /* offset to start of free space */
// 空闲空间终止位置
LocationIndex pd_upper; /* offset to end of free space */
// 特殊用途空间的开始位置
LocationIndex pd_special; /* offset to start of special space */
// 页面版本编号(尺寸必须为256的倍数,留下低8位用于页面版本编号)
uint16 pd_pagesize_version;
// 最老的可修剪XID, 如果没有设置为0
TransactionId pd_prune_xid; /* oldest prunable XID, or zero if none */
// 行指针数组
ItemIdData pd_linp[FLEXIBLE_ARRAY_MEMBER]; /* line pointer array */
} PageHeaderData;
typedef PageHeaderData *PageHeader;
4.3.3 ItemIdData 结构
在 PageHeaderData 单中有一个 ItemIdData 的结构体定义。它的结构如下:
// 缓冲区页中的项目指针(item pointer),也被称为行指针(line pointer)
typedef struct ItemIdData ItemIdData
// 元组偏移量 (相对页面起始处)
unsigned lp_off:15
// 行指针的状态
unsigned lp_flags:2
// 元组的字节长度
// lp_len == 0 表示该行指针没有关联存储。独立于其lp_flags的状态
unsigned lp_len:15
lp_flags 这个字段一共有四种取值:
#define LP_UNUSED 0 // 空闲行指针 (lp_len必需始终为0)
#define LP_NORMAL 1 // 行指针被使用,指向一个元组 (lp_len必需始终>0)
#define LP_REDIRECT 2 // HOT技术标识 (lp_len必需为0)
#define LP_DEAD 3 // 行指针对应的元组为死元组
这些内容和得知呢?因为 ItemIdData 的结构在上面的链接并没有找到任何有关ItemIdData的代码,说明被定义到其他地方。
授人以鱼不如授人以渔,这里解释下这部分源码如何层层递进查找:PostgreSQL Source Code: src/include/storage/itemid.h File Reference
此链接中找到如下页面内容:
我们点击 ItemIdData 跳转:
点击对应内容我们会访问到下面的 Git master 的页面定义。
PostgreSQL Source Code: ItemIdData Struct Reference
从上面的截图可以看到它们各自都有相关参考页面。
- lp_flags:Referenced by mask_lp_flags().
- lp_len:Referenced by PageIndexTupleOverwrite().
- lp_off:Referenced by compactify_tuples(), PageIndexTupleDelete(), PageIndexTupleDeleteNoCompact(), and PageIndexTupleOverwrite().
这里挑选前面介绍的 lp_flags 字段介绍,于是就有了下面的相关源代码,这些代码写的非常工整,根据单词含义不难猜出逻辑:
/*
* mask_lp_flags
*
* In some index AMs, line pointer flags can be modified on the primary
* without emitting any WAL record.
*/
void
mask_lp_flags(Page page)
{
OffsetNumber offnum,
maxoff;
// 获取页面最大偏移量
maxoff = PageGetMaxOffsetNumber(page);
// 交易当前的偏移量是否合法
for (offnum = FirstOffsetNumber;
offnum <= maxoff;
offnum = OffsetNumberNext(offnum))
{
// 根据偏移量取出对应的编号
ItemId itemId = PageGetItemId(page, offnum);
// 检查当前的编号是否被使用,根据结果更新lp_flags标记。
if (ItemIdIsUsed(itemId))
itemId->lp_flags = LP_UNUSED;
}
}
这里的 LP_UNUSED 可以点击(不得不夸一下Postgresql的源码阅读网站做的有点好)
再次点击 "itemid.h" 我们可以访问到具体的源代码位置、
/*
* A line pointer on a buffer page. See buffer page definitions and comments
* for an explanation of how line pointers are used.
* 缓冲区页面上的一个行指针。 关于行指针的使用方法,请参见缓冲区页面的定义和注释。
*
* In some cases a line pointer is "in use" but does not have any associated
* storage on the page. By convention, lp_len == 0 in every line pointer
* that does not have storage, independently of its lp_flags state.
* 在某些情况下,行指针是 "使用中"z状态,但在页面上没有任何相关的存储。
* 根据惯例,在每一个没有存储空间的行指针中,lp_len == 0。都没有存储空间,这与它的lp_flags状态无关。
*/
typedef struct ItemIdData
{
unsigned lp_off:15, /* offset to tuple (from start of page)从页面的开始到元组的偏移量*/
lp_flags:2, /* state of line pointer, see below 行指针状态 */
lp_len:15; /* byte length of tuple 元组的字节长度 */
} ItemIdData;
typedef ItemIdData *ItemId;
/*
* lp_flags has these possible states. An UNUSED line pointer is available
* for immediate re-use, the other states are not.
*/
#define LP_UNUSED 0 /* unused (should always have lp_len=0) */
#define LP_NORMAL 1 /* used (should always have lp_len>0) */
#define LP_REDIRECT 2 /* HOT redirect (should have lp_len=0) */
#define LP_DEAD 3 /* dead, may or may not have storage */
五、元祖CRUD操作详解
5.1 写入方式
假设我们有一个表,仅仅由一个页面组成,页面只包含一个堆元组,此时的 pd_lower 会指向第一个行指针,pd_upper 指向第一个堆元组。
第二个元组会放到第一个元祖后面,第二个行指针被插入到第一个行指针的后面,pd_lower 会改为指向第二个行指针,pd_upper 更改指向第二个堆元组,然后更新头部的 pd_lsn
,pg_checksum
,pg_flag
等相关参数。
从上面的步骤可以看到,写入方式比较好理解,就是在行指针后面插入新的数据,以及在末端元组加入新数据,之后更新指针引用以及更新头部信息即可。
5.2 删除方式
删除方式在源代码中对应方法PageIndexTupleDelete
,这里不对源代码做讲解,而是主要提一下思路:
- 首先删除行指针,然后把后面的位置向前填充补齐空位,如果删除pd_lower指向位置则不需要移动,只需要对应更新为上一个行指针即可。
- 删除实际的堆元组。对应的也需要进行填补空缺,如果删除pd_upper指向位置则不需要移动,只需要更新为后一个即可。
- 数据的存储位置发生移动,更新数据指针的 offset 属性。
负责删除指定位置的数据,删除数据后,会将需要将空闲的数据指针和数据进行压缩合并。
/* 负责删除指定位置的数据,删除数据后,会将需要将空闲的数据指针和数据进行压缩合并 */
void PageIndexTupleDelete(Page page, OffsetNumber offnum )
{
PageHeader phdr = (PageHeader) page;
char *addr;
ItemId tup;
Size size;
unsigned offset;
int nbytes;
int offidx;
int nline;
/*
* As with PageRepairFragmentation, paranoia seems justified.
*/
if (phdr->pd_lower < SizeOfPageHeaderData ||
phdr->pd_lower > phdr->pd_upper ||
phdr->pd_upper > phdr->pd_special ||
phdr->pd_special > BLCKSZ ||
phdr->pd_special != MAXALIGN(phdr->pd_special))
ereport(ERROR,
(errcode(ERRCODE_DATA_CORRUPTED),
errmsg("corrupted page pointers: lower = %u, upper = %u, special = %u",
phdr->pd_lower, phdr->pd_upper, phdr->pd_special)));
nline = PageGetMaxOffsetNumber(page);
if ((int) offnum <= 0 || (int) offnum > nline)
elog(ERROR, "invalid index offnum: %u", offnum);
/* change offset number to offset index */
offidx = offnum - 1;
tup = PageGetItemId(page, offnum);
Assert(ItemIdHasStorage(tup));
size = ItemIdGetLength(tup);
offset = ItemIdGetOffset(tup);
if (offset < phdr->pd_upper || (offset + size) > phdr->pd_special ||
offset != MAXALIGN(offset))
ereport(ERROR,
(errcode(ERRCODE_DATA_CORRUPTED),
errmsg("corrupted line pointer: offset = %u, size = %u",
offset, (unsigned int) size)));
/* Amount of space to actually be deleted */
size = MAXALIGN(size);
// 首先删除行指针,然后把后面的位置向前填充补齐空位,如果删除**pd_lower**指向位置则不需要移动,只需要对应更新为上一个行指针即可。
/*
* First, we want to get rid of the pd_linp entry for the index tuple. We
* copy all subsequent linp's back one slot in the array. We don't use
* PageGetItemId, because we are manipulating the _array_, not individual
* linp's.
*/
nbytes = phdr->pd_lower -
((char *) &phdr->pd_linp[offidx + 1] - (char *) phdr);
if (nbytes > 0)
memmove((char *) &(phdr->pd_linp[offidx]),
(char *) &(phdr->pd_linp[offidx + 1]),
nbytes);
// 删除实际的堆元组。对应的也需要进行填补空缺,如果删除**pd_upper**指向位置则不需要移动,只需要更新为后一个即可
/*
* Now move everything between the old upper bound (beginning of tuple
* space) and the beginning of the deleted tuple forward, so that space in
* the middle of the page is left free. If we've just deleted the tuple
* at the beginning of tuple space, then there's no need to do the copy.
*/
/* beginning of tuple space */
addr = (char *) page + phdr->pd_upper;
if (offset > phdr->pd_upper)
memmove(addr + size, addr, offset - phdr->pd_upper);
/* adjust free space boundary pointers */
phdr->pd_upper += size;
phdr->pd_lower -= sizeof(ItemIdData);
// 3. 数据的存储位置发生移动,更新数据指针的 offset 属性。
/*
* Finally, we need to adjust the linp entries that remain.
*
* Anything that used to be before the deleted tuple's data was moved
* forward by the size of the deleted tuple.
*/
if (!PageIsEmpty(page))
{
int i;
nline--; /* there's one less than when we started */
for (i = 1; i <= nline; i++)
{
ItemId ii = PageGetItemId(page, i);
Assert(ItemIdHasStorage(ii));
if (ItemIdGetOffset(ii) <= offset)
ii->lp_off += size;
}
}
}
5.3 修改数据
修改数据方法对应PageIndexTupleOverwrite
,它对应的源代码如下:
bool PageIndexTupleOverwrite(Page page, OffsetNumber offnum, Item newtup, Size newsize);
{
PageHeader phdr = (PageHeader) page;
ItemId tupid;
int oldsize;
unsigned offset;
Size alignednewsize;
int size_diff;
int itemcount;
/*
* As with PageRepairFragmentation, paranoia seems justified.
*/
if (phdr->pd_lower < SizeOfPageHeaderData ||
phdr->pd_lower > phdr->pd_upper ||
phdr->pd_upper > phdr->pd_special ||
phdr->pd_special > BLCKSZ ||
phdr->pd_special != MAXALIGN(phdr->pd_special))
ereport(ERROR,
(errcode(ERRCODE_DATA_CORRUPTED),
errmsg("corrupted page pointers: lower = %u, upper = %u, special = %u",
phdr->pd_lower, phdr->pd_upper, phdr->pd_special)));
itemcount = PageGetMaxOffsetNumber(page);
if ((int) offnum <= 0 || (int) offnum > itemcount)
elog(ERROR, "invalid index offnum: %u", offnum);
tupid = PageGetItemId(page, offnum);
Assert(ItemIdHasStorage(tupid));
oldsize = ItemIdGetLength(tupid);
offset = ItemIdGetOffset(tupid);
if (offset < phdr->pd_upper || (offset + oldsize) > phdr->pd_special ||
offset != MAXALIGN(offset))
ereport(ERROR,
(errcode(ERRCODE_DATA_CORRUPTED),
errmsg("corrupted line pointer: offset = %u, size = %u",
offset, (unsigned int) oldsize)));
/*
* Determine actual change in space requirement, check for page overflow.
*/
oldsize = MAXALIGN(oldsize);
alignednewsize = MAXALIGN(newsize);
if (alignednewsize > oldsize + (phdr->pd_upper - phdr->pd_lower))
return false;
// 重新定位现有数据并更新行指针,除非新的元组与旧元组的大小相同(对齐后),要重新定位的是目标元组之前的数据
/*
* Relocate existing data and update line pointers, unless the new tuple
* is the same size as the old (after alignment), in which case there's
* nothing to do. Notice that what we have to relocate is data before the
* target tuple, not data after, so it's convenient to express size_diff
* as the amount by which the tuple's size is decreasing, making it the
* delta to add to pd_upper and affected line pointers.
*/
size_diff = oldsize - (int) alignednewsize;
if (size_diff != 0)
{
char *addr = (char *) page + phdr->pd_upper;
int i;
/* relocate all tuple data before the target tuple */
memmove(addr + size_diff, addr, offset - phdr->pd_upper);
/* adjust free space boundary pointer */
phdr->pd_upper += size_diff;
/* adjust affected line pointers too */
for (i = FirstOffsetNumber; i <= itemcount; i++)
{
ItemId ii = PageGetItemId(page, i);
/* Allow items without storage; currently only BRIN needs that */
if (ItemIdHasStorage(ii) && ItemIdGetOffset(ii) <= offset)
ii->lp_off += size_diff;
}
}
/* Update the item's tuple length without changing its lp_flags field */
tupid->lp_off = offset + size_diff;
tupid->lp_len = newsize;
/* Copy new tuple data onto page */
memcpy(PageGetItem(page, tupid), newtup, newsize);
return true;
}
上面的逻辑大致如下:
- 如果原有数据的大小和新数据相同,那么直接修改对应的数据指针和实际的数据。
- 如果不一致,需要先将数据进行删除。
- 将删除的空间进行压缩合并,并且更新所有数据指针的 offset 属性。最后才完成添加数据。
5.4 常用读取方式
读取方式分两种:顺序扫描与B树索引扫描。
- 顺序扫描:是通过行指针数组遍历,O(1) 的查找速度。
- BTree扫描:键存储被索引的列值,值存储的是堆元组的tid。查找的先按照Key搜索,找到之后根据值的TID读取对应堆元祖。TID这个属性记录堆元组偏移量和长度信息,可以直接通过扫描堆元组找到。
5.5 其他读取方式
除了上面两种经典读取方式之外,Postgresql还支持下面的读取方式。
- TID扫描
- 仅索引扫描
- 位图扫描
- GIN索引扫描
5.5.1 TID扫描
TID扫描是通过使用所需元组的TID直接访问元组的方法。我们可以通过explain
命令的tid scan
确认是否为tid扫描。
sampledb=# SELECT ctid, data FROM sampletbl WHERE ctid = '(0,1)';
ctid | data
-------+-----------
(0,1) | AAAAAAAAA
(1 row)
sampledb=# EXPLAIN SELECT ctid, data FROM sampletbl WHERE ctid = '(0,1)';
QUERY PLAN
----------------------------------------------------------
Tid Scan on sampletbl (cost=0.00..1.11 rows=1 width=38)
TID Cond: (ctid = '(0,1)'::tid)
元组标识符(tuple identifier, TID)包含区块号和行指针偏移量
5.5.2 仅索引扫描
和索引组织表的构建思路一样,建立index时包含的字段集合囊括了需要查询的字段,这样就只需在索引中取数据,就不必回表了。
仅索引扫描是几乎所有的关系型数据库查询的必备方式。
上面的案例分析,下面是分析过程:
- 我们假设有下面的表和索引。
表
- id - integer
- name text
- data text
索引
- "tbl_idx" btree (id, name)
查询语句
select id,key from tbl where id between 18 and 19
- 元组数据分析
- id=18, name = 'Queen'的Tuple_18存储在0号数据页中。
- id=19, name='BOSTON'的Tuple_19存储在1号数据页中。
- 可见性分析
- 0号页面中的元组永远可见
可见性映射(visibility map)
- 可见性映射根本作用是帮助VACUUM确定是否包含死元组,提高死元组的扫描效率
- 仅索引查询优化
- 某一页中存储所有的元组都是可见的,PostgreSQL就会使用索引元组。
- 如果存在不可见元祖,则PostgreSQL读取索引元组指向的数据元组并检查元组可见性。
由于存在不可见的元组,所以本查询的仅索引查询优化需要二次检查可见性。
5.5.3 位图扫描
位图扫描最初是为了Greenplum的Bizgres系统(专业操作系统)开发,之后被Postgresql列入标准实现。
参考:https://wiki.postgresql.org/wiki/Bitmap_Indexes#Index_Scan
bitmap scan的作用就是通过建立位图的方式,将回表过程中对标访问随机性IO的转换为顺行性行为,从而减少查询过程中IO的消耗。
注意页面位图是为每个查询动态创建的,并在位图索引扫描结束时被丢弃。
位图扫描的过程如下:
- 扫描满足条件的TID。
- TID按照页面访问顺序构建位图。
- 读取记录对应的页面只需要读取一次。
相关文章阅读:
位图扫描应用场景不多,具体可以看这篇文章介绍:
- 第一篇:PostgreSQL中的位图索引扫描(bitmap index scan) - MSSQL123 - 博客园 (cnblogs.com)
- 第二篇:PostgreSQL技术内幕(七)索引扫描_数据库_HashData_InfoQ写作社区
- 第三篇:PostgreSQL优化器之从一个关于扫描方式选择引发的思考 - 掘金 (juejin.cn)
5.5.4 GIN索引扫描
也叫做Generalized Inverted Index,通用倒排索引。
GIN索引特别适用于支持全文搜索。内部使用了倒排索引的数据结构,存储结构为(key, posting list),意味着key是关键字,posting list 是一组出现过key的位置。
GIN 最大的问题是不能频繁插入,并且插入效率很低,因为倒排索引的设计特性,增加一个索引需要更多索引项。
为了优化GIN索引插入性能,Postgresql引入了插入模式进行优化,主要思路是将GIN索引插入分为两类模式。
- 正常模式:基表元组产生的新的GIN索引立即插入GIN索引。
- fastupdate(快速更新)模式:基表元组产生的新的GIN索引会以追加的方式被插入到pending list列表中。
fastupdate(快速更新)模式这种优化思路和Mysql的插入缓冲类似,就把大量的GIN插入合并为一次插入并且一次刷新到磁盘。需要注意GIN索引的pending list代价要大,因为posting list 是一组出现过key的位置,一次大批量插入会导致扫描效率低。
注意:通过 create index 的
WITH FASTUPDATE = OFF
参数来关闭fastupdate模式为什么GIN 不使用正排索引?
答案是类似链表模式进行构建,虽然构建索引的方式简单,但是每次查找最坏需要O(n)的时间。
倒排索引则记录该文档的ID和字符在该文档中出现的位置情况,只需要扫描一次即可查找到所需的信息。
Postgresql的GIN索引具备一定的扩展性,代码上只需要实现三个用户定义方法即可。
- 比较两个键(不是被索引项)并且返回一个整数。
int compare(Datum a, Datum b)
- 根据参数inputValue生成一个键值数组
Datum * extractValue(Datum itemValue, int32 * nkeys, bool ** nullFlags)
- 根据参数query生成一个用于查询的键值数组,并返回其指针。
** pmatch, Pointer ** extra_data, bool ** nullFlags, int32 * searchMode)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。