ITPub博客

首页 > 数据库 > 数据库开发技术 > PostgreSQL Internals之路 第3章 页和元组

PostgreSQL Internals之路 第3章 页和元组

原创 数据库开发技术 作者:数据库杂记 时间:2024-09-03 06:05:00 0 删除 编辑


页和元组(Page and Tuples)

3.1 页结构(Page Structure)

每一页都有特定的内部布局,它通常由下边几个部分[1]构成:

  • 页头
  • 项指针数组
  • 空闲空间
  • 项节点(行版本)(items)
  • 特殊空间

页头

页头位于页的最低地址空间里,并有固定大小。它存储有关页的各种不同的信息,像校验和以及其它各部分的大小等等。

这些大小可以使用插件pageinspect[2]简单的展示出来。我们来看看表的第1页(页号是从0开始算起):

=> CREATE EXTENSION pageinspect;
=> SELECT lower, upper, special, pagesize
FROM page_header(get_raw_page('accounts',0));
lower | upper | special | pagesize
−−−−−−−+−−−−−−−+−−−−−−−−−+−−−−−−−−−−
152 | 6904 | 8192 | 8192
(1 row)

特殊空间 (Special Space)

特殊空间位于页中相对那一部分,占据着高地址空间。它通常用于某些索引,存储一些辅助信息;在其它类型的索引和表页里,这部分空间大小为0。

通常,索引页的布局是相当不同的;它们的内容很大程度上依赖于特定的索引类型。即使同一个索引也有可能使用不同类型的页:例如,B-树可以有特殊元数据页(第0页),以及一些与表页相似的普通页。

元组

记录行包含了存储在数据库当中实际的数据,同时还存有一些附加的信息。它们位于特殊空间的前头。

在表的例子中,我们必须要处理行版本号,而不是行本身,因为MVCC(多版本并发控制)意味着同一行数据拥有多个不同的版本。索引并不使用MVCC机制;相反,它们必须引用所有可以访问的行版本,反过来会依赖于可见性规则,来挑选合适的行版本。

表的行版本和索引项都被称做是元组。这个术语是从关系理论里借用过来 ---这也是PG从学术圈遗留下来的术语。

项指针

指向元组的指针数组,构成了页表的内容。它紧跟在页头后边。

索引项必须引用特定的堆元组。PG使用6字节的元组标识(TID)达到此目的。每个TID由主分叉的页号和指向该页中特定的行版本的引用构成。

理论上元组可以使用从页开始位置的偏移量来引用。但是如果这样,在不打破引用的前提下,在页内移动元组就不太可能了。反过来,会导致页碎片以及其它让人不悦的后果。

因为这个原因,PG使用了间接地址:元组标识引用对应的指针编号,而指针指定元组的当前偏移。如果元组在页内移动,它的TID仍然不变;只需要修改指针,它同样位于这个页内。

每一个指针只占4个字节,并包含下边的数据:

  • 自页起始位置算起的元组偏移
  • 元组长度
  • 定义元组状态的若干二进制位

空闲空间

在指针和元组之间,页可以有一些空闲空间(这个可以在空闲空间映射FSM里反映出来)。没有页碎片:所有空闲空间总是聚集成一个块[3]。

3.2 行版本布局

每个行版本包含一个带真实数据的头。行的头包含多个字段,分别如下:

xmin, xmax 表示事务ID;它们用于区分同一行的不同版本。

infomask 提供了一组信息提示位,定义版本的属性

ctid 一个指向相同行的下一个更新的版本的指针

null bitmap  一个位数组,标识那些可能包含NULL值的列。

结果来看,行的头非常大:每个元组需要至少23个字节,这个值经常被扩大,因为null位图和一些用于数据对齐的必须的填充。在一张“窄”表里,不同元数据在大小方面,可以简单的击败存储的真实数据。

磁盘上的数据布局与RAM中的数据表示完全一致。页及其里边的元组读到buffer cache,没有任何转换。这也是为何不同平台数据文件不兼容的原因[4]。

不兼容的一个原因是字节序。例如,x86体系用的是不端编码,Z/Arch用的是大端编码,而ARM则用的是可配置的字节序。

另一个原因是机器字边界的数据对齐,很多体系架构都需要。例如,32位的x86系统,整数(整数类型,占4个字节),会与4字节的字的边界对齐,就像双精度浮点数一样(双精,8字节)。但是在64位系统里,双精会按8字节边界对齐。

数据对齐会让元组的大小依赖于表中字段的顺序。这个效果通常可以忽略,但是在某些情况下它会导致大小的急剧增长。这里看一个示例:

=> CREATE TABLE padding(
b1 boolean,
i1 integer,
b2 boolean,
i2 integer
);
=> INSERT INTO padding VALUES (true,1,false,2);
=> SELECT lp_len FROM heap_page_items(get_raw_page('padding', 0));
lp_len
−−−−−−−−
40
(1 row)

我已经使用了pageinspect插件的函数:heap_page_items来显示有关指针和元组的一些细节。

在PG中,表通常引用称为堆。这是另一个模糊的术语,它暗示了为数组分配空间和动态内存分配之间的相似性。当然可以看到一些类比,但是表是由完全不同的算法管理的。与有序索引相比,我们可以将这个术语解释为“所有东西都堆成一个堆”。

该行的大小是40字节。而行的头就占有24字节,整数列占4字节,每个布尔型列占1字节。它构成了34字节,有6个字节浪费在整数列的4字节对齐上。

如果我们重建这张表,空间则会得到更有效的利用:

=> DROP TABLE padding;
=> CREATE TABLE padding(
    i1 integer,
    i2 integer,
    b1 boolean,
    b2 boolean
);

=> INSERT INTO padding VALUES (1,2,true,false);
=> SELECT lp_len FROM heap_page_items(get_raw_page('padding', 0));
lp_len
−−−−−−−−
34
(1 row)

另一个可能的局部优化是让表的起始列是定长的列,并且不能包含NULL值。访问这样的列将更高效,因为在元组内缓存它们的偏移[5]成为可能。

3.3 元组的操作

为了标识同一行的不同版本,PG使用两个值来标识它们:xmin和xmax。这些值定义了每个行版本的“有效时间”,但是不是用实际时间,它们依赖于曾不断增长的事务ID。

当行创建的时候,xmin值用于设置INSERT命令的事务ID。

当行删除的时候,xmax值用于设置DELETE命令的事务ID的当前版本。

使用一定程度的抽象,UPDATE命令可以被看作是两个独立的操作:DELETE和INSERT。首先,当前行版本的xmax值用来设成UPDATE命令的事务ID。接着一个该行的一个新版本创建出来;它的xmin值将与前一个版本的xmax值完全相同。

现在我们可以看下元组上不同操作的一些底层细节[6]。

对于这些实验,我们需要一张两列的表,对其中一列创建了索引:

=> CREATE TABLE t(
id integer GENERATED ALWAYS AS IDENTITY,
s text
);
=> CREATE INDEX ON t(s)

Insert

启动一个事务并插入一行:

=> BEGIN;
=> INSERT INTO t(s) VALUES ('FOO');

这是当前的事务ID:

=> -- txid_current() before v.13
SELECT pg_current_xact_id();
pg_current_xact_id
−−−−−−−−−−−−−−−−−−−−
776
(1 row)

为了表示事务的概念,PG使用了术语xact,在SQL函数和源码中都可以见到。结果是,事务ID可以称为xact ID, TXID, 或者简单的XID。我们将反复的横跨这些缩写词。

我们来看看page里头的内容。函数heap_page_items可以为我们提供所有需要的信息,但是它只是数据本来的样子“as is", 输出格式有点难以让人理解:

=> SELECT *
FROM heap_page_items(get_raw_page('t',0)) \gx
−[ RECORD 1 ]−−−−−−−−−−−−−−−−−−−
lp | 1
lp_off | 8160
lp_flags | 1
lp_len | 32
t_xmin | 776
t_xmax | 0
t_field3 | 0
t_ctid | (0,1)
t_infomask2 | 2
t_infomask | 2050
t_hoff | 24
t_bits |
t_oid |
t_data | \x0100000009464f4f

为了更具有可读性,我们留下一些信息,并扩展了几列:

