Page 内部结构
PostgreSQL 中 Page 是一个磁盘 Block 上的一个抽象结构,用于描述 Block 内部的数据结构与组织形式。
所有数据块在读写时,必须按 Page 格式进行访问操作。
相关源码
src/include/storage/bufpage.h -- Page 相关定义
src/include/storage/itemid.h -- 行指针相关定义
Page 结构示意图
PostgreSQL 11 的 Page 格式(包含 3 行数据)如下:
行指针之前的 Page Header 总空间消耗为: (64 + 16 * 6 + 32) bit / 8 = 24 Byte
结构及标志说明
以下分别对这些结构以及对应的标志位的值进行说明:
pd_lsn
PageLSN, 记录了最后更改 Page 内容的 xlog 记录的 LSN,可关联到 WAL 日志,用于恢复和一致性校验。
因历史原因,该结构又分为两个部分:高 32 位为 xlogid,定位日志,低 32 位为记录在日志内的偏移量。
在 pd_lsn 对应的 WAL 日志内容刷出到磁盘之前,该 Page 不能刷出到磁盘。pd_checksum
当前 Page 的校验和,0 也是合法的值,表示没有设置校验和。
早期版本 Page 中这个偏移位置存储了当前时间线ID (timelineID),当升级到 9.3 的时候,其值不会被清理,会保留下来。
并且没有任何标志位能指示这个校验和是否有效,PostgreSQL 内部设计为依赖页面内容来决定是否验证它的校验和。-
pg_flags
标识页面的数据存储情况,目前只用了 3 位,没有使用的二进制位都初始化为 0 以供后续可能的使用。
目前使用的标志位有:- 0x0001 : PD_HAS_FREE_LINES
是否存在未使用的行指针,即在 pd_lower 指针之前的 pd_linp 数组中,是否存在未使用的指针,这是一个提示性的值,不是一个确保真实的值,因为对其进行的修改都不会记录到 WAL 日志中,故存在标志位数据丢失或不一致的情况。 - 0x0002 : PD_PAGE_FULL
是否没有剩余空间以供新的 Tuple 插入,当一个 UPDATE 操作找不到剩余空间的时候,会设置这个标志位。当然,这也是一个提示性的值。 - 0x0004 : PD_ALL_VISIBLE
所有的 Tuple 是否都对所有人可见。 - 0x0007 : PD_VALID_FLAGS_BITS
目前有效的标志位,即前面 3 中标志均设置。
- 0x0001 : PD_HAS_FREE_LINES
pd_lower
空闲空间的指针,指向行指针 pd_linp 的最后一个元素之后。pd_upper
空闲空间的指针,指向偏移量最小的 Tuple 数据之前。pd_special
索引相关数据的开始位置,在数据文件中为空(即 pd_special = <Page Size>).
主要针对不同索引。例如对于 B-TREE 索引,这个部分存放着索引的左右兄弟节点。-
pd_pagesize_version
由于历史原因,Page 版本和大小被打包到一个 uinit16 类型的标志位中。
这个标志位的前 8 位表示大小,后 8 位表示版本号。也就是说 Page 大小最小为 2^9 即 64B。
版本号说明如下:- 0 : PostgreSQL 7.3 之前,没有 Page 版本的概念,可认定其版本为版本是 0
- 1 : 对应 PostgreSQL 7.3 ~ 7.4 的 Page 版本
- 2 : 对应 PostgreSQL 8.0 的 Page 版本
- 3 : 对应 PostgreSQL 8.1 ~ 8.2 的 Page 版本
- 4 : 对应 PostgreSQL 8.3 及以后版本
pd_prune_xid
页面最老的可删除 Tuple 的 XID (XMAX 值),如果没有的话,设置为 0
如果该位置有值,说明当前 Page 中存在部分记录可以被 vacuum 回收,那么在执行 pruning 操作时就可能会有收益,反之则没有收益。
因为索引 Page 中没有 MVCC 多版本记录,所以该标志位不使用-
pd_linp
行指针数组,指向具体行 Tuple 的位置。
数组中每个元素占用 32 位的数据,又可以分为 3 个部分:- lp_off
Tuple 相对于 Page 开始位置的偏移量
占用 15 bit 数据,即能指向的最大位置 2^15 = 32768,这也解释了为什么 PostgreSQL 最大支持 32K 的块大小 - lp_flags
行指针的状态标志,分为如下 4 种状态位- 0 : LP_UNUSED , 未使用,对应的 lp_len 总是为 0
- 1 : LP_NORMAL , 正常使用,对应的 lp_len 总是大于 0
- 2 : LP_REDIRECT , HOT 特性中重定向的 Tuple,对应的 lp_len = 0
- 3 : LP_DEAD , dead 状态,对应的存储空间 lp_len 不确定,可能为 0,可能大于 0
- lp_len
Tuple 数据的实际存储长度
- lp_off
Free Space
Page 中空闲可分配空间,从 pd_lower 到 pd_upper 之间的区域均为空闲空间,用于 INSERT 或 UPDATE 操作所需要的额外空间。
其中行指针从 pd_lower 往后申请,Tuple 实际数据的空间从 pd_upper 往前申请,空间分配后调整 pd_lower, pd_upper 的指针位置。
Tuple 内部结构
Tuple 类型和行中各列数据的头部信息共享相同的数据结构,所以可以用相同的方法来构建和检查。但需求略有不同,数据不需要事务可见性信息,它需要一个长度字段和一些嵌入式类型信息。我们可以通过覆盖 Heap Tuple 上的 xmin/cmin/xmax/cmax/xvac 字段来实现数据上的需求。
相关源码
src/include/access/htup_detail.h -- Tuple Header 相关定义
src/include/access/htup.h -- Tuple 相关定义
Heap tuple 的头部信息,为了避免空间浪费,应该将字段以一种避免结构扩充的方式来布局。
通常,内存中所有的 tuples 都会使用数据字段进行初始化,当一个 tuple 需要写入表中时,事务相关的字段将会被写入,并覆盖数据字段。
Heap tuple 的整体结构包括:
- 固定字段 (HeapTupleHeaderData)
- NULL 位图 (若 t_infomask 中 HEAP_HASNULL 被设置)
- 空白对齐 (必须使得用户数据对齐)
- Object ID (若 t_infomask 中 HEAP_HASOID 被设置)
- 用户数据字段
Tuple 结构示意图
事务虚拟字段说明
有 5 个虚拟字段 (XMIN, CMIN, XMAX, CMAX, XVAC),它们被存储在 3 个物理字段中。
XMIN, XMAX 总是真实存储,其他三个 (CMIN, CMAX, XVAC) 共用同一个字段,因为 CMIN, CMAX 只在插入和删除的事务周期中才有意义。
如果一行 tuple 在一个事务中被插入并删除,我们会存储一个复合的命令 ID,可以映射到真实的 CMIN, CMAX,但只能在原始后端中使用本地状态,相关详细信息可在 combocid.c 中查看。
与此同时,XVAC 只在老式的 VACUUM FULL 中设置,它没有任何的命令子结构,所以不需要 CMIN, CMAX (这要求老式 VACUUM FULL 从不尝试移动 CMIN, CMAX 依然有效的 tuple,例如:正在插入或正在删除的 tuple)-
t_ctid 的说明
无论一个新的 tuple 何时存储到磁盘中,它的 t_ctid 字段都会使用其自身的 TID (location,即对应的 Page 与 行指针编号) 进行初始化。
如果这个 tuple 曾经被更新过,那么它的 t_ctid 会修改为指向更新版本的 tuple。
如果这个 tuple 因为更新了分区键,导致需要从一个分区移动到另外的分区(PostgreSQL 中分区表采用继承表来实现,所以更新分区键,实际上相当于从一张表挪动到另外一张表中),那么 t_ctid 也会设置为一个特殊的值来标识 (可查看 ItemPointerSetMovedPartitions),因此,如果 XMAX 无效或者 t_ctid 指向自己,那么 tuple 是最新的版本,如果 XMAX 有效,则表明 tuple 处于被删除中或已经删除。
可以通过跟踪 t_ctid 的链表来找到最新版本的行记录,除非它被移动到一个不同的分区中。但是要注意,VACUUM 可能会在擦除链表中 pointing tuple (older) 之前先擦除 pointed-to tuple (newer)。
因此,当跟踪一个 t_ctid 链表的时候,有必要检查 referenced slot 是否为空,或包含一个非相关的 tuple。
通过检查 referenced tuple 的 XMIN 是否与 referencing tuple 的 XMAX 相等,来验证它是否实际上是子版本(更新操作导致的两个版本,其旧版本的 XMAX 一定等于新版本的 XMIN),而不是一个被 VACUUM 释放的存储在 slot 中的非相关 tuple。如果检查失败,那么可认定为没有存活的后代版本(即当前版本正在被 VACUUM 清理)。t_ctid 有时用于存储一个推测的插入令牌,而不是一个真实的 TID。这个令牌设置在正在插入的 tuple 上直到真正继续插入为止。因此,令牌只在拥有 XMAX 进行中或无效/终止的 tuple 上看到。当插入被确认之后,令牌就会被替换为真实的 TID。绝对不会在跟踪 t_ctid 链表中看到预测插入令牌,因为它们只在插入时使用,而不是在 update 中。
NULL 位图
在固定头部字段后面,存储着 NULL 位图 (从 t_bits 开始)。如果 t_infomask 显示 tuple 中没有 null 值,那么就不会存储 NULL 位图。-
t_infomask
t_infomask 中存储的标志位有如下几种:- 0x0001 : HEAP_HASNULL , 有 NULL 值的属性
- 0x0002 : HEAP_HASVARWIDTH , 有变宽的属性(varchar 等)
- 0x0004 : HEAP_HASEXTERNAL , 有存储在外部的属性 (TOAST)
- 0x0008 : HEAP_HASOID , 有一个 OID 字段
- 0x0010 : HEAP_XMAX_KEYSHR_LOCK , XMAX (执行删除的事务) 是一个 key-shared 锁
- 0x0020 : HEAP_COMBOCID , t_cid 是一个复合 cid (既包含 CMIN 也包含 CMAX,在同一个事务中创建并删除)
- 0x0040 : HEAP_XMAX_EXCL_LOCK , XMAX (执行删除的事务) 是一个 exclusive 锁
- 0x0080 : HEAP_XMAX_LOCK_ONLY , 如果 XMAX 域有效,那么仅仅是一个锁
- 0x0100 : HEAP_XMIN_COMMITTED , XMIN (插入操作) 对应的事务已经提交,即当前 tuple 已经创建成功
- 0x0200 : HEAP_XMIN_INVALID , XMIN (插入操作) 对应的事务无效或者已经被终止了
- 0x0400 : HEAP_XMAX_COMMITTED , XMAX (删除操作) 对应的事务已经提交,即当前 tuple 已经被删除了
- 0x0800 : HEAP_XMAX_INVALID , XMAX (删除操作) 对应的事务无效或者已经被终止了
- 0x1000 : HEAP_XMAX_IS_MULTI , XMAX (删除操作) 对应的事务是一个多段事务 ID
- 0x2000 : HEAP_UPDATED , 这是数据行被更新后的版本
- 0x4000 : HEAP_MOVED_OFF , 被 9.0 之前的 VACUUM FULL 移动到另外的地方,为了兼容二进制程序升级而保留
- 0x8000 : HEAP_MOVED_IN , 与 HEAP_MOVED_OFF 相对,表明是从别处移动过来的,也是为了兼容性而保留
- 0xFFF0 : HEAP_XACT_MASK , 与可见性相关的位
- HEAP_XMAX_SHR_LOCK , HEAP_XMAX_EXCL_LOCK | HEAP_XMAX_KEYSHR_LOCK
- HEAP_LOCK_MASK , HEAP_XMAX_SHR_LOCK | HEAP_XMAX_EXCL_LOCK | HEAP_XMAX_KEYSHR_LOCK
- HEAP_XMIN_FROZEN , HEAP_XMIN_COMMITTED | HEAP_XMIN_INVALID
- HEAP_MOVED , HEAP_MOVED_OFF | HEAP_MOVED_IN
-
t_infomask2
t_infomask2 中存储的标志位有如下几种:- 0x07FF : HEAP_NATTS_MASK , 11 位,记录了属性(字段)的数量,0x1800 也是允许的
- 0x2000 : HEAP_KEYS_UPDATED , tuple 被更新且列被修改了,或者 tuple 被删除了
- 0x4000 : HEAP_HOT_UPDATED , tuple 被使用 HOT 方式更新了(即更新后的 tuple 还在当前 Page 内)
- 0x8000 : HEAP_ONLY_TUPLE , 这是 HOT tuple
- 0xE000 : HEAP2_XACT_MASK , 与可见性相关的位
- HEAP_TUPLE_HAS_MATCH , HEAP_ONLY_TUPLE, 在 Hash Join 中临时使用的标志,
只用于 Hash 表中的 tuple,且不需要可见性信息,
所以我们可以用一个可见性标志覆盖他,而不是使用一个单独的位
其他信息说明
如果依据 t_infomask 指示存在 OID 字段,那么它会存储在用户数据之前,从 t_hoff 指定的位置开始。
t_hoff 是 header 的大小(包括 NULL bitmap 和留白),其值必须是 MAXALIGN 的整数倍。
观察 page
pageinspect 模块
通过 pageinspect 扩展模块,可以在低层次观察 page 中的实际数据,而不用考虑事务及相关可见性限制,这通常用于 DEBUG 目的的数据研究。
其常用函数说明如下:
get_raw_page(relname text[, fork text], blkno int) returns bytea
从给定的 relname 文件中读取指定的 blkno 编号的 Page,可通过 fork 指定读取的文件类型: main (默认), fsm, vm, init。
该函数的返回值是后续大部分其他函数的所需要的参数,通常作为其他函数的参数调用。page_header(page bytea) returns record
解析返回 Page 的通用头部信息(堆表和索引都一样),其参数是从 get_raw_page 获取的。page_checksum(page bytea, blkno int4) returns smallint
计算一个 Page 的 checksum 数据,计算的结果可以与 Page 头部信息中的 pd_checksum 数据进行对比。heap_page_items(page bytea) returns setof record
解析返回 Page 内所有的 Tuple 指针,以及正在使用的 Tuple 头和原始数据,不考虑 MVCC 可见性控制,显示所有的 Tuple。tuple_data_split(rel_oid oid, t_data bytea, t_infomask integer, t_infomask2 integer, t_bits text [, do_detoast bool]) returns bytea[]
采用后台内部相同的方式,将 Tuple 数据拆分为属性数据,其参数来源于 heap_page_items 函数。heap_page_item_attrs(page bytea, rel_oid regclass [, do_detoast bool]) returns setof record
除了与 heap_page_items 类似功能之外,还可以解码 toast 数据。
操作实例
创建模块
create extension pageinspect;
\dx+ pageinspect
创建测试表
CREATE TABLE test (id int, name varchar(10));
INSERT INTO test values (1, 'name1');
INSERT INTO test values (2, 'name2');
SELECT * FROM test;
查看 Page Header
SELECT * FROM PAGE_HEADER(GET_RAW_PAGE('TEST', 0));
lsn | checksum | flags | lower | upper | special | pagesize | version | prune_xid
-----------+----------+-------+-------+-------+---------+----------+---------+-----------
0/32C49F8 | 0 | 0 | 32 | 8112 | 8192 | 8192 | 4 | 0
数据含义解析:
- checksum 为 0,是因为当前库没有启用 checksum
- lower 为 32,是因为 Page Header 占用 24 Byte,当前 Page 中有两行数据,每个行数据的指针占用 4 Byte,共占用 8 Byte, 24 + 8 = 32 Byte
- upper 为 8152,说明两行 tuple 数据占用了 8192 - 8112 = 80 Byte
- special 为 8192,与 pagesize 大小一致,说明这是个表的 Page,不需要 special space
查看 Page 中的记录(Tuple)
SELECT * FROM HEAP_PAGE_ITEMS(GET_RAW_PAGE('TEST', 0));
lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_field3 | t_ctid | t_infomask2 | t_infomask | t_hoff | t_bits | t_oid | t_data
----+--------+----------+--------+--------+--------+----------+--------+-------------+------------+--------+--------+-------+------------------------
1 | 8152 | 1 | 34 | 680 | 0 | 0 | (0,1) | 2 | 2306 | 24 | | | \x010000000d6e616d6531
2 | 8112 | 1 | 34 | 783 | 0 | 0 | (0,2) | 2 | 2306 | 24 | | | \x020000000d6e616d6532
数据含义解析:
- 输出的前 4 列来源于行指针,以 lp_ 开头,分别代表:行号,行对应的 tuple 数据偏移量,标志位,tuple 数据长度
- lp_off 偏移量字段中,最小值即为 Page 头部信息中 upper 指针的位置,对应 8112
- lp_len 为 34 说明 tuple 数据长度为 34,其中 t_hoff 为 24,说明 tuple 头部信息占用 24 Byte,实际数据占用的空间为 34 - 24 = 10 Byte,从 t_data 数据也可以看出这一点。
- t_xmin 对应插入事务的 ID,默认 psql 中每一条语句都是一个事务,所以看到 t_xmin 的值是不一样的
- t_max 值均为 0 ,说明两条数据均未被删除
- t_field3 是一个复合多功能字段(对应 C 中的 union 结构)
- t_infomask2 为 2,其中低 11 位记录 tuple 中属性的数量,即当前 tuple 中包含两个属性(字段 id 和 name)
- t_infomask 为 2306,转换成16进制 0x0902 = 0x0800 + 0x0100 + 0x0002,即未删除,插入已提交,属性中含有变长的属性(name 为 varchar)
解析 Tuple 数据
SELECT * FROM tuple_data_split('test'::regclass::int, '\x010000000d6e616d6531'::bytea, 2306, 2, NULL);
tuple_data_split
-----------------------------------
{"\\x01000000","\\x0d6e616d6531"} -- 1, 'name1'
尝试多次更新同一条一条数据
UPDATE test SET NAME = 'update1' WHERE ID = 1;
UPDATE test SET NAME = 'update2' WHERE ID = 1;
再次查看页面数据
SELECT * FROM PAGE_HEADER(GET_RAW_PAGE('TEST', 0));
lsn | checksum | flags | lower | upper | special | pagesize | version | prune_xid
-----------+----------+-------+-------+-------+---------+----------+---------+-----------
0/32C9F70 | 0 | 0 | 40 | 8032 | 8192 | 8192 | 4 | 787
SELECT * FROM HEAP_PAGE_ITEMS(GET_RAW_PAGE('TEST', 0));
lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_field3 | t_ctid | t_infomask2 | t_infomask | t_hoff | t_bits | t_oid | t_data
----+--------+----------+--------+--------+--------+----------+--------+-------------+------------+--------+--------+-------+----------------------------
1 | 8152 | 1 | 34 | 680 | 787 | 0 | (0,3) | 16386 | 1282 | 24 | | | \x010000000d6e616d6531
2 | 8112 | 1 | 34 | 783 | 0 | 0 | (0,2) | 2 | 2306 | 24 | | | \x020000000d6e616d6532
3 | 8072 | 1 | 36 | 787 | 788 | 0 | (0,4) | 49154 | 8450 | 24 | | | \x010000001175706461746531
4 | 8032 | 1 | 36 | 788 | 0 | 0 | (0,4) | 32770 | 10242 | 24 | | | \x010000001175706461746532
数据含义解析:
- 可以看到 lower 和 upper 指针的相应变化, lower 增大, upper 减小,对应该 Page 中剩余空间的减小
- lp 字段为 Tuple 数据在 Page 中的自身 ctid,而 t_ctid 字段则记录着 Tuple 数据的版本变更历史(通常对应 update 操作)。更新两次之后,可以看出其变化为 (0, 1) -> (0, 3) -> (0, 4)
- 更新字段导致的版本变化也能体现在 XMIN/XMAX 信息中,事务 787 中删除了 (0,1) 新增了 (0,3),而事务 788 删除了 (0,3) 新增了 (0,4)
- t_infomask2 也有着对应的变化,其中:
- (0,1) 变为 16386,十六进制为 0x4002 = 0x4000 + 0x0002,表明当前 Tuple 被 HOT 更新,属性数量为 2;
- (0, 3) 变为 49154,十六进制为 0xC002 = 0x8000 + 0x4000 + 0x0002,表明当前 Tuple 是 HOT 更新生成的,且又被 HOT 更新了,属性数量为 2;
- (0,4) 变为 32770,十六进制为 0x8002 = 0x8000 + 0x0002,表明当前 Tuple 是 HOT 更新生成的,属性数量为 2;
- t_infomask 也有对应的变化,其中:
- (0,1) 变为 1282,十六进制为 0x0502 = 0x0400 + 0x0100 + 0x0002,即:删除事务已提交,插入事务已提交,有变宽属性(varchar)
- (0,3) 变为 8450,十六进制为 0x2102 = 0x2000 + 0x0100 + 0x0002,即:这是更新后的版本,插入事务已提交,有变宽属性(varchar)
- (0,4) 变为 10242,十六进制为 0x2802 = 0x2000 + 0x0800 + 0x0002,即:这是更新后的版本,未删除,有变宽属性(varchar)
删除一条数据
DELETE FROM test WHERE id = 2;
再次查看页面数据
SELECT * FROM PAGE_HEADER(GET_RAW_PAGE('TEST', 0));
lsn | checksum | flags | lower | upper | special | pagesize | version | prune_xid
-----------+----------+-------+-------+-------+---------+----------+---------+-----------
0/32CA2D0 | 0 | 0 | 40 | 8032 | 8192 | 8192 | 4 | 787
SELECT * FROM HEAP_PAGE_ITEMS(GET_RAW_PAGE('TEST', 0));
lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_field3 | t_ctid | t_infomask2 | t_infomask | t_hoff | t_bits | t_oid | t_data
----+--------+----------+--------+--------+--------+----------+--------+-------------+------------+--------+--------+-------+----------------------------
1 | 8152 | 1 | 34 | 680 | 787 | 0 | (0,3) | 16386 | 1282 | 24 | | | \x010000000d6e616d6531
2 | 8112 | 1 | 34 | 783 | 789 | 0 | (0,2) | 8194 | 258 | 24 | | | \x020000000d6e616d6532
3 | 8072 | 1 | 36 | 787 | 788 | 0 | (0,4) | 49154 | 9474 | 24 | | | \x010000001175706461746531
4 | 8032 | 1 | 36 | 788 | 0 | 0 | (0,4) | 32770 | 10498 | 24 | | | \x010000001175706461746531
数据含义解析:
- 删除之后,在 Page 中依然可以看到 (0,2) 对应的数据
- (0,2) 数据的 t_xmax 从 0 变化为 789,表名在 789 事务中执行了删除操作(或者由 update 导致的删除)
- (0,2) 数据的 t_infomask2 变为 8194,转化为十六进制 0x2002 = 0x2000 + 0x0002,即:Tuple 被删除了,属性数量为2
- (0,2) 数据的 t_infomask 变为 258,转化为十六进制 0x0102 = 0x0100 + 0x0002,即:插入事务已提交,有变宽属性(varchar)
多版本简述
通过跟踪 t_xmin, t_xmax, t_ctid 三个字段的变化,可以得到 Tuple 数据的多版本变化历史,这也是 PostgreSQL 的 MVCC 实现原理
- 插入时,记录 t_xmin,t_ctid 指向自身
- 更新时,实际上转化为旧 tuple 的删除与新 tuple 的插入,同时将旧 tuple 的 t_ctid 指向新 tuple,表明二者的多版本先后关系
- 删除时,记录 t_xmax
- 多版本数据可见性,由当前事务ID, t_xmin, t_xmax, t_infomask, t_infomask2 共同决定
PostgreSQL 的多版本(MVCC)与 Oracle 有很大的不同,在于其将多版本信息与表数据存储在一起,这种多版本实现方式有其优势与局限性。
优势
- 回滚操作可能立即完成,因为各个版本的数据都在表中存储,只需要修改部分标志位即可(Oracle 中可能需要修改大量的实际数据)
- 删除操作不实际删除数据,非常快速(Oracle 实际上删除操作也不删除数据,只标记行指针,性能也较为快速)
- 不需要额外的空间存储多版本数据(Oracle 需要使用 UNDO 表空间存储多版本数据,生产环境容易出现 ORA-01555 错误)
劣势
- 表数据文件中混合了多版本数据,造成表膨胀的问题,需要定期清理(由引入的 autovacuum 自动完成,或手动 vacuum)
- 由于存在表膨胀问题,导致数据过度分散,也会造成查询性能降低
- 更新操作并不是原地更新,可能导致索引的同步更新,影响性能(这个在 HOT 方式更新时得到改善)