IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 大数据 -> PostgreSQL heap堆表 存储引擎实现原理 -> 正文阅读

[大数据]PostgreSQL heap堆表 存储引擎实现原理

前言

Postgresql 的存储引擎 是heap表引擎,用来存储postgresql 的用户表和系统表 的 实际数据 以及 索引数据。

了解pg 的heap 表引擎底层设计细节,能够帮助我们更好得理解整个 pg 的读写 以及 事务处理链路。
而且 heap表引擎与 Relation cache, WAL(xlog), CheckPoint, BufferPool 都是强相关的,毕竟这一些机制或者说组件都是为了处理表数据而存在的,而heap 存储引擎则是管理表数据在磁盘上的物理结构,所以从下向上来探索PG 能够更为结构化 和 准确。

本节涉及到的 postgresql 源代码版本 REL_12_2

Heap表 物理结构

PG数据库目录

heap表存储引擎 是管理表数据的一个PG 组件。表数据实际的存在形态可以通过直接查看pg数据所处 文件系统目录看到。

当我们使用 pg的 createdb 命令创建了一个数据库之后,pg 会在 $PGDATA/base 目录下生成一个新的目录,用来存储这个创建好的 db 的表数据,pg 不同数据库之间是完全物理隔离的。

其中 $PGDATA 是我们初始化 数据库 以及 使用 pg_ctl 启动 pg时指定的数据存放目录。

$ ./bin/createdb testdb
$ cd $PGDATA
$ ls -l base
total 40
drwx------ 2 admin admin  4096 Jun 24 16:44 1
drwx------ 2 admin admin  4096 Jun 24 16:44 12708
drwx------ 2 admin admin 12288 Jun 25 17:16 12709
drwx------ 2 admin admin 12288 Jun 25 22:00 16384

其中 1, 12708, 12709 目录对应的 数据库是在 initdb 的时候预先创建好的系统数据库。而 16384 则时我们创建 testdb 时 默认创建的目录。
可以通过 psql 连接 testdb 快速确认 其所处的 pg目录:

$./bin/psql testdb
psql (12.2)
Type "help" for help.

testdb=# select pg_relation_filepath('pg_class');
 pg_relation_filepath
----------------------
 base/16384/1259
(1 row)

这里是我们连接到 testdb之后 查看某一个表的数据目录,其中 base/16384就是在 $PGDATA目录下的数据库目录,1259 文件表示的是 pg_class 这个表中的数据存储的文件。

heap表 实际的数据文件

前面我们能够通过 pg_relation_filepath 函数确认一个表实际数据 所处的物理文件。
如果我们在同一个数据库中创建一个新的表,pg 会为这个表单独分配一个存储的物理文件,且 这个表会有一个整个db 内部的唯一标识 oid ,并且会存储在 pg_class系统表内部,可以在 pg_class 内部 select 查找到。relfilenode 则是该表实际的物理文件名称,如果对这个表执行了 truncate 则会保持 oid不变的情况下生成一个新的 relfilenode,也就是一个新的文件。

$ ./bin/psql testdb
testdb=# create table d (c1 int, c2 int, c3 int);
CREATE TABLE
testdb=# select pg_relation_filepath('d');
 pg_relation_filepath
----------------------
 base/16384/16430
(1 row)
testdb=# select relname, oid, relfilenode from pg_class where relname ='d';
 relname |  oid  | relfilenode
---------+-------+-------------
 d       | 16430 |       16430
(1 row)

可以看到实际的物理文件(空的,因为我们并没有向表内插入数据):

$ ls -l base/16384/16430
-rw------- 1 admin admin 0 Jun 25 22:46 base/16384/16430

然后我们向这个表内部插入几条数据,可以看到实际的表文件已经有了数据:

testdb=# insert into d values(1, 11, 12);
INSERT 0 1
testdb=# insert into d values(2, 21, 22);
INSERT 0 1
testdb=# select * from d;
 c1 | c2 | c3
----+----+----
  1 | 11 | 12
  2 | 21 | 22
(2 rows)
------------------------------------------------
$ ls -l base/16384/16430
-rw------- 1 admin admin 8192 Jun 25 22:46 base/16384/16430

如果我们查看整个 数据库 16384 的目录,则能够看到一些表文件为前缀的 _fsm 以及 _vm文件,它们分别是管理整个表 空闲空间映射 以及 可见性映射的持久化文件,分别用于 为表数据从表文件中分配存储空间 以及 事务读写场景进行 数据可见性检查的。

heap表 数据属性

一个表的数据是放在一个文件,随着这个表内数据越来越多,文件也会越来越大,默认 单个表文件的上限是 1G (可以在编译 pg 时通过指定 --with-segsize 设置 表文件的大小上限,它是没法通过 pgoptions 更改的),如果单个表文件大小超过了1G,则会生成一个 base/16384/16430.1 文件继续接受新数据的写入,以此类推。

表文件内部 则会被分为一个个小的 page,或者说 block 进行数据存储管理,page 和 block 都是属于 pg 内部的物理结构的描述术语,和 os 本身的 内存 page 和 磁盘 block 是不一样的,对于 pg 表文件的组成单位 后续统一用 page进行称呼,默认一个 page 大小是 8K(同样,需要在编译的时候指定 --with-blocksize 来进行变更)。

整个表文件由一个个page组成,每一个page有术语自己的编号(block number) 用来唯一标识一个page。一个 page 内部则是由一个个 heaptuple 的数据元组组成,它们是实际的表内数据。

我们可以通过如下几个 pg 内置扩展组件 pageinspect 提供的扩展函数来初步了解一下page的组织结构 以及 page 内部的 元组的一些信息,可以通过 create extension pageinspect; 语句打开该extension。

对于这一些信息的解析,后面会详细描述,可以先简单看一下。

  1. 查看表 d 的 第一个page 的header信息,其中 page_header 用来获取page的header结构,get_raw_page 获取 表 d 且 block number 为 0 即第一个的 page

    	testdb=# SELECT * FROM page_header(get_raw_page('b', 0));
        lsn    | checksum | flags | lower | upper | special | pagesize | version | prune_xid
    -----------+----------+-------+-------+-------+---------+----------+---------+-----------
     0/164D980 |        0 |     0 |    36 |  8096 |    8192 |     8192 |       4 |         0
    (1 row)
    
  2. 查看表 d 的第一个 page 的tuple信息 SELECT * FROM heap_page_items(get_raw_page('d', 0)); ,其中 heap_page_items 获取 page 内部每一个heaptuple 元组条目信息:

    testdb=# SELECT * FROM heap_page_items(get_raw_page('d', 0)) where lp < 5;
    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 |     36 |    516 |      0 |        0 | (0,1)  |           3 |       2304 |     24 |        |       | \x010000000b0000000c000000
      2 |   8112 |        1 |     36 |    517 |      0 |        0 | (0,2)  |           3 |       2304 |     24 |        |       | \x020000001500000016000000
      3 |   8072 |        1 |     36 |    518 |      0 |        0 | (0,3)  |           3 |       2304 |     24 |        |       | \x01000000300000001f000000
      4 |   8032 |        1 |     36 |    518 |      0 |        0 | (0,4)  |           3 |       2304 |     24 |        |       | \x02000000280000000e000000
    (4 rows)
    
  3. 接下来看 我们再深入一些,看一下元组 t_data 内部的 attrs 信息:

    	testdb=# SELECT lp,lp_off, t_xmin, t_ctid, t_attrs FROM heap_page_item_attrs(get_raw_page('d', 0), 'd') where lp < 5;
     lp | lp_off | t_xmin | t_ctid |                   t_attrs
    ----+--------+--------+--------+---------------------------------------------
      1 |   8152 |    516 | (0,1)  | {"\\x01000000","\\x0b000000","\\x0c000000"}
      2 |   8112 |    517 | (0,2)  | {"\\x02000000","\\x15000000","\\x16000000"}
      3 |   8072 |    518 | (0,3)  | {"\\x01000000","\\x30000000","\\x1f000000"}
      4 |   8032 |    518 | (0,4)  | {"\\x02000000","\\x28000000","\\x0e000000"}
    (4 rows)
    

从以上内容中可以比较清晰得看到数据表文件中一个page 以及 page内部对应的元组结构,拥有非常多的字段来对 page 以及 元组结构进行描述,接下来将详细看一下整个page 以及 元组的内核结构。

heap表结构

这里 pg 为什么将表文件叫做 heap table,“应该(不确定)” 是因为 在表文件中的每一个page 内部空间的分配是从文件末尾向文件开头分配,有点像是os 的堆内存分配是从低地址空间向高地址空间增长,所以直接叫做堆表文件。

整个堆表文件 由多个 page组成,每一个page有一个唯一标识的 block number,接下来看看整个 page 以及其内部的heap tuple 都有哪一些字段:
在这里插入图片描述
其中 Page 的关键字段(header部分)如下:

  1. pd_lsn 一个8byte的 unsigned int,与 pg的 WAL文件 xlog 相关,唯一标识上一个写入到这个page 的请求。
  2. pd_checksum 当前page 的校验和, uint16。
  3. pd_flags 当前page 的一些flag信息,比如是否有空的line pointer(可用的元组空间),其内部的每一个元组是否对外可见等,uint16。
  4. pd_lower 标识当前page 空闲空间的起始偏移位置。
  5. pd_upper 标识当前page 空闲空间的结束偏移位置。
  6. pd_special 特殊空间的 起始偏移位置,其一般会存放在整个page的最后一段(如上图)。
  7. pd_pagesize_version 标识page大小和当前page版本信息。
  8. pd_prune_xid 标识本页面可以回收的 最老的元组 id.
  9. pd_linp 即上图中的 line-pointer,可变长度的数组,与元组一一对应,用来存储每一个元组在当前page内部的起始偏移地址。

可以看到整个pageheader 的这么多个字段就是为了管理其内部的元组而服务的。
可以通过 SELECT * FROM page_header(get_raw_page('b', 0)); 语句来查看 随着对表 b 的数据插入,整个page header的变化情况。

比如插入tuple2,会变更 pd_lower 以及 pd_upper 的偏移地址,将 pd_lower指向 为 tuple2 分配了line-pointer 之后的位置, 将 pd_upper 指向tuple2的起始位置(tuple2 是紧接着tuple1进行放置的),同时更新一下对应的pd_lsn 等指标。

Page 内部的 heaptuple 主要分为三个部分:除了 t_data 之外的 HeapTupleDatat_data 也就是 HeapTupleHeaderData,还有一部分就是 实际的data area。

  1. HeapTupleData 主要字段包括:len 整个tuple header-data + data-area 的长度; t_self 标识当前 tuple 所处的 page 位置(page的block-number 以及 page内的offset), tableOid 当前tuple 所处的表 的唯一标识。

  2. HeapTupleHeader t_data,这个数据结构主要保存整个tuple的 header 关键信息。其内部的字段与 pg 要是实现的事务语义强相关,在pg 内部一个元组的可见性检查 以及 MVCC 都是通过 HeapTupleHeader 内部的字段实现的(我们后续在源码分析层面会详细描述),主要字段如下:
    a. union t_choice,内部主要有 t_heap或者t_datum 两个字段。t_heap中主要是 事务标识字段,t_xmin 标识插入元组时的事务id; t_xmax 标识对当前元组进行更新/删除 时的事务id, 如果 t_xmax 被设置为0,则标识没有事务操作对该元素进行 更新和删除; t_cid 标识commid id,数值的含义是标识当前操作之前有多少个sql 命令; t_xvac 标识执行 vaccum full 操作的id。
    b. t_ctid 唯一标识一个元组,如果当前元组被更新或者删除,则t_ctid 则会被更新为最新的元素的t_ctid; 比如 :

    # 第一个元组
    testdb=# SELECT lp,lp_off, t_xmin, t_ctid, t_attrs FROM heap_page_item_attrs(get_raw_page('c', 0), 'c');
     lp | lp_off | t_xmin | t_ctid |            t_attrs
    ----+--------+--------+--------+-------------------------------
      1 |   8160 |    506 | (0,1)  | {"\\x57040000","\\xae080000"}
    # 更新,再次查看t_ctid
    testdb=# update c SET l1 = 2;
    UPDATE 1
    testdb=# SELECT lp,lp_off, t_xmin, t_ctid, t_attrs FROM heap_page_item_attrs(get_raw_page('c', 0), 'c');
     lp | lp_off | t_xmin | t_ctid |            t_attrs
    ----+--------+--------+--------+-------------------------------
      1 |   8160 |    506 | (0,2)  | {"\\x57040000","\\xae080000"}
      2 |   8128 |    595 | (0,2)  | {"\\x02000000","\\xae080000"}
    (2 rows)
    

    可以很明显得看到 两个元组的 t_ctid 被改变,第一个元组的t_ctid被变更为指向更新时产生的新的元组。
    c. t_infomask2 标识当前元组有多少个 attributes,即有多少列。同时,数值还编码了一些元组类型的 flags(元组被更新?元组类型时 hot-updated / only-tuple 等)。
    d. t_infomask 存储了元组的属性flag信息,比如 当前元组是否有空列, 是否有变长的列,object-id 列是否为空等。
    e. t_hoff 标识整个header + 后面的bitmap的占用偏移,方便上层利用t_hoff 计算实际的data_area 数据。
    f. t_bits,是一个变长的 bitmap数组,用来标识当前元组某一列是否为空,比如当前元组总共有四列,则t_bits 内容为 1111,如果第三列为空,则t_bits 内容变更为 1101 ;postgresql 允许的元组内部最大的列的个数为MaxTupleAttributeNumber 1664个,允许的用户表的最大的列数为 MaxHeapAttributeNumber 1600 个,和元组内允许的最大列数有差异的原因是元组内会因为用户对某一项的删除和更新而增加t_ctid 或者 t_xmax 这样的列数。

  3. data_area len - header的 剩余长度部分都可以被转为 元组的数据区域,也就是我们通过 SELECT lp,lp_off, t_xmin, t_ctid, t_attrs FROM heap_page_item_attrs(get_raw_page('c', 0), 'c'); 看到的 t_attrs 部分。
    有多少数据列,在 t_attrs中就有多少条数据,每一条数据 是代表一个列的数值,都是用16进制表示。比如:x57040000 需要将低16位放在后面才能完成表示,真正数值的16进制是0x0000 0457

整个元组这里还是有非常多的字段,其中大多数都是为了 pg 的事务实现 而设计的。

能看到 PG 内部的元组 update 是 append 方式的,就是需要生成一个新的元组,不过还需要对这个元组的旧版本中的某一些字段进行更新(t_xmax),如果这个元组数据不在内存中,更新这个字段也就意味着需要将旧的元组读出来进行更新,而如果更新较为频繁也就意味着事务更新的性能并不会很好。

当然,这种属于inplace-update的更新方式,对读性能自然很友好;像是lsm-tree 的纯append-only方式的更新场景基本就是正常的append 写入的性能了, 基本没有更新时性能损失的情况(如果正常的update 需要在之前版本基础上进行更新,使用 merge-operator,则能保证更新本身的性能的情况下 降低了 读的性能,因为实际的数值合并被放在了compaction 或者 读中)

Heap 表的读写逻辑

写入

heap表的写入并不是将元组构造好插入到page之后直接落盘,而是将插入元组的page标记为 dirty,由专门的 checkpointer 进程进行 周期性 buffer 的 刷盘,也就是数据并非实时落盘。但是一致性、可靠性 保障,则是会通过 postgresql 的 WAL保障。

heap元组的插入栈如下,上次执行的是INSERT 语句:

main
 PostmasterMain
  ServerLoop
   BackendStartup
	BackendRun
	 PostgresMain
	  exec_simple_query # 词法解析/语法解析/优化器
	   PortalRun # 已经生产执行计划,开始执行
		PortalRunMulti
		 ProcessQuery # 执行 DML
		  standard_ExecutorRun
		   ExecutePlan # 执行器
			ExecProcNode # 选择执行函数
			 ExecModifyTable 
			  ExecInsert
			   table_tuple_insert # 通过 default_table_access_method选择执行函数
				heapam_tuple_insert # 选择了 heap access method.
				 heap_insert # 插入元组

heapam_tuple_insert 中会先从 HeapTupleSlot 中拿到实际的元组数据,并构造一个 HeapTuple 返回,但是这个 HeapTuple 对象并没有header信息,所以 这个 HeapTuple对象的填充会放在 heap_insert中。

这里我们主要关注的是 heap_insert 如何更新元组 以及 将一个元组插入到 表文件的page 中。

函数内部逻辑 大体分为如下几步:

  1. 初始化元组头
  2. 从 Relation cache 中获取一个 可用的page block-number
  3. 做事务上的冲突检测(rw/ww)
  4. 将元组信息添加到这个 page中
  5. 标记 page 为dirty
  6. 写 WAL
  7. 标记 relation-cache 中旧的 tuple 所在的buffer 失效

第一步 初始化元组头

在函数 heap_prepare_insert 之中:

static HeapTuple
heap_prepare_insert(Relation relation, HeapTuple tup, TransactionId xid,
					CommandId cid, int options)
{
	...
	tup->t_data->t_infomask &= ~(HEAP_XACT_MASK);
	tup->t_data->t_infomask2 &= ~(HEAP2_XACT_MASK);
	tup->t_data->t_infomask |= HEAP_XMAX_INVALID;
	// xid 是当前操作元组的 transaction id,这里是更新到了我们前面说的 t_xmin之中。
	HeapTupleHeaderSetXmin(tup->t_data, xid);
	// 更新 t_cid,即 commid-id,用来标识当前事务操作之前有多少个 command.
	HeapTupleHeaderSetCmin(tup->t_data, cid);
	// 更新 t_xmax,即 对当前元组发生update 或者 delete 的事务id,默认是0.
	HeapTupleHeaderSetXmax(tup->t_data, 0); /* for cleanliness */
	// 更新 t_tableOid 为 表 oid.
	tup->t_tableOid = RelationGetRelid(relation);
	...
	// 后面是处理以下 较大元组的的情况,比如一个元组内部列数超过我们之前说的最大上限1664个,
	// 这种情况会通过 toast 机制进行处理。
}

第二步 获取一个 可用的page blk-num

第二步,从relation cache 中获取一个可用的 page,返回的是这个page对应的block number.
这一步 pg 会先根据 heap-size 和上一次使用的 page进行check,比如上一次的page 剩余空间是足够存放当前 heaptuple 的, 则直接将这个 page 对应的block-number 返回。

如果不够,则需要从 free-list-manager 中查找空闲可用的page,也就是读取 最开始描述数据库目录时提到的 _fsm 文件,如果 FSM 也没有足够的page,则通过 relation管理的 smgr – storage manager 从磁盘文件系统上取一块文件空间。

将最后拿到的 可用的 block-number 返回即可。

Buffer
RelationGetBufferForTuple(Relation relation, Size len,
						  Buffer otherBuffer, int options,
						  BulkInsertState bistate,
						  Buffer *vmbuffer, Buffer *vmbuffer_other)
{
	...
	// 前面利用输入的 参数做一些hup-size的检查,heapsize 如果小于 MaxHeapTupleSize,
	// 表示一定可以分配到一个可用的 page.
	if (targetBlock == InvalidBlockNumber && use_fsm)
	{
		// 获取空闲的block number
		// 1. 先尝试从 FSM 中获取,获取不到
		// 2. 再尝试 利用 smgr 从磁盘文件系统上分配,分配不到
		// 则说明磁盘空间不足,直接返回 -1。
		targetBlock = GetPageWithFreeSpace(relation, len + saveFreeSpace);
		...
	}

loop:
	// 1. 将 targetBlock 对应的page 读到内存中
	// 2. 做一些 buffer 的可见性检查
	// 3. 没有问题,则获取到 内存中的page,标记其为dirty
	    (新的page,插入需要后续checkpoint刷盘)
	//  4. 返回 block-number (代码中时buffer)
	while (targetBlock != InvalidBlockNumber)
	{
	...
			/* lock other buffer first */
			buffer = ReadBuffer(relation, targetBlock);
			if (PageIsAllVisible(BufferGetPage(buffer)))
				visibilitymap_pin(relation, targetBlock, vmbuffer);
			LockBuffer(otherBuffer, BUFFER_LOCK_EXCLUSIVE);
			LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE);

	...
			// 获取 page,如果是新的,则标记这个page 为dirty.
			page = BufferGetPage(buffer);
			if (PageIsNew(page))
			{
				PageInit(page, BufferGetPageSize(buffer), 0);
				MarkBufferDirty(buffer);
			}
			// 再次检查这个 page 可用空间足够,足够则直接返回buffer.
			pageFreeSpace = PageGetHeapFreeSpace(page);
			if (len + saveFreeSpace <= pageFreeSpace)
			{
				/* use this page as future insert target, too */
				RelationSetTargetBlock(relation, targetBlock);
				return buffer;
			}
...
	}
...
}

第三步 冲突检测

第三步是进行冲突检测,直接调用函数 CheckForSerializableConflictIn(事务部分的逻辑会和 WAL 机制单独分析一篇,本文中暂时不进行展开)。

第四步 元组写入到page

接下来就到第四步了,将 元组数据添加到获取到的page 中,调用函数 RelationPutHeapTuple

  1. 先在 PageAddItem,将tuple 的 t_data 数据部分 插入到page中,更新page header信息,并返回 tuple 所在page的 offnum.
  2. 将offnum 和 buffer 更新到 tuple 的 t_self中。这样就能够在tuple 中确认该tuple 所在的 block-number 以及 page偏移地址了。
void
RelationPutHeapTuple(Relation relation,
					 Buffer buffer,
					 HeapTuple tuple,
					 bool token)
{
	...
	// 拿到上文中获取到 buffer ,也就是 block-number,拿到对应的page.
	/* Add the tuple to the page */
	pageHeader = BufferGetPage(buffer);
	// 在宏  PageAddItem 调用 PageAddItemExtended 函数 将 tup 的 data数据添加到page中。
	// 其中 PageAddItemExtended 函数中,会更新 page的 header 信息:
	// 比如 pd_lower, pd_upper, line-pointer 也就是 pd_linp 等
	// 更新完成这一些信息之后会将 tuple 传入的 t_data 信息 memcpy 到page中。
	offnum = PageAddItem(pageHeader, (Item) tuple->t_data,
						 tuple->t_len, InvalidOffsetNumber, false, true);
	...
	// 将offnum 和 buffer 更新到 tuple 的 t_self中.
	ItemPointerSet(&(tuple->t_self), BufferGetBlockNumber(buffer), offnum);
	...

第五步 标记page 为dirty 以及 checkpointer 进程异步刷脏页

第五步 会做一个 page 的标记,前面第二步时 拿一个可用的page block-number 过程中会为一个新的 page打上ditry 标记;如果是update场景,或者复用已有的page,则第二步不会打diry。所以,这里是更新完成了 tuple 到这个page,则需要 通过 MarkBufferDirty(buffer); 对该page 写入 dirty标记。

需要注意的是 到这里,关于 heap 表文件的元组插入就已经完成了,后续的 WAL 写入 以及 invalid relation cache 都是与堆表文件本身无关的,而元组所在page 的实际写盘是通过额外的 checkpoint 进程进行写入的。

这里可以做一个很简单的测试,先查看已有数据的表文件,利用 pg_relation_filepath函数能确认当前表对应的具体的heap文件,具体使用 可以参考前面 介绍数据库目录。可以直接 vim 这个文件,查看它的十六进制, 一般模式下:%!xxd,从最后看能看到实际的数据:
在这里插入图片描述
上面是之前插入的元组,每一个元组有两个 attribute,上面中能看到很多个元组,有一些是update产生的,需要在 vacuum 进程进行处理。

checkpointer 进程 是PG 众多子进程中的一个,定期 对内存中缓存的数据进行落盘,包括 CLOG(事务状体数据), 子事务,Relation map, snapshot , buffer pool 脏页(淘汰页)等。
在这里插入图片描述
checkpointer 子进程是在 postgresql 启动时在PostmasterMain 初始化的子进程。
其调用栈如下:

CheckpointerMain
  CreateCheckPoint
  	CheckPointGuts
  		CheckPointBuffers

在函数 CheckPointBuffers 中,会先通过 BufferSync 将 buffer 中的数据统一通过 posix write 写入到磁盘;再通过ProcessSyncRequests 对 buffer写入的page 所处的路径调用 fsync。

第六步 写入WAL

第六步 写WAL,如果用户在 postgresql.conf 文件中设置了 wal_level=0, 则会关闭 WAL 功能。同样,因为 wal 的源码逻辑 是 和事务体系相关联的,所以本篇也暂时不会描述。

代码 还是在 heap_insert之中

/* XLOG stuff */
if (!(options & HEAP_INSERT_SKIP_WAL) && RelationNeedsWAL(relation))
{
	...
}

应该是9系版本及以前,PG的WAL 还是同步落盘的,后来为了分离IO调度,又搞了一个wal-writer 子进程,来单独调度写 WAL的逻辑。

第七步 Invalid relation-cache

这里是在对元组更新的场景下, 如果这个元组所在的page 存在于 relation-cache 之中, 则需要告诉relation-cache 让这个page失效,因为数据已经发生了变更。
关于 relation-cache 和 catalog(系统表)后续会详细介绍,这里也是拥有非常多的细节。

invalid 的代码在:

void
CacheInvalidateHeapTuple(Relation relation,
						 HeapTuple tuple,
						 HeapTuple newtuple)
{
	...
}

到此,整个heap_insert 的逻辑是执行完了。

可以看到 PG 为了将IO 调度和内核逻辑处理分离,设计了很多子进程进行异步IO调度(所有的IO 调度都会通过 smgr 存储管理器进行),这样对于 PG 内核代码可维护性 以及 性能来说都是最为方便的。

回到我们的 堆表文件中的 page落盘,其在 INSERT 的主链路上并不会直接落盘,而是插入到 内存中对应的 pg page区域中,对 page 做一个标记,后续通过 checkpointer 进行 脏页的异步落盘(调用fsync)。

因为我们建表的时候没有在表上面创建索引,所以插入元组的时候并不会有索引的插入。如果我们创建了索引,则在ExecInsert --> table_tuple_insert 再 调用 heap 表写page完成之后还需要执行 ExecInsertIndexTuples() 进行索引的插入。

PostgreSQL find a kernel’s “Fsync Bug”

下面简单提一个 pg 社区认为的一个 内核 fsync 的 bug,这个讨论很有趣:

在2018年的时候 pg 社区发现了一个 “os-内核 fsync 的bug”,当时在 os-kernel社区, PG社区 以及 其他关系型数据库社区传的沸沸扬扬:)。

背景是 os-内核本身在处理 DRAM page落盘的时候默认是通过 write-back 机制,write-back的pd-flush 内核线程刷 内存脏页失败了(可能os硬件问题,内存脏页的存储其实对 pg 来说 是 checkpoint 刷 buffers 的第一个阶段,只是将buffers 数据 写入到os的 page-cache中,后续才会调用fsycn),当时内核对于这种脏页的错误处理“不足”(pg社区的开发者是这样认为的),然后 pg 在 checkpointer 刷 buffers 的第二个阶段会调用 fsync,这个时候因为内核本身 write-back 刷内存的某一个脏页是失败的,这个时候 fsync 也会处理失败,但是对于PG来说,在发现 fsync 失败的时候会根据 fsync 失败的错误码进行重试,fsync的失败返回的错误码内核确实没有区分是 write-back 失败的那一些脏页,这样 pg 重试 fsync的时候 内核返回成功了。

这个时候问题就来了,因为失败的那一些脏页不一定落盘,对PG来说,他们认为调用了 fsync 成功 之后所有的数据都一定会在磁盘上,然而事实是 这一些脏页数据还是内存里,假如os 断电什么的,这一些数据就丢了,违背了 PG 对fsync 的语义理解,存在丢数据的情况(稳定性问题,极为严重)。

所以PG社区 认为 内核应该为 fsync 这一语义在这种场景下的问题负责,然而内核社区认为这种问题fsync 很难避免,因为有后台write-back线程在执行的过程中 sync 也有可能失败,而且PG 的 checkpointer 进程的 smgr 并没有保存所有的打开过的文件fd(pg 为了避免 进程打开的fd 数量达到内核上限,做了只会缓存一部分fd到内存得优化),在有需要的时候才去重新打开fd,也就是fsync 的时候可能需要重新打开一部分文件,在打开之前可能也会发生一些 os 的 write-back 错误,打开过程中在4.13 及更新的内核版本 不会报错,那打开文件之后调用fsync 可能会成功,这种场景下更是会丢数据,内核开发者认为 PG 的这种checkpoint 刷脏页的机制(写完内存, 重新打开文件fd ,调用fsync )本身就会存在 调用fsync时前面发生过失败,导致部分内存脏页未落盘的情况。

内核社区建议 PG 社区在这种问题的最好的解决办法就是 使用 DIO(direct io),也就是 checkpointer 落盘的时候不要先写page-cache再批量fsync了,直接调用 DIO得了,但是 DIO 意味着整个 os 的page-cache用不了,而且对 PG的 写链路 可以说是非常大(对于这样的bug,改造成本太高了)。

后来,看到 PG的 commit 是在fsync 失败之后 做了panic(其他的 innodb 和 wiredtiger 也都做了同样的改造),内核这里也提了一些建议,可能需要在新的内核版本上才能上(DIO 是linux 为数据库应用专门开发的一个 io 特性,简单,对内核来说省事 ,对数据应用来说稳定。但是现如今看来,数据库领域的卷已经带入到了内核了,因为大家都想要好的性能,dio 的性能实在是让大家没有太大的竞争力;当然,如果选择自维护 page-cache 也能达到效果,但是对于 IO 链路的改造没有巨量人力和时间成本的投入,根本做不出来像内核这样的稳定性和性能;为了迎合用户对性能的需求 以及 卷过其他数据库,大家还是都默认选择了 buffer i/o)。

说的有点多,这块后面还是会仔细看看这个 PG 发现的bug,看看能不能复现。主要的是,PG 社区的几个 PMC 在讨论这个 bug的时候 提到 kernel brain damage 以及 100% unreasonable,以 torvalds 性格看到可能要爆粗口了… 不过 torvalds 没有参与讨论,不然可以吃一个大瓜。

更多的细节可以去下文直接看:

  1. 邮件讨论连接:The “fsyncgate 2018” mailing list thread
  2. 内核社区对 fsync bug 的讨论 : LWN.net article “PostgreSQL’s fsync() surprise”
  3. 内核提升了一下 block-layer 的错误处理机制 : LWN.net article “Improved block-layer error handling”
  4. 关于fsync 是否安全的 一篇研究论文 : Can Applications Recover from fsync Failures

读取

读取链路 从 SELECT 语句开始,调度的栈如下:

main
 PostmasterMain
  ServerLoop
   BackendStartup
	BackendRun
	 PostgresMain
	  exec_simple_query # 词法解析/语法解析/优化器
	   PortalRun # 已经生产执行计划,开始执行
	    PortalRunSelect # 执行计划是 查询,这里和 insert的执行计划是不一样的
	     standard_ExecutorRun
	      ExecutePlan
	       ExecProcNode
	        ExecScan
	         ExecScanFetch
	          SeqNext
	           table_scan_getnextslot
	            heap_getnextslot

主体逻辑是在 heap_getnextslot 函数中,该函数内部逻辑分为两部分:

  1. 从磁盘上读取对应的 tuple 坐在的page 数据到内存中。
  2. 将读取上来的tuple 填充到 可以被用户读取到的 TupleTableSlot 数据结构中。

第一部分是在 如下的调用栈中,最终会通过 ReadBuffer_common 产生实际的 read io,即 通过 PG smgr 存储管理器进行磁盘读取。

heap_getnextslot
 heapgettup_pagemode
  heapgetpage
   ReadBufferExtended
    ReadBuffer_common

读上来的 page 还会 在 heapgetpage 函数中进行可见性检查,这一部分有一些根据 PG 提供的隔离级别的规则来设置的可见性,能够较为高效得对一个元组/Page 的可见性进行判断 ,判断逻辑主要集中在 HeapTupleSatisfiesVisibility 函数中。

在第二部分中,主要做一些数据结构成员上的填充,通过函数 ExecStoreBufferHeapTuple 进行,将tuple 元组 以及 tuple 元组所在的 page 放入到 TupleTableSlot 之中,由上层进行数据解析返回给客户端。

读链路因为 workload 比较单一,相对来说简单很多,核心是 如何从磁盘读取 page 以及 如何对读上来的 page 按照预置好的 pg 事务可见性规则进行可见性检测 这两部分。

总结

因为本篇文章也只是单纯关注 PG 的存储引擎部分,对于PG 的事务 以及 其周边伴随的 wal-writer, 可见性检测,vacuum, checkpointer 等机制没有描述,因为 整个PG 作为 百万行代码级别的关系型数据库,其每一个小功能/组件 的实现都有非常多的细节,还需要持续深入学习。

从heap 表引擎设计来看, pg 将应用逻辑 和 io 调度分割开,PG 主进程只需要专注于DML 等语句的CPU 逻辑调度,而 IO 层面交给专门的后台进程,对性能更为友好。

因为IO 调度对于线程来说过于繁重,且 不利于 PG 的架构节藕,所以PG 设计了多进程的应用调度架构,每一个 I/O 繁重且功能集中的 组件交给专门的子进程进行调度,资源利用率更高,可扩展性更强。

当然,对于 DML 语句来说,对其性能影响最大的是 WAL 写入的性能,毕竟 heaptuple 的写入也仅仅是写内存而已。WAL 的写入业界大家都没有差太多,都是 group commit的形态,PG 利用的是预先分配好每一个事务提交请求的 LSN, 可以让不同的 事务在提交写 WAL的时候并发写同一个表文件的不同 position。

  大数据 最新文章
实现Kafka至少消费一次
亚马逊云科技:还在苦于ETL?Zero ETL的时代
初探MapReduce
【SpringBoot框架篇】32.基于注解+redis实现
Elasticsearch:如何减少 Elasticsearch 集
Go redis操作
Redis面试题
专题五 Redis高并发场景
基于GBase8s和Calcite的多数据源查询
Redis——底层数据结构原理
上一篇文章      下一篇文章      查看所有文章
加:2022-06-29 19:08:56  更:2022-06-29 19:11:55 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年3日历 -2024/3/29 5:21:24-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码