=> SELECT '(0,'||lp||')' AS ctid,
CASE lp_flags
WHEN 0 THEN 'unused'
WHEN 1 THEN 'normal'
WHEN 2 THEN 'redirect to '||lp_off
WHEN 3 THEN 'dead'
END AS state,
t_xmin as xmin,
t_xmax as xmax,
(t_infomask & 256) > 0 AS xmin_committed,
(t_infomask & 512) > 0 AS xmin_aborted,
(t_infomask & 1024) > 0 AS xmax_committed,
(t_infomask & 2048) > 0 AS xmax_aborted
FROM heap_page_items(get_raw_page('t',0)) \gx

−[ RECORD 1 ]−−+−−−−−−−
ctid | (0,1)
state | normal
xmin | 776
xmax | 0
xmin_committed | f
xmin_aborted | f
xmax_committed | f
xmax_aborted | t

这里做了如下工作:

  • lp指针被转换成标准格式的元组ID:(页号,编号指针)。

  • lp_flags状态拼出来了。这里设成正常值,意指它确实指向一个元组。

  • 对所有的信息位,我们弄成两对。xmin_committed和xmin_aborted位显示xmin事务是否提交,是否终止。xmax_committed和xmax_aborted提供xmax事务相类似的信息。

    v.13 pageinspect插件提供了函数heap_tuple_infomask_flags,用于解释所有的信息位,但是我将提取这个时候有用的一些信息,以更简洁的形式显示出来。

我们回到实验当中来。INSERT命令将指针1添加到堆页中,它指向第1个元组,也是目前唯一的一个元组。

元且的xmin字段被设成当前的事务ID。这个事务仍然是活动的,因此xmin_committed and xmin_aborted 位都没有设值。

xmax字段值为0,它是一个哑元数,表示这个元组还没有被删除,也表示着当前该行的版本号。事务将忽略该数值,因为xmax_aborted位被设置了。

与终止的事务对应的位看起来有些奇怪,这个值被设上的时候,对应的事务还没有启动。但是从隔离的立场来看,这样的事务之间没什么区别:中止的事务不会留下任何痕迹,因此它从未存在过。

我们会再次使用上边的查询, 我打算将上边的查询提炼成一个函数。同时,我会把信息位的列隐藏起来,同时显示事务及其ID,提供更简洁的输出结果。

=> CREATE FUNCTION heap_page(relname text, pageno integer)
RETURNS TABLE(ctid tid, state text, xmin text, xmax text)
AS $$
SELECT (pageno,lp)::text::tid AS ctid,
CASE lp_flags
WHEN 0 THEN 'unused'
WHEN 1 THEN 'normal'
WHEN 2 THEN 'redirect to '||lp_off
WHEN 3 THEN 'dead'
END AS state,
t_xmin || CASE
WHEN (t_infomask & 256) > 0 THEN ' c'
WHEN (t_infomask & 512) > 0 THEN ' a'
ELSE ''
END AS xmin,
t_xmax || CASE
WHEN (t_infomask & 1024) > 0 THEN ' c'
WHEN (t_infomask & 2048) > 0 THEN ' a'
ELSE ''
END AS xmax
FROM heap_page_items(get_raw_page(relname,pageno))
ORDER BY lp;
$$ LANGUAGE sql;

现在可以从元组头看到更清晰的内容,到底发生了什么:

=> SELECT * FROM heap_page('t',0);
ctid | state | xmin | xmax
−−−−−−−+−−−−−−−−+−−−−−−+−−−−−−
(0,1) | normal | 776 | 0 a
(1 row)

您也可以通过查询表中的伪列:xmin和xmax得到类似的但仍不够详细的信息:

=> SELECT xmin, xmax, * FROM t;
xmin | xmax | id | s
−−−−−−+−−−−−−+−−−−+−−−−−
776 | 0 | 1 | FOO
(1 row)

Commit

一旦事务成功完成,它的状态就被存起来了 --- 必须被注册成事务被提交了。为此目的,PG使用了特殊的CLOG[7](提交日志)结构。它存储在PGDATA/pg_xact目录下,而不是放到系统目录表中。

以前,这些文件位于PGDATA/pg_clog下,但是在版本10中,该目录被重命名[8]:这也很常见,对于不熟悉PG的DBA而言,搜索空闲磁盘空间,并删除它,认为”log"目录是不需要的。

为了方便,CLOG被分拆成数个文件。这些文件通过服务器上的缓冲逐页访问[9]。

就像元组头一样,CLOG为每个事务包含两位:committed和aborted。

一旦提交,事务在CLOG里就被标为committed。当任意其它事务访问一个堆页时,它必须回答这样的问题:xmin事务已经结束了吗?

  • 如果没有,那已经创建的元组必须不可见。

    为了检查事务是否活动,PG使用了实例的共享内存中的另外一套结构;称为ProcArray。这个结构包含了所有活动进程的列表,每个进程还带有相对应的当前活动事务。

  • 如果结束了,它是已提交还是已终止 ?如果是后一种情况,对应的元组也必须不可见。

    正是这种检查需要CLOG。但是即使最近的CLOG页存储在内存缓冲里,每次都执行这种检查也是非常昂贵的。一旦确定,事务状态就被写进元组头---更具体地说,是写到xmin_commited和xmin_aborted信息位,它们也被称做是提示位。如果其中的一位被设置了,xmin事务状态被认为是已知了,下一个事务将既不能访问CLOG,也不能访问ProcArray。

为何这些位是由执行行插入的事务设置?问题是在那个时间点还不知道该事务是否能成功完成。当它提交的时候,它已经不太清楚哪个元组和页发生了改变。如果一个事务影响了很多页,要跟踪它们将非常昂贵。另外,其中一些页可能不再在缓存里;再次读取它们,然后只是简单的更新提示位,会严重的拉慢提交的速度。

这个成本下降的另一方面是,任何事务(甚至只读的SELECT命令)可以启动设置提示位,在缓冲缓存中留下脏页的尾巴。

最后,我们提交由INSERT语句启动的事务:

=> COMMIT;

该页没有发生改变(但是我们知道事务状态已经改变了,并写进CLOG里头了):

=> SELECT * FROM heap_page('t',0);
ctid | state | xmin | xmax
−−−−−−−+−−−−−−−−+−−−−−−+−−−−−−
(0,1) | normal | 776 | 0 a
(1 row)

现在访问该页的第1个事务(“标准”方式里,不使用pageinspect)必须确定一下xmin事务的状态,并更新一下提示位:

=> SELECT * FROM t;
id | s
−−−−+−−−−−
1 | FOO
(1 row)
=> SELECT * FROM heap_page('t',0);
ctid | state | xmin | xmax
−−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−
(0,1) | normal | 776 c | 0 a
(1 row)

Delete

当一行被删除时,当前版本的xmax字段会被设到此事务ID,用于执行删除,这时xmax_aborted位没被设置。

当事务是活动的时候,xmax值用做行锁。如果另一个事务打算更新或删除此行,它将会等待xmax事务完成。

我们试着删除一行:

=> BEGIN;
=> DELETE FROM t;
=> SELECT pg_current_xact_id();
pg_current_xact_id
−−−−−−−−−−−−−−−−−−−−
777
(1 row)

事务ID已经写到xmax字段,但是信息位还没有设置:

=> SELECT * FROM heap_page('t',0);
ctid | state | xmin | xmax
−−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−
(0,1) | normal | 776 c | 777
(1 row)

终止(Abort)

终止一个事务的机制与提交类似,发生的也一样的快,不过,它设置CLOG的位的值是aborted。尽管对应的命令是ROLLBACK, 实际上没有任何数据回滚发生:终止事务在数据页上所有的改变都保留在原地。

=> ROLLBACK;
=> SELECT * FROM heap_page('t',0);
ctid | state | xmin | xmax
−−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−
(0,1) | normal | 776 c | 777
(1 row)

当页被访问时,事务状态也被检查,元组接收xmax_aborted提示位。xmax数值自身仍留在页中,但是不会再有事务会注意到它:

=> SELECT * FROM t;
id | s
−−−−+−−−−−
1 | FOO
(1 row)
=> SELECT * FROM heap_page('t',0);
ctid | state | xmin | xmax
−−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−−
(0,1) | normal | 776 c | 777 a
(1 row)

Update

更新执行的方式,就如何当前元组被删除,然后再添加一个新行被插入:

=> BEGIN;
=> UPDATE t SET s = 'BAR';
=> SELECT pg_current_xact_id();
pg_current_xact_id
−−−−−−−−−−−−−−−−−−−−
778
(1 row)

下边查询返回单独的一行(它的新版本):

原文作者:数据库杂记;如有侵权,请联系管理员删除。

请登录后发表评论 登录
全部评论

注册时间:2019-04-22

  • 博文量
    276
  • 访问量
    160277