页面布局
页面布局可参考下图:

包括以下5个部分:
- 页头:
PageHeaderData占24字节,保存着页面元数据信息。 - 行指针数组:每个行指针
ItemIdData占4字节,保存着指向堆元组的指针,包含<偏移量,长度>信息。 - 空闲空间: 未使用的页面空间。
- 堆元组:数据记录本身,从页面底部倒序堆叠。
- 特殊空间:特殊空间
special space,在某些索引页中会使用到该字段,在普通表(非索引)中该区域为空。(特殊空间是仅由索引使用的特殊数据区域,包含特定的数据,具体内容依索引的类型而定)
页面布局如下:
/* 页头24字节 行指针数组,每个行指针4字节
* +----------------+---------------------------------+
* | PageHeaderData | linp1 linp2 linp3 ... |
* +-----------+----+---------------------------------+
* | ... linpN | |
* +-----------+--------------------------------------+
* | ^ pd_lower(空闲空间起始位置) |
* | 中间区域为空闲空间 |
* | v pd_upper(空闲空间结束位置) |
* +-------------+------------------------------------+
* | | tupleN ... |
* +-------------+------------------+-----------------+
* | ... tuple3 tuple2 tuple1 | "special space" |
* +--------------------------------+-----------------+
* ^ pd_special(页头中pg_special字段指向特殊空间起始位置)
*/
需要注意的是,PostgreSQL中,除了表(包括toast表)和索引页面,还有其他的页面,表和索引的页面布局遵循以上布局,而比如FSM页面和VM页面的布局与普通页面不同。
页头
页头存储了页的元数据信息,通过页头,我们可以计算出当前页的空闲空间大小,页中有多少元组等。
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,如果该位置有值,说明当前页中存在部分记录可被vacuum回收 */
ItemIdData pd_linp[FLEXIBLE_ARRAY_MEMBER]; /* 行指针数组 */
} PageHeaderData;
typedef PageHeaderData *PageHeader;
我们看一个实际的例子:
-- 清空表
postgres=# truncate table t1;
TRUNCATE TABLE
-- 插入一条元组,
postgres=# insert into t1 values(1,1);
INSERT 0 1
-- 查看页面头信息,此时,表共有1个页,里面只有一个元组,元组大小为32字节
-- 因为是普通表,所以,特殊空间为空
postgres=# select * from page_header(get_raw_page('t1',0));
lsn | checksum | flags | lower | upper | special | pagesize | version | prune_xid
------------+----------+-------+-------+-------+---------+----------+---------+-----------
3/20467CA0 | 0 | 0 | 28 | 8160 | 8192 | 8192 | 4 | 0
(1 row)
-- 查看元组,可以看到,元组大小为32字节,元组头部为24字节,数据8字节,元组起始位置为8160
postgres=# SELECT * FROM heap_page_item_attrs(get_raw_page('t1', 0), 't1'::regclass);
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_attrs
----+--------+----------+--------+---------+--------+----------+--------+-------------+------------+--------+--------+-------+-------------------------------
1 | 8160 | 1 | 32 | 4027950 | 0 | 0 | (0,1) | 2 | 2048 | 24 | | | {"\\x01000000","\\x01000000"}
(1 row)
其中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 : \ // pd_lower小于页头大小,则没有行指针,返回0
((((PageHeader) (page))->pd_lower - SizeOfPageHeaderData) \ // 正常的情况
/ sizeof(ItemIdData)))
根据行指针获取元组(根据lp_off以及lp_len可以定位到一个元组在页面的位置):
#define PageGetItem(page, itemId) \
( \
AssertMacro(PageIsValid(page)), \
AssertMacro(ItemIdHasStorage(itemId)), \
(Item)(((char *)(page)) + ItemIdGetOffset(itemId)) \
)
lp_flags有以下状态,只有LP_UNUSED状态的行指针可以被立即重用。
#define LP_UNUSED 0 /* 未被使用 (lp_len=0) */
#define LP_NORMAL 1 /* 使用中 (lp_len>0) */
#define LP_REDIRECT 2 /* HOT重定向 (lp_len=0) , 重定向到其他line pointer*/
#define LP_DEAD 3 /* 死行指针,可能有或没有存储空间 */
HOT(heap only tuple)堆内元组:更新行时,可将新行放置在旧行所处的同一个数据页中,并设置相关标识位,主要目的是:更新表元组时减少对索引表的更新。
堆元组
元组标识符TID
表中的一条记录对应页中的元组,但因为多版本并发控制MVCC,实际上可能存在一条记录的多个版本,每个版本都是一个元组。具体采用那个版本由可见性规则决定。
为了标识一个元组,数据库内部使用TID(Table IDentifier)作为元组标识符。TID由元组所在的页号和元组在页中的行指针的偏移号组成。
// 元组标识符定义:
typedef struct ItemPointerData
{
BlockIdData ip_blkid; // 块号
OffsetNumber ip_posid; // 行号
}
/* If compiler understands packed and aligned pragmas, use those */
#if defined(pg_attribute_packed) && defined(pg_attribute_aligned)
pg_attribute_packed() // 强制结构体紧凑排列,不添加填充字节
pg_attribute_aligned(2) // 指定结构体按 2 字节对齐
#endif
ItemPointerData;
// 通过上面的定义,占用6字节,节省2字节(如果内存对齐填充字节,则占用8字节)
typedef ItemPointerData *ItemPointer;
// BlockNumber的存储形式,之所以这么定义的原因是为了节省空间
typedef struct BlockIdData
{
uint16 bi_hi;
uint16 bi_lo;
} BlockIdData;
在索引扫描时,可通过TID,定位到元组。

元组布局
元组中除了存储实际数据,还会存储一些额外的系统列,用于事务处理以及一些标识位。数据库中所有增删改查等操作全部在元组层面进行操作。外部的页、文件等某种程度上可以认为是元组的容器。元组才是最核心的数据。
元组布局如下:
+------------------------+
| HeapTupleHeaderData | -- 固定头部(23 字节)
+------------------------+
| NULLs bitmap (可选) | -- 空值位图,如果t_infomask的HEAP_HASNULL被设置,则存在
+------------------------+
| 填充字节 (padding) | -- 确保用户数据按MAXALIGN对齐,通常为8的倍数
+------------------------+ <- t_hoff 开始于此,用户数据偏移
| 用户数据字段 |
| - field1 |
| - field2 |
| - ... | -- 用户数据字段间也可能需要对齐填充
+------------------------+
其中元组头部定义如下:
- t_xmin: 事务ID,表示该元组被创建时的事务ID。
- t_xmax: 事务ID,表示该元组被删除时事务ID。
- t_cid: 命令ID,表示该元组被插入或删除时命令ID(同一事务内)。
- t_infomask2: 标识位,低11位标识当前元组的属性个数,其他位用于包括HOT技术以及元组可见性的标识位。
- t_infomask: 标识位,
- t_hoff: 元组头部的大小(包括位图信息和填充),同时也是用户数据的起始位置,它必须总是
MAXALIGN的倍数。 - t_bits: 空值位图,用于标识该元组哪些字段为空。如果元组没有属性为空,则该字段为空。仅在
t_infomask中的HEAP_HASNULL为1时才存在,如果存在,则需保证位图长度要占据足够的字节来容纳每个数据列对应的一个位(位数等于t_infomask2中的属性个数)。在位图中,1表示非空,0表示空。
t_infomask2标识位:
#define HEAP_NATTS_MASK 0x07FF /* 低11位表示属性个数 */
#define HEAP_KEYS_UPDATED 0x2000 /* 标记该元组的索引键列被更新或该元组已被删除 */
#define HEAP_HOT_UPDATED 0x4000 /* 标记该元组是HOT机制更新的 */
#define HEAP_ONLY_TUPLE 0x8000 /* 标记该元组是Heap only tuple */
例如:t_infomask2低11位为00000000010,标识有2个属性。
postgres=# SELECT t_xmin,t_xmax,t_ctid,t_infomask2::bit(16),t_infomask::bit(16),t_hoff,t_bits,t_attrs FROM heap_page_item_attrs(get_raw_page('t1', 0), 't1'::regclass);
t_xmin | t_xmax | t_ctid | t_infomask2 | t_infomask | t_hoff | t_bits | t_attrs
---------+--------+--------+------------------+------------------+--------+--------+-------------------------------
4027950 | 0 | (0,1) | 00000,00000000010 | 0000,1001,0000,0000 | 24 | | {"\\x01000000","\\x01000000"}
(1 row)
t_infomask标识位:
#define HEAP_HASNULL 0x0001 /* 是否包含NULL值字段,决定是否需要空值位图 */
#define HEAP_HASVARWIDTH 0x0002 /* 是否包含变长字段 */
#define HEAP_HASEXTERNAL 0x0004 /* 是否有字段被toast线外存储 */
#define HEAP_HASOID_OLD 0x0008 /* 是否含有OID字段,已废弃 */
#define HEAP_XMAX_KEYSHR_LOCK 0x0010 /* xmax锁类型:键共享锁(for key share) */
#define HEAP_COMBOCID 0x0020 /* 标记t_cid字段为组合命令ID,用于同一事务内的复杂操作 */
#define HEAP_XMAX_EXCL_LOCK 0x0040 /* xmax锁类型:排他锁 (for update) */
#define HEAP_XMAX_LOCK_ONLY 0x0080 /* xmax仅用于锁 */
#define HEAP_XMIN_COMMITTED 0x0100 /* t_xmin字段中的事务已提交 */
#define HEAP_XMIN_INVALID 0x0200 /* t_xmin字段中的事务无效或已中止,该元组对任何事务都不可见 */
#define HEAP_XMAX_COMMITTED 0x0400 /* t_xmax字段中的事务已提交,意味着删除该元组的事务已提交 */
#define HEAP_XMAX_INVALID 0x0800 /* t_xmax字段中的事务无效或已中止 */
#define HEAP_XMAX_IS_MULTI 0x1000 /* 标识t_xmax字段中的值是一个MultiXactId(多重事务ID),而不是一个普通事务ID,用于表示多个事务同时对同一行进行锁定的情况 */
#define HEAP_UPDATED 0x2000 /* 表示该元组时通过UPDATE操作产生的,新的元组 */
#define HEAP_MOVED_OFF 0x4000 /* 9.0版本后已废弃,保留它仅为了二进制升级的兼容性支持 */
#define HEAP_MOVED_IN 0x8000 /* 9.0版本后已废弃,保留它仅为了二进制升级的兼容性支持 */
示例:
-- 当前页只有一个元组
postgres=# select * from t1;
a | b
---+---
1 | 1
(1 row)
-- 更新
postgres=# update t1 set b = 2;
UPDATE 1
-- 查看元组信息,观察标识位的变化,
-- 旧版本的元组: t_infomask标识位:HEAP_XMIN_COMMITTED
-- 更新后的新元组,t_infomask标识位:HEAP_UPDATED | HEAP_XMIN_COMMITTED
postgres=# SELECT t_xmin,t_xmax,t_ctid,t_infomask2::bit(16),t_infomask::bit(16),t_hoff,t_bits,t_attrs FROM heap_page_item_attrs(get_raw_page('t1', 0), 't1'::regclass);
t_xmin | t_xmax | t_ctid | t_infomask2 | t_infomask | t_hoff | t_bits | t_attrs
---------+---------+--------+------------------+------------------+--------+--------+-------------------------------
4027950 | 4027952 | (0,2) | 0100,0000,0000,0010 | 0000,0001,0000,0000 | 24 | | {"\\x01000000","\\x01000000"}
4027952 | 0 | (0,2) | 1000,0000,0000,0010 | 0010,1000,0000,0000 | 24 | | {"\\x01000000","\\x02000000"}
(2 rows)
需要注意,
HEAP_XMIN_COMMITTED等元组可见性标识位,并不是在事务提交时设置,而是在后面的DML或vacuum等SQL扫描到tuple判断事务是否可见时(HeapTupleSatisfiesVisibility)设置,在这个过程中会判断事务是否已提交,如果已提交,则通过SetHintBits函数设置相应标识位。 所以,只读查询也有可能产生写IO,就是这个原因。
内核实现:
// 事务可见性信息,占用12字节
typedef struct HeapTupleFields
{
TransactionId t_xmin; /* 插入元组时事务ID */
TransactionId t_xmax; /* 删除元组时事务ID */
union
{
CommandId t_cid; /* 插入或删除该元组的命令ID(同一事务内) */
TransactionId t_xvac; /* PG8.4开始新版本已废弃不用,old-style VACUUM FULL xact ID */
} t_field3;
} HeapTupleFields;
// 元组头部
struct HeapTupleHeaderData
{
union
{
HeapTupleFields t_heap; /* 磁盘形式 */
DatumTupleFields t_datum; // 内存形式
} t_choice;
ItemPointerData t_ctid; /* 记录当前元组或者新元组的物理位置,如果元组被更新,则存储新版本元组的物理位置 */
/* Fields below here must match MinimalTupleData! */
#define FIELDNO_HEAPTUPLEHEADERDATA_INFOMASK2 2
uint16 t_infomask2; /* 属性个数(低11位) + 标识位 */
#define FIELDNO_HEAPTUPLEHEADERDATA_INFOMASK 3
uint16 t_infomask; /* 标识位 */
#define FIELDNO_HEAPTUPLEHEADERDATA_HOFF 4
uint8 t_hoff; /* 元组头部大小(包括bitmap, padding) */
/* ^ - 元组头部固定大小 23字节 - ^ */
#define FIELDNO_HEAPTUPLEHEADERDATA_BITS 5
bits8 t_bits[FLEXIBLE_ARRAY_MEMBER]; /* 空值位图 (大小不固定,可能为空) */
/* 元组的用户数据字段在此之后 */
};
我们可通过pageinspect扩展查看一下元组数据,元组头部会字节对齐,按t_hoff总是MAXALIGN的倍数(通常为8)。
postgres=# create table padding4(a1 bool,a2 bool,a3 bool,a4 bool,a5 bool,a6 bool,a7 bool,a8 bool,a9 bool);
CREATE TABLE
postgres=# insert into padding4 values(true);
INSERT 0 1
-- 元组长度为33字节,元组头部32字节,用户数据1字节,头部 = 23 + 2 + 7(填充)
postgres=# select * from heap_page_items(get_raw_page('padding4',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 | 33 | 4027920 | 0 | 0 | (0,1) | 9 | 2049 | 32 | 1000000000000000 | | \x01
(1 row)
除了头部会发生字节填充,用户数据字段间也可能会发生对齐填充:
postgres=# create table padding(a bool, b integer);
CREATE TABLE
postgres=# insert into padding values(true);
INSERT 0 1
-- lp_len表示元组长度
-- t_hoff表示元组头部长度,用户数据偏移位置
-- t_bits表示空值位组
postgres=# select * from heap_page_items(get_raw_page('padding',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 | 8160 | 1 | 25 | 4027914 | 0 | 0 | (0,1) | 2 | 2049 | 24 | 10000000 | | \x01
(1 row)
postgres=# update padding set b = 1;
UPDATE 1
-- 更新之后,发现t_ctid的值被修改为新元组的TID值,
-- 同时旧元组t_xmax被设置为新元组的事务ID,表示该元组已被删除
-- lp_len的长度发生改变,由25字节变为了32字节,24+1+4,另3个字节为字节对齐填充
postgres=# select * from heap_page_items(get_raw_page('padding',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 | 8160 | 1 | 25 | 4027914 | 4027915 | 0 | (0,2) | 16386 | 257 | 24 | 10000000 | | \x01
2 | 8128 | 1 | 32 | 4027915 | 0 | 0 | (0,2) | 32770 | 10240 | 24 | | | \x0100000001000000
(2 rows)
为了避免对齐填充造成的空间浪费,可以优化表中的字段顺序。
-- 通过重新排列字段顺序,减少元组大小,减少填充
postgres=# create table padding3(a integer, b bool);
CREATE TABLE
postgres=# insert into padding3 values(1);
INSERT 0 1
-- 在有一个空值的情况下,元组大小为23+1+4=28,可以看到空值通过空值位图表示,不写数据
postgres=# select * from heap_page_items(get_raw_page('padding3',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 | 8160 | 1 | 28 | 4027917 | 0 | 0 | (0,1) | 2 | 2049 | 24 | 10000000 | | \x01000000
(1 row)
-- 更新元组
postgres=# update padding3 set b = true;
UPDATE 1
-- 可以看到,更新后的元组大小为29=23+1+4+1, 相比表padding,每个元组减少了3个填充字节
postgres=# select * from heap_page_items(get_raw_page('padding3',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 | 8160 | 1 | 28 | 4027917 | 4027918 | 0 | (0,2) | 16386 | 257 | 24 | 10000000 | | \x01000000
2 | 8128 | 1 | 29 | 4027918 | 0 | 0 | (0,2) | 32770 | 10240 | 24 | | | \x0100000001
(2 rows)