天天看点

PostgreSQL 基于heap表引擎的事务 实现原理

文章目录

  • ​​PG 事务隔离级别 以及 支持的语义​​
  • ​​基本理论​​
  • ​​实践:变更 PG 的隔离级别​​
  • ​​实践:RU(read uncommitted) 隔离级别​​
  • ​​实践:RC(Read committed) 隔离级别​​
  • ​​实践:RR(repeatable read) 隔离级别​​
  • ​​实践:Serialization Anomaly 异常​​
  • ​​PG 事务隔离级别 实现的核心组件​​
  • ​​HeapTuple​​
  • ​​Insert 与 事务标识​​
  • ​​Delete 与 事务标识​​
  • ​​Update 与 事务标识​​
  • ​​CLOG​​
  • ​​SnapShot​​
  • ​​PG 事务实现​​
  • ​​事务状态 和 分层事务架构​​
  • ​​底层事务的关键实现​​
  • ​​StartTransaction​​
  • ​​CommitTransaction​​
  • ​​AbortTransaction​​
  • ​​PG 可见性检查实现原理​​
  • ​​总结​​

之前描述过了

​​PG 的heap表引擎 读写链路的基本原理​​,本篇会在 heap 表引擎的基础上对 PG 的事务体系进行描述。

本节涉及到的 PG 代码版本是 ​

​REL_12_STABLE​

PG 事务隔离级别 以及 支持的语义

基本理论

PG release-9 版本 以后, PG 支持的隔离级别 以及 各个隔离级别对应的 一些读现象如下:

PostgreSQL 基于heap表引擎的事务 实现原理

简单介绍一下这几种读现象:

Dirty Read - 脏读 : 事务A读到到了其他事务未提交的数据。事务A 不清楚其他事务后续是会提交还是 终止,所以这种情况会出现比较严重的一致性问题,基本所有的数据库都应该避免这种情况。

Non-repeatable Read - 不可重复读 : 事务A前后两次查询,因为有其他事务对当前读的行的更新,从而得到了不同的内容。

Phantom Read - 幻读 : 事务A 前后两次批量查询,查询的数据总量不一致(有其他的事务的更新)。和 不可重复读的差异 : 幻读 是数据总量不一致(insert/delete 语句),后者是数据内容不一致(update 语句)。

Serialization Anomaly - 串形化异常 : 并发执行一批事务 和 按照顺序执行这一批事务的结果不一致(并发场景下就会有问题)。

关于 事务的隔离级别,基本几种如下:

  • ​RU-read uncommited​

    ​,这种隔离级别基本可以看作没有隔离,不同事务底层实现共享的同一套缓存,可以互相读到各自的更新数据。
  • ​RC-read commited​

    ​,不同的事务只能读到已经提交的数据。
  • ​Repeatable Read​

    ​​,不同的事务可重复读,每次读到的数据是一致的。不过这里需要注意的是 PG 支持的 这个隔离级别不存在 ​

    ​Phantom Read​

    ​​ 问题,而 ANSI-SQL 标准中的 这个隔离级别是有 ​

    ​Phantom Read​

    ​ 问题的。
  • ​Serializable​

    ​ 可串形化 隔离级别,并发执行的事务最终的执行结果能够和 按照顺序执行的事务拥有一样的结果。当然,它的实现底层肯定有一个保证执行顺序的机制(比如拥有足够大的粒度的锁),性能相比其他的隔离级别肯定是差不少。

实践:变更 PG 的隔离级别

PG 内部默认是 ​

​Read Commited​

​ 隔离级别

testdb=# show transaction isolation level;
 transaction_isolation
-----------------------
 read committed
(1 row)      

PG 内部如果想要在psql terminal内部 设置一个事务的隔离级别,只能在事务启动之后设置(无法全局生效),当前设置的隔离级别只能针对当前事务生效了。如果想要变更全局的事务隔离级别,可以通过 变更 PGOPTION 来达到:

testdb=# set default_transaction_isolation='repeatable read';
SET
testdb=# show transaction isolation level;
 transaction_isolation
-----------------------
 repeatable read
(1 row)      

我们还是在 一个事务内部做隔离级别的变更:

testdb=# begin;
BEGIN
testdb=# set transaction isolation level read uncommitted;
SET
testdb=# show transaction isolation level;
 transaction_isolation
-----------------------
 read uncommitted
(1 row)      

实践:RU(read uncommitted) 隔离级别

虽然表格中没有这个隔离级别,但是 PG 本身是支持的,但是语义是和 ​

​read committed​

​ 一样的,毕竟使用PG的场景肯定是 TP 场景,对一致性的要求还是比较高的,直接拒绝提供标准的 RU 语义了。当然,本质原因可能是 PG 的学院派风格对代码要求比较高,支持一个基本没有人使用的 RU 语义 对PG本身的事务体系的 C 代码模块还是有一定的影响的(不同事务需要共享一些数据结构,对于封装特性并不是很友好的 C 代码来说 实现上不够优雅),还不如直接不支持。

开两个终端,分别用于 txn1 和 txn2 的执行:

--> txn1
testdb=# begin;
testdb=# show transaction isolation level;
 transaction_isolation
-----------------------
 read uncommitted
(1 row)
testdb=# select * from account ;
 id | c1 |             c2
----+----+----------------------------
  1 |  5 | 2022-07-24 17:22:27.464059
  2 |  2 | 2022-07-24 17:22:27.464059
  3 |  3 | 2022-07-24 17:22:27.464059
  4 |  7 | 2022-07-24 17:22:27.464059
  5 | 10 | 2022-07-24 17:22:27.464059      

在另一个终端开启 txn2

--> txn2
testdb=# begin;
testdb=# show transaction isolation level;
 transaction_isolation
-----------------------
 read uncommitted
(1 row)
testdb=# select * from account where id = 2;
 id | c1 |             c2
----+----+----------------------------
  2 |  2 | 2022-07-24 17:22:27.464059      

接下来我们在 txn1 中更新 id=2 这一行,如果标准的 RU 语义,即期望读到未提交的事务,那 txn1还没有提交的情况下,txn2 也能够看到 更新的这一行,一起看看

--> txn1
testdb=# update account SET c1 = 100 where id=2 returning *;
 id | c1  |             c2
----+-----+----------------------------
  2 | 100 | 2022-07-24 17:22:27.464059
(1 row)

--> txn2
testdb=# select * from account where id=2;
 id | c1 |             c2
----+----+----------------------------
  2 |  2 | 2022-07-24 17:22:27.464059
(1 row)      

可以发现还是一样的内容,没有任何变化,可以在​​PG官方的文档​​中清晰得看到 RU 隔离级别其实还是 RC 的语义。

所以 目前 PG实现的隔离级别中 不会出现 脏读的情况。

实践:RC(Read committed) 隔离级别

read committed 隔离级别 标识不同的正在运行的事务之间只能读到对方已经 commit的数据,未commit 的数据在不同的事务之间都是不可见的。

但是仍然存在 幻读、不可重复读 以及 串形化异常 的问题。

比如 幻读现象(同一个事务前后读取的数据量不同):

先场景两个事务,读取同一个表的内容,展示的是数据条目。

-->txn1
testdb=# begin;
BEGIN
testdb=# select * from account;
 id | c1  |             c2
----+-----+----------------------------
  1 |   5 | 2022-07-24 17:22:27.464059
  3 |   3 | 2022-07-24 17:22:27.464059
  4 |   7 | 2022-07-24 17:22:27.464059
  5 |  10 | 2022-07-24 17:22:27.464059
  2 | 100 | 2022-07-24 17:22:27.464059
(5 rows)

-->txn2
testdb-# begin;
BEGIN
testdb=# select * from account where c1 >= 7;
 id | c1  |             c2
----+-----+----------------------------
  4 |   7 | 2022-07-24 17:22:27.464059
  5 |  10 | 2022-07-24 17:22:27.464059
  2 | 100 | 2022-07-24 17:22:27.464059
(3 rows)      

接下来 txn1 做了一些更新,然后提交,能够看到 txn2再次读的时候条目个数发生了变化。

--> txn1
testdb=# update account SET c1=5 where id=5 returning *;
 id | c1 |             c2
----+----+----------------------------
  5 |  5 | 2022-07-24 17:22:27.464059
(1 row)

UPDATE 1
testdb=# commit;
COMMIT

--> txn2
testdb=# select * from account where c1 >= 7;
 id | c1  |             c2
----+-----+----------------------------
  4 |   7 | 2022-07-24 17:22:27.464059
  2 | 100 | 2022-07-24 17:22:27.464059
(2 rows)      

对于 RC隔离级别下可能存在的其他两种读现象也比较好模拟,比如 不可重复读 问题,针对的是一条具体的数据,可以让 txn2 两次读取观察同一条数据,而 在两次读期间 txn1 则更新这一条数据,结果就比较清晰了。

实践:RR(repeatable read) 隔离级别

对于刚才 RC 出现的幻读问题,如果 PG的事务隔离级别为 RR,则不会有这个问题(ANSI-SQL 标准定义的 RR是有这个问题的,但是 PG 这里RR 不会出现这个问题)。

如下操作过程,先设置两个事务的隔离级别:

--> txn1
testdb=# begin;
BEGIN
testdb=# set transaction isolation level repeatable read;
SET
testdb=# select * from account;
 id | c1  |             c2
----+-----+----------------------------
  1 |   5 | 2022-07-24 17:22:27.464059
  3 |   3 | 2022-07-24 17:22:27.464059
  4 |   7 | 2022-07-24 17:22:27.464059
  2 | 100 | 2022-07-24 17:22:27.464059
  5 |   5 | 2022-07-24 17:22:27.464059
(5 rows)

--> txn2
testdb=# begin;
BEGIN
testdb=# set transaction isolation level repeatable read;
SET
testdb=# select * from account where c1 >= 5;
 id | c1  |             c2
----+-----+----------------------------
  1 |   5 | 2022-07-24 17:22:27.464059
  4 |   7 | 2022-07-24 17:22:27.464059
  2 | 100 | 2022-07-24 17:22:27.464059
  5 |   5 | 2022-07-24 17:22:27.464059
(4 rows)      

接下来 我们一样的,在txn1内部更新一行,让 输出 在 txn2的执行条件下理论上减小一行,但实际 RR 隔离级别下 txn2并不会减少。

--> txn1
testdb=# update account SET c1=1 where id=5 returning *;
 id | c1 |             c2
----+----+----------------------------
  5 |  1 | 2022-07-24 17:22:27.464059
(1 row)

UPDATE 1
testdb=# commit;
COMMIT

--> txn2
testdb=# select * from account where c1 >= 5;
 id | c1  |             c2
----+-----+----------------------------
  1 |   5 | 2022-07-24 17:22:27.464059
  4 |   7 | 2022-07-24 17:22:27.464059
  2 | 100 | 2022-07-24 17:22:27.464059
  5 |   5 | 2022-07-24 17:22:27.464059
(4 rows)      

同样的, Non-repeatable read 这种现象也不会在 RR 的隔离级别下发生。

实践:Serialization Anomaly 异常

PG 的 RR级别下是有可能出现 Serialization Anomaly 异常问题的,即 并发执行的多个事务的执行结果和每一个事务串形执行的结果不同。

接下来我们模拟的是在 RR 隔离级别下,看两个并发执行的事务最终会出现的 Serialization Anomaly异常问题。

两个并发事务都根据自己查询到的结果插入一条相同的数据:

--> txn1;
testdb-# begin;
BEGIN
testdb=# set transaction isolation level repeatable read;
SET
testdb=# select * from account;
 id | c1  |             c2
----+-----+----------------------------
  1 |   5 | 2022-07-24 17:22:27.464059
  3 |   3 | 2022-07-24 17:22:27.464059
  4 |   7 | 2022-07-24 17:22:27.464059
  2 | 100 | 2022-07-24 17:22:27.464059
  5 |   1 | 2022-07-24 17:22:27.464059
(5 rows)

testdb=# select sum(c1) from account;
 sum
-----
 116
(1 row)

testdb=# insert into account values (666, 116, current_timestamp);
INSERT 0 1
testdb=# select * from account;
 id  | c1  |             c2
-----+-----+----------------------------
   1 |   5 | 2022-07-24 17:22:27.464059
   3 |   3 | 2022-07-24 17:22:27.464059
   4 |   7 | 2022-07-24 17:22:27.464059
   2 | 100 | 2022-07-24 17:22:27.464059
   5 |   1 | 2022-07-24 17:22:27.464059
 666 | 116 | 2022-07-24 19:24:10.269407

--> txn2
testdb=# select sum(c1) from account;
 sum
-----
 116
(1 row)

testdb=# insert into account values (666, 116, current_timestamp);
INSERT 0 1      

可以看到并发的 txn1 和 txn2 都在自己 RR 隔离级别下读到的数据插入一条新的数据,只是两者读到的是一样的, 接下来看一下两个事务都提交之后的数据。

--> txn1
testdb=# commit;
COMMIT

--> txn2
testdb=# commit;
COMMIT

testdb=# select * from account;
 id  | c1  |             c2
-----+-----+----------------------------
   1 |   5 | 2022-07-24 17:22:27.464059
   3 |   3 | 2022-07-24 17:22:27.464059
   4 |   7 | 2022-07-24 17:22:27.464059
   2 | 100 | 2022-07-24 17:22:27.464059
   5 |   1 | 2022-07-24 17:22:27.464059
 666 | 116 | 2022-07-24 19:24:10.269407
 666 | 116 | 2022-07-24 19:26:52.112013      

可以看到有两条一样的 record,而如果两个事务串形执行,则 不会出现 c1 都是 116的行,这样的情况就是我们说的 Serializable Abnormal 现象。

接下来 尝试一下将 隔离级别设置为 ​

​Serializable​

​ 时是否会有这个异常。

--> txn1
testdb=# begin;
BEGIN
testdb=# set transaction isolation level serializable;
SET
testdb=# select sum(c1) from account;
 sum
-----
 116
(1 row)

testdb=# insert into account values (666, 116, current_timestamp);
INSERT 0 1
testdb=# select * from account;
 id  | c1  |             c2
-----+-----+----------------------------
   1 |   5 | 2022-07-24 17:22:27.464059
   3 |   3 | 2022-07-24 17:22:27.464059
   4 |   7 | 2022-07-24 17:22:27.464059
   2 | 100 | 2022-07-24 17:22:27.464059
   5 |   1 | 2022-07-24 17:22:27.464059
 666 | 116 | 2022-07-24 19:35:50.265322
(6 rows)

--> txn2
testdb=# begin;
BEGIN
testdb=# set transaction isolation level serializable;
SET
testdb=# select * from account;
 id | c1  |             c2
----+-----+----------------------------
  1 |   5 | 2022-07-24 17:22:27.464059
  3 |   3 | 2022-07-24 17:22:27.464059
  4 |   7 | 2022-07-24 17:22:27.464059
  2 | 100 | 2022-07-24 17:22:27.464059
  5 |   1 | 2022-07-24 17:22:27.464059
(5 rows)

testdb=# select sum(c1) from account;
 sum
-----
 116
(1 row)

testdb=# insert into account values (666, 116, current_timestamp);
INSERT 0 1      

然后先提交 txn1,再提交txn2

--> txn1
testdb=# commit;
COMMIT

--> txn2
testdb=# commit;
ERROR:  could not serialize access due to read/write dependencies among transactions
DETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.
HINT:  The transaction might succeed if retried.      

很明显,txn1 成功了,而txn2 失败了。PG 在 serializable 隔离级别下能够检测出不同事务之间的依赖关系,从而在各自提交的时候进行判断,是否满足互不依赖,如果有依赖(事务内部 读写操作了同一批数据),则让后提交事务直接失败 然后重试即可,这样就能够有效阻止 Serializable Abnormal 现象。

PG 事务隔离级别 实现的核心组件

从上面的几个PG 不同隔离级别下的操作语义我们能够看到 PG 对 ANSI-SQL 支持的基本情况,接下来我们从 PG 内核层面来看看 事务实现的一些原理。

PG 事务的实现主要依靠三个组件或者说 数据结构:

  • ​HeapTuple​

    ​​,一个保存行数据的持久化数据结构。这个是在上一片文章中介绍的 heap表存储引擎内部 存储一行数据的最小单位。在事务中使用到的主要是 其内部的几个字段 :​

    ​t_xmin​

    ​​, ​

    ​t_xmax​

    ​​, ​

    ​t_cid​

    ​​, ​

    ​t_ctid​

    ​​ 这四个,细节下文会展开。

    这几个字段对一行数据的描述能够清晰得知道该行数据是 新插入的 还是 被删除的 或者 被更新的。

  • ​CLOG​

    ​​,一个保存事务状态信息。clog 是会保存每一个事务的状态,一般有三种:​

    ​IN_PROGRESS​

    ​​, ​

    ​COMMITTED​

    ​​, ​

    ​ABORTED​

    ​​,这一些状态会被放在共享内存中,也会像其他的要持久化的数据一样定期被刷到磁盘上。磁盘上存储 clog 信息的位置是在 PG 的 data目录下面的 ​

    ​pg_xact​

    ​​目录中,在 PG9.6 版本及之前是存放在 ​

    ​pg_clog​

    ​​目录下,后来可能因为大家删除 一些log文件的时候会用 ​

    ​*log​

    ​ 结尾,导致这个影响PG 可用性以及一致性的 重要目录频繁发生操作失误的情况,干脆直接换个名字。
  • ​Snapshot​

    ​,快照信息。主要保存的是当前 PG 内部的活跃事务列表。

对于事务的可见性检查或者判断,熟悉了这三个“小”组件的原理 以及 如何搭配工作的,那整个事务的宏观实现也就基本清楚了。

HeapTuple

虽然上一篇文章有介绍整个​​heap表引擎的结构​​,今天再来简单回顾一下 ​

​HeapTuple​

​ 数据结构的基本形态:

PostgreSQL 基于heap表引擎的事务 实现原理

其中我们主要关注的是三个字段:

  • ​t_ctid​

    ​​,这是一个 ​

    ​ItemPointerData​

    ​​ 类型,其有两个 ​

    ​block-id​

    ​​ 以及 ​

    ​offset​

    ​​,主要是标识当前tuple 所指向的一个有效的元组的位置。比如 在当前tuple 基础上产生了更新,那会将 ​

    ​t_ctid​

    ​ 指向更新后的tuple的位置。
  • ​t_xmin​

    ​,标识 当前tuple的数据第一次insert 时的 事务 id.
  • ​t_xmax​

    ​, 标识 对当前tuple进行过删除/更新的 事务id,如果没有,会被设置为0。
  • ​t_cid​

    ​,在当前事务内部,操作当前 tuple 的 commnd 之前有多少个commid,是一个command数量的标识。

Insert 与 事务标识

接下来看看 tuple 的这几个字段 在 Insert 场景的变化:

PostgreSQL 基于heap表引擎的事务 实现原理

插入时会更新 ​

​t_xmin​

​​ 以及 ​

​t_ctid​

​​,比如当前事务id 是99,则设置 ​

​t_xmin​

​​为99即可,并设置 ​

​t_ctid​

​​标识当前tuple 所在的 page/block number 以及 页内offset。更新 ​

​t_xmin​

​​, ​

​t_xmax​

​​, ​

​t_cid​

​​ 标识的代码是在 ​

​heap_prepare_insert​

​ 函数内部:

PostgreSQL 基于heap表引擎的事务 实现原理

关于 ​

​t_ctid​

​​的更新,因为需要知道当前tuple所处 page的编号 以及 page 内偏移,所以需要在插入page时才会进行更新,通过 ​

​RelationPutHeapTuple​

​ 函数进行更新。

此外还可以通过 PG 提供的 ​

​pageinspect​

​ extension 来看到插入的tuple信息:

testdb=# CREATE TABLE tbl (data text);
CREATE TABLE
testdb=# INSERT INTO tbl VALUES('A');
INSERT 0 1
testdb=# SELECT lp as tuple, t_xmin, t_xmax, t_field3 as t_cid, t_ctid
testdb-#                 FROM heap_page_items(get_raw_page('tbl', 0));
 tuple | t_xmin | t_xmax | t_cid | t_ctid
-------+--------+--------+-------+--------
     1 |    662 |      0 |     0 | (0,1)      

Delete 与 事务标识

删除一行数据 意味着删除一个tuple,也就是需要更新我们前面说的 t_xmax 这个标识了。

比如,删除操作的 事务id 为 111,则会将 原本的tuple 的 t_xmax 字段从 0 设置为 111。

PostgreSQL 基于heap表引擎的事务 实现原理

这里的更新主要是在 ​

​heap_delete​

​的代码中进行的:

PostgreSQL 基于heap表引擎的事务 实现原理

如果 txid=111 的事务被提交了,则这个元组会被标记为dead tuple,由后台的vacuum 进程清理。

Update 与 事务标识

对一行数据的 update,在元组操作中并不是像 delete 一样直接在 已有tuple上直接修改,而是生成一个新的 tuple,并修改旧版本tuple 的 ​

​t_xmax​

​​,​

​t_ctid​

​等信息。

PostgreSQL 基于heap表引擎的事务 实现原理

代码也是在 ​

​heap_update​

​ 这个函数中,不过实现上因为考虑的生成新元组 以及 更新旧元组的细节很多,所以整体的代码也比其他操作多了不少。

对于 update 来说 为了提供 MVCC的能力,做了一个 追加元组的操作,保证了旧的元组仍然能够提供正常的读请求(参与可见性判断),在后续的 vacuum 中能够进行旧元组的一个清理工作。

到此,管理数据的元组部分就基本描述完了,因为元组本身保存的是表数据,每一个元组都拥有属于自己的一些transaction 标识(上面图中的字段),PG事务可见性的单位也就是元组了,除了元组之外 还有两个小组件能够参与到基本事务的实现中。

CLOG

因为一个事务操作是一个过程,需要经过一段时间的命令执行才能够完成,而在这个事务完成之前对于不同的客户端来说其操作内容都是不可见的,至少对于 PG 提供的三个隔离级别来说是由这样的要求的。

所以在实现事务的过程中需要有一种机制能够保存每一个事务的执行状态,从而在事务1 想要读取一行数据的时候 能够根据操作同一行的事务2 的状态来判断该行操作后的数据是否可见。

为了保证性能,该数据默认会缓存到共享内存中,也会通过后台的 checkpoint 进程进行持久化。

CLog 主要记录四种事务状态,它本身做的事情有点像时UNDO log,只是没有记录那么详细的事务操作,仅仅保存每一个事务的状态:

#define TRANSACTION_STATUS_IN_PROGRESS    0x00   // 正在处理的事务
#define TRANSACTION_STATUS_COMMITTED    0x01     // 已经提交的事务
#define TRANSACTION_STATUS_ABORTED      0x02     // 已经终止的事务
#define TRANSACTION_STATUS_SUB_COMMITTED  0x03   // 标识当前子事务不会长时间运行,但是其父事务还未更新其状态      

标识一个事务会用 2 bits,保存CLOG的 block 大小默认是 8k,因为目前PG 的transactionid 还是 32bits的,每一个事务可能有四种状态,即使不清理CLOG空间且保存每一个事务的四种状态,则CLOG 占用空间总大小最多也就 4294967296 * 4 * (2 / 8) B = 4G,而这样大小的CLOG 则大多数机器都能够缓存到内存中,能够防止CLOG 成为事务瓶颈。

宏观来看就是下图这样的作用,保存事务执行过程中的状态信息,用作下文要说的可见性检查。

PostgreSQL 基于heap表引擎的事务 实现原理

clog的实现上来看是如下图的逻辑:

PostgreSQL 基于heap表引擎的事务 实现原理
  1. 在事务提交/终止的时候,更新完xlog 就会更新clog,先拿到事务id(注意,pg 不会为 select分配事务id)
  2. 拿到事务id之后 会通过 ​

    ​TransactionIdToPage​

    ​将该事务映射到一个要存储在磁盘上的pageno,因为前面说过,为了保证性能,clog的状态信息会先保存在内存中。
  3. 拿到映射好的 pageno 会在保存 clog 共享内存变量 ​

    ​ClogCtl​

    ​中找到一个可用的 slot。这个slot 可以理解为是内存中管理事务状态的数据结构,每一个slot 可以管理 32768 个事务状态,就可以和磁盘上实际存储clog 状态的page 一一对应了。当然,如果根据pageno 找不到可用的 slot,那就会扩展一个新的slot,并将旧的slot pg_fsync 刷盘。
  4. Slot 持久化到底层 pg_xact 中的clog文件时机有两种情况:第一种就是上面说的,扩展 slot 的时候会刷盘。第二种就更新事务状态到已有的slot中,那就标识一下当前slot 对应的page 为dirty,后续通过后台进程 checkpointer进行落盘。
  5. 过期数据的清理(毕竟clog 是追加写入page的,uin32_t 的回卷是需要回收clog信息的 以及 同一个事务的不同状态夸page持久化也需要回收),所以 还会有 vacuum 进程的介入来进行clog 过期数据的清理。

到此,整个 clog 的作用以及大体实现就清楚了,代码细节会在后文讲 事务实现的代码原理的时候展开。

SnapShot

​SnapShot​

​ 是 PG 事务体系的第三个核心组件。简单来看,其维护了一个数据结构用来标识当前活跃的事务列表。

typedef struct SnapshotData
{
  SnapshotType snapshot_type; /* type of snapshot */

  /*
   * The remaining fields are used only for MVCC snapshots, and are normally
   * just zeroes in special snapshots.  (But xmin and xmax are used
   * specially by HeapTupleSatisfiesDirty, and xmin is used specially by
   * HeapTupleSatisfiesNonVacuumable.)
   *
   * An MVCC snapshot can never see the effects of XIDs >= xmax. It can see
   * the effects of all older XIDs except those listed in the snapshot. xmin
   * is stored as an optimization to avoid needing to search the XID arrays
   * for most tuples.
   */
  TransactionId xmin;     /* all XID < xmin are visible to me */
  TransactionId xmax;     /* all XID >= xmax are invisible to me */

  /*
   * For normal MVCC snapshot this contains the all xact IDs that are in
   * progress, unless the snapshot was taken during recovery in which case
   * it's empty. For historic MVCC snapshots, the meaning is inverted, i.e.
   * it contains *committed* transactions between xmin and xmax.
   *
   * note: all ids in xip[] satisfy xmin <= xip[i] < xmax
   */
  TransactionId *xip;
  uint32    xcnt;     /* # of xact ids in xip[] */
  ......
} SnapshotData;      

核心的数据结构主要是三个,​

​xmin​

​​, ​

​xmax​

​​ 以及 ​

​xip​

​​数组,可以通过 ​

​SELECT txid_current_snapshot();​

​ 查看当前活跃事务列表。

其中:

  • ​xmin​

    ​ 最早的活跃事务 txid。所有小于 xmin 事务id 的事务提交了的,都是可见的,abort的则都不可见。
  • ​xmax​

    ​​ 最新的未赋值给其他事务的 txid,所有大于等于 ​

    ​xmax​

    ​的事务id 都是未开始的事务,也都不可见。
  • ​xip​

    ​ 按添加时间顺序 的活跃 txid数组,这里面的元素仅包含在 xmin - xmax 之间的活跃事务id。

举例如下:

PostgreSQL 基于heap表引擎的事务 实现原理

对于 ​

​100:100​

​ 来说,xmin = 100, xmax=100, 在xmin和xmax 之间再没有活跃事务了

  • 小于 100 的事务都是不活跃的事务
  • 大于等于 100 的事务都是活跃事务,

对于 ​

​100:104:100,102​

​来说, xmin = 100, max = 104, xip = [100, 102]

  • 小于100的事务都是不活跃事务
  • 大于等于104的事务都是活跃事务
  • 在100 到 104之间,还有两个活跃事务 100和102,活跃事务都是不可见的。

有了这一些小组件的基础知识,我们就能够在后续了解 PG 事务底层的隔离级别的实现上事半功倍了。

接下来我们会先自顶向下展开PG 事务形态的架构,来让大家对PG 事务的宏观形态有一个基本的认识。

PG 事务实现

事务状态 和 分层事务架构

大体的 事务状态转化 以及 分层事务架构如下:

PostgreSQL 基于heap表引擎的事务 实现原理

其中

  • topest level transaction 为:
StartTransactionCommand
CommitTransactionCommand
AbortCurrentTransaction      

用来直接和 SQL 命令进行交互。

  • Second level transaction 为:
BeginTransactionBlock
EndTransactionBlock
UserAbortTransactionBlock
DefineSavepoint
RollbackToSavepoint
ReleaseSavepoint      

以上两层的 transaction 管理的事务状态均为 ​

​TBlockState​

  • lowest level transaction 为:
StartTransaction
  CommitTransaction
  AbortTransaction
  CleanupTransaction
  StartSubTransaction
  CommitSubTransaction
  AbortSubTransaction
  CleanupSubTransaction      

lowest level transaction 为PG 的内部事务,管理的状态是 ​

​TransState​

基本的事务语句在PG 调用的函数集如下,对应事务层内部的状态转换可以参考上图,需要注意的是 topest level transaction 函数会在每一个事务语句中进行应用:

/  StartTransactionCommand;
    /       StartTransaction;
1) <    ProcessUtility;                 << BEGIN
    \       BeginTransactionBlock;
     \  CommitTransactionCommand;

    /   StartTransactionCommand;
2) /    PortalRunSelect;                << SELECT ...
   \    CommitTransactionCommand;
    \       CommandCounterIncrement;

    /   StartTransactionCommand;
3) /    ProcessQuery;                   << INSERT ...
   \    CommitTransactionCommand;
    \       CommandCounterIncrement;

     /  StartTransactionCommand;
    /   ProcessUtility;                 << COMMIT
4) <        EndTransactionBlock;
    \   CommitTransactionCommand;
     \      CommitTransaction;      

接下来主要关注 与我们前面介绍的 事务是三个组件相关的 事务层实现,也就是 lowest level(最上层和中间层事务的函数块大多都是状状态机,没有涉及太核心的数据结构)。

底层事务的关键实现

StartTransaction

​StartTransaction​

​ 并没有Clog相关的操作,主要是分配一些当前事务需要的系统资源,比如 内存,GUC, Cache 等。

同时分配虚拟事务id ​

​GetNextLocalTransactionId​

​,用来和当前 backend 所启动的其他事务进行区分(并不属于全局事务)。

/*
 *  StartTransaction
 */
static void
StartTransaction(void)
{
  TransactionState s;
  VirtualTransactionId vxid;
  ...
  /* 设置 lowest level transaction 的事务状态为 START. */
  s->state = TRANS_START;
  ...
  /*
   * 初始化 TopTransactionContext 的内存 以及 资源管理器
   */
  AtStart_Memory();
  AtStart_ResourceOwner();

  /*
   * 分配虚拟事务id,也就是 local transaction id,标识当前backend 正在执行的事务。
   */
  vxid.backendId = MyBackendId;
  vxid.localTransactionId = GetNextLocalTransactionId();
  
  ...
  
  /*
   * 初始化 GUC, cache
   */
  AtStart_GUC();
  AtStart_Cache();
  
  ...
}      

CommitTransaction

这个函数需要讲当前事务 以及 子事务(有savepoint 这种) 内的所有数据进行提交,包括写clog。

static void
CommitTransaction(void)
{
  TransactionState s = CurrentTransactionState;
  TransactionId latestXid;
  bool    is_parallel_worker;
  ...
  /*
   * 设置事务状态 以及 通过 RecordTransactionCommit 刷xlog 和 clog
   */
  s->state = TRANS_COMMIT;
  s->parallelModeLevel = 0;

  if (!is_parallel_worker)
  {
    /*
     * We need to mark our XIDs as committed in pg_xact.  This is where we
     * durably commit.
     */
    latestXid = RecordTransactionCommit();
  }
  ...
  /* 剩下的逻辑就是清理一些PROC 存储的当前事务状态 以及 StartTransaction 期间申请的资源。*/
  ...
}      

这里主要关注的逻辑是 ​

​RecordTransactionCommit​

​,其主要是Flush xlog 以及 向共享内存管理的 clog slot 中插入当前事务的clog 状态。

static TransactionId
RecordTransactionCommit(void)
{
  TransactionId xid = GetTopTransactionIdIfAny();
  bool    markXidCommitted = TransactionIdIsValid(xid);
  ...
  
  if ((wrote_xlog && markXidCommitted &&
     synchronous_commit > SYNCHRONOUS_COMMIT_OFF) ||
    forceSyncCommit || nrels > 0)
  {
    XLogFlush(XactLastRecEnd);

    /*
     * Now we may update the CLOG, if we wrote a COMMIT record above
     */
    if (markXidCommitted)
      TransactionIdCommitTree(xid, nchildren, children);
  }
  ...
}      

其中 ​

​TransactionIdCommitTree​

​​ 函数会 调用 ​

​TransactionIdSetTreeStatus​

​​ 将当前 ​

​xid​

​​ 设置为​

​TRANSACTION_STATUS_COMMITTED​

​​ 状态,这个状态 以及 xid 的存储会在 ​

​ClogCtl->shared->page_buffer​

​​ 中选择一个按照 xid 映射的slot进行存储, 每一个事务状态会占用两个 ​

​bit​

​。

最终的存储clog状态的函数为 ​

​TransactionIdSetStatusBit​

​,其调用栈为:

TransactionIdCommitTree
  TransactionIdSetTreeStatus
    TransactionIdSetPageStatus
      TransactionIdSetPageStatusInternal
        TransactionIdSetStatusBit      

存储当前事务状态到一个slot 之后,会标识这个slot 对应的共享内存的page 为ditry,后台的 checkpointer 进程会间歇性刷盘。

还有一种刷盘的情况是 需要扩展slot,即 ​

​ClogCtl​

​ 管理的共享内存的slot已经被用光了(每一个slot内部,8*1024*8/2个事务状态为一组,存储在一个slot中),需要申请新的slot,会在如下调用栈中进行旧的slot的落盘操作:

TransactionIdCommitTree
  TransactionIdSetTreeStatus
    TransactionIdSetPageStatus
      TransactionIdSetPageStatusInternal
        SimpleLruReadPage
          SlruSelectLRUPage
            SlruInternalWritePage
              SlruPhysicalWritePage      

在 ​

​SlruPhysicalWritePage ​

​​函数中,并不是直接刷 一个 slot 对应的page,而且需要先将当前slot中最大的事务id 之前的 xlog都落盘,所以 会先执行一次 ​

​XLogFlush(max_lsn)​

​,然后才会写入clog。

write系统调用写完clog之后会调用 pg_fsync,保证数据落盘,这个落盘和checkpinter 进程写clog 一样都需要开启 pg_fsync。

static bool
SlruPhysicalWritePage(SlruCtl ctl, int pageno, int slotno, SlruFlush fdata)
{
  ...
    XLogRecPtr  max_lsn;
    int     lsnindex,
          lsnoff;

    lsnindex = slotno * shared->lsn_groups_per_page;
    max_lsn = shared->group_lsn[lsnindex++];
    for (lsnoff = 1; lsnoff < shared->lsn_groups_per_page; lsnoff++)
    {
      XLogRecPtr  this_lsn = shared->group_lsn[lsnindex++];

      if (max_lsn < this_lsn)
        max_lsn = this_lsn;
    }

    if (!XLogRecPtrIsInvalid(max_lsn))
    {
      /*
       * As noted above, elog(ERROR) is not acceptable here, so if
       * XLogFlush were to fail, we must PANIC.  This isn't much of a
       * restriction because XLogFlush is just about all critical
       * section anyway, but let's make sure.
       */
      START_CRIT_SECTION();
      XLogFlush(max_lsn); // 写xlog
      END_CRIT_SECTION();
    }
  
    ...
  /* 写clog. */
  if (write(fd, shared->page_buffer[slotno], BLCKSZ) != BLCKSZ)
  {
    pgstat_report_wait_end();
    ...
  }
  
  /* 带pg_fsync 写盘。 */
  if (!fdata)
  {
    pgstat_report_wait_start(WAIT_EVENT_SLRU_SYNC);
    if (ctl->do_fsync && pg_fsync(fd))
    {
      ...
    }
    ...
  }
  ...
}      

需要注意的是本篇里面没有详细描述 clog 中的 事务 如何和 pg_subtrans 子事务进行交互的,他们之间的落盘的原子性如何保证的。

如果 主事务 和 子事务(savepoint 之后 进行 insert/update/delete) 都分配事务xid,且他们分布在不同的clog page,那么他们之间落盘的一致性需要有保障的,如果出现第一个page落盘之后 第二个page落盘之前磁盘坏了,那需要在后续的recovery过层中能够识别到第一次落盘的page中的部分事务是无效的。

这里实际实现是在 ​

​TransactionIdSetTreeStatus​

​ 函数中,借用了 2pc 的思想:

  • 将主事务以外的CLOG PAGE中的子事务设置为sub-committed状态;
  • 主事务所在的CLOG PAGE中的子事务设置为sub-committed,同时设置主事务为committed状态,将同页的子事务设置为committed状态;
  • 将其他CLOG PAGE中的子事务设置为committed状态

Recovery的时候发现了部分page有 ​

​sub-committed​

​​状态,那就不能标识当前事务为可见,还需要等待 遇到​

​committed​

​​ 的事务,如果没有遇到,则当前 ​

​sub-committed​

​的事务状态是无效的。

这种两阶段实现原子性的思想是比较传统的跨文件(跨写时机)的方式,能够有效保证 BatchWrite 的原子性。

AbortTransaction

逻辑同 ​

​CommitTransaction​

​​比较接近,用来终止一个事务,释放这个事务之前分配的所有的资源,同时会通过​

​TransactionIdAbortTree​

​​ 为当前事务的 xid 记录 abort状态的clog,和​

​CommitTransaction​

​的差异是不会写 xlog了。

/*
 *  AbortTransaction
 */
static void
AbortTransaction(void)
{
  TransactionState s = CurrentTransactionState;
  TransactionId latestXid;
  bool    is_parallel_worker;
  
  /* 释放各种资源 */
  ...
  /*
   * set the current transaction state information appropriately during the
   * abort processing
   */
  s->state = TRANS_ABORT;
  
  ...

  /*
   * Advertise the fact that we aborted in pg_xact (assuming that we got as
   * far as assigning an XID to advertise).  But if we're inside a parallel
   * worker, skip this; the user backend must be the one to write the abort
   * record.
   */
  if (!is_parallel_worker)
    latestXid = RecordTransactionAbort(false);

  ...
}      

这几个主要的 lowest level transaction 大体逻辑就描述完了,因为我们主要关注的是和clog的交互部分,所以xlog部分没有特别详细得描述。

有了这一些事务操作状态之后,我们就需要看看 PG 在一个事务内部进行的 DML操作 是如何实现可见性检查的,因为可见性检查是PG 实现不同的事务隔离级别的核心。

PG 可见性检查实现原理

可见性检查的基础单位是针对一个tuple的,也就是一个拥有transaction-id 的独立数据元组。通过结合当前元组的事务id,snapshot 以及 clog 状态来对当前元组进行可见性判断。

以 select为例,select 读取tuple的调用栈如下:

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

PortalRun 之前会先执行​

​PortalStart​

​​函数,用来获取当前事务执行时的获取snapshot,这个获取到的snapshot会在后续的 ​

​heapgetpage​

​ 读取tuple时对部分tuple的进行可见性检查判断。

void
PortalStart(Portal portal, ParamListInfo params,
      int eflags, Snapshot snapshot)
{
  ...
    switch (portal->strategy)
    {
      case PORTAL_ONE_SELECT:
       ...
        /*
             * Create QueryDesc in portal's context; for the moment, set
             * the destination to DestNone.
             */
        queryDesc = CreateQueryDesc(linitial_node(PlannedStmt, portal->stmts),
                                    portal->sourceText,
                                    GetActiveSnapshot(),
                                    InvalidSnapshot,
                                    None_Receiver,
                                    params,
                                    portal->queryEnv,
                                    0);
  ...
}      

拿到的snapshot 时通过​

​GetActiveSnapshot​

​​ 获取的,这个函数里面的全局activesnapshot 的填充则是通过 ​

​PortalStart​

​​ 之前的一个逻辑添加进来的​

​PushActiveSnapshot(GetTransactionSnapshot());​

​​,创建snapshot的核心逻辑就是 ​

​GetTransactionSnapshot()​

​函数。

先来看看在​

​heapgetpage​

​函数读取 tuple的时候是如何结合 snapshot 以及 clog 进行可见性检查的。

heapgetpage 函数,在拿到了要读的page 之后会先检查一下整个page的可见性,如果整个page并不是 ​

​PageIsAllVisible​

, 则在实际读取数据的时候需要确认每一条数据的可见性,即每一个tuple的可见性:

void
heapgetpage(TableScanDesc sscan, BlockNumber page)
{
  HeapScanDesc scan = (HeapScanDesc) sscan;
  Buffer    buffer;
  Snapshot  snapshot;
  ......
  
  
  dp = BufferGetPage(buffer);
  TestForOldSnapshot(snapshot, scan->rs_base.rs_rd, dp);
  lines = PageGetMaxOffsetNumber(dp);
  ntup = 0;
  /* 判断整个page 的可见性 */
  all_visible = PageIsAllVisible(dp) && !snapshot->takenDuringRecovery;
  /* 拿着all_visible 标识去读取每一个 tuple, 如果all_visible是false,则需要检查每一个tuple的可见性。*/
  for (lineoff = FirstOffsetNumber, lpp = PageGetItemId(dp, lineoff);
     lineoff <= lines;
     lineoff++, lpp++)
  {
    ...
      if (all_visible)
        valid = true;
      else
        /* 检查每一个tuple 的可见性。 */
        valid = HeapTupleSatisfiesVisibility(&loctup, snapshot, buffer);
    ...
  }
  ...
}      

在 ​

​HeapTupleSatisfiesVisibility​

​​函数中,因为我们的snapshot type 默认都是 ​

​SNAPSHOT_MVCC​

​​,所以会进入到 ​

​HeapTupleSatisfiesMVCC​

​。

在这个逻辑中就是根据每一个tuple的 ​

​t_xmin​

​​, ​

​t_xmax​

​​, ​

​t_ctid​

​ 等字段 进行可见性检查的逻辑判断了,如果返回为 true 则表示这个tuple 是可见的,如果返回为false,则这个tuple不可见。

看一部分的可见性判断逻辑,如下:

static bool
HeapTupleSatisfiesMVCC(HeapTuple htup, Snapshot snapshot,
             Buffer buffer)
{
  HeapTupleHeader tuple = htup->t_data;
  ......

  /* 
   * 当前tuple 的xmin 没有提交,提交的话会标记t_infomask. 
   * t_xmin 是第一个insert 数据到 当前tuple的事务id.
   */
  if (!HeapTupleHeaderXminCommitted(tuple))
  {
    /* 当前 tuple 的 xmin 是非法的,则直接返回false,即不可见。 */
    if (HeapTupleHeaderXminInvalid(tuple))
      return false;

    /* Used by pre-9.0 binary upgrades , 兼容旧版本 tuple 的一些infomask 字段。 */
    if (tuple->t_infomask & HEAP_MOVED_OFF)
    {
      /* 获取当前tuple的 transaction id. */
      TransactionId xvac = HeapTupleHeaderGetXvac(tuple);

      /* 如果当前tuple的transaction id是 用户sql正在执行的事务id,则肯定是不可见的,返回false。  */
      if (TransactionIdIsCurrentTransactionId(xvac))
        return false;
      /* 关键逻辑,查看 snapshot 中是否包含该 xvac. */
      if (!XidInMVCCSnapshot(xvac, snapshot))
      {
        /* 如果不包含(非法的事务id),则检查该事务的clog状态是否已经提交。 */
        if (TransactionIdDidCommit(xvac))
        {
          /* 如果是提交的状态,那这个xmin 就是一个非法持久化的事务,需要标记invalid. */
          SetHintBits(tuple, buffer, HEAP_XMIN_INVALID,
                InvalidTransactionId);
          return false;
        }
        
        SetHintBits(tuple, buffer, HEAP_XMIN_COMMITTED,
              InvalidTransactionId);
      }
    }
    ....
  }      

如上可见性检查的部分逻辑中,我们可以看到影响一个 tuple的可见性检查的关键字段包括 HeapTupleHeader 中的关键字段、SnapShot、CLOG。

以上这个小分支主要是判断t_xmin 的可见性,但是t_xmin并不能决定这个 tuple是可见的,只能决定这个tuple是不可见的,所以大家从以上逻辑可以看到并没有返回true 的情况,还需要一些后续的处理逻辑,比如判断是否有xmax 以及 xmax的一些状态。

  • 对于tuple。需要借用tuple 的t_xmin 字段,因为它是第一个插入这个tuple的事务id,如果这个事务id的一系列状态不满足可见的标准,那显然该tuple是不可见的。
  • 对于snapshot,对于基本概念的理解还不清楚的可以看前面单独介绍 snapshot 的小结,需要清楚那三个关键变量的作用。拿到了tuple的 transacitonid 之后先确认这个 id 是否满足 snapshot 维护的事务id的可见性,主要逻辑在 ​

    ​XidInMVCCSnapshot​

    ​​。

    该逻辑中:

    a. 如果 xvac < snapshot->xmin,则认为该 xvact 是不活跃的事务,已经提交了,直接返回false, 跳过后续的clog 的处理逻辑,继续后续的可见性检查判断。

    b. 如果xvact >= snapshot->xmax,则认为该xvact 是活跃的事务,显然是不可见的,也直接返回false,跳过clog的处理,继续后续的逻辑判断。

    c. 如果 xvact 处于 snapshot->xip 的活跃事务数组中,返回 true,则显然要么提交了,要么abort了,则都是可见的,需要后续继续判读。

    逻辑如下:

bool
XidInMVCCSnapshot(TransactionId xid, Snapshot snapshot)
{
  uint32    i;

  /*
   * Make a quick range check to eliminate most XIDs without looking at the
   * xip arrays.  Note that this is OK even if we convert a subxact XID to
   * its parent below, because a subxact with XID < xmin has surely also got
   * a parent with XID < xmin, while one with XID >= xmax must belong to a
   * parent that was not yet committed at the time of this snapshot.
   */

  /* Any xid < xmin is not in-progress */
  if (TransactionIdPrecedes(xid, snapshot->xmin))
    return false;
  /* Any xid >= xmax is in-progress */
  if (TransactionIdFollowsOrEquals(xid, snapshot->xmax))
    return true;
  ...
    /* xid 是否在snapshot的活跃事务数组中 ,在则直接返回true. */
    for (i = 0; i < snapshot->xcnt; i++)
    {
      if (TransactionIdEquals(xid, snapshot->xip[i]))
        return true;
    }
  ...
}      
  • 对于CLOG,则更为简单。判断一个 事务id 是否已经提交,通过 ​

    ​TransactionIdDidCommit​

    ​​ 中的 ​

    ​TransactionLogFetch​

    ​​函数,读取该事务id 映射的clog-page 的某一个偏移量的两个bit,从而完成该事务的事务状态提取。读取 clog page的逻辑和前面 ​

    ​CommitTransaction​

    ​ 中 提到的 写入 clog page的逻辑刚好相反。
/* 调用栈如下: */
TransactionIdDidCommit
  TransactionLogFetch
    TransactionIdGetStatus
      
/* TransactionIdGetStatus 函数实现如下 */
XidStatus
TransactionIdGetStatus(TransactionId xid, XLogRecPtr *lsn)
{
  /* 事务id 对应的clog-page 以及 page内的偏移量。 */
  int     pageno = TransactionIdToPage(xid);
  int     byteno = TransactionIdToByte(xid);
  int     bshift = TransactionIdToBIndex(xid) * CLOG_BITS_PER_XACT;
  int     slotno;
  int     lsnindex;
  char     *byteptr;
  XidStatus status;

  /* lock is acquired by SimpleLruReadPage_ReadOnly */

  /* 读取clog page 到共享内存管理的 ClogCtl->shared->page_buffer 的slot中 */
  slotno = SimpleLruReadPage_ReadOnly(ClogCtl, pageno, xid);
  byteptr = ClogCtl->shared->page_buffer[slotno] + byteno;

  /* 提取status. */
  status = (*byteptr >> bshift) & CLOG_XACT_BITMASK;

  lsnindex = GetLSNIndex(slotno, xid);
  *lsn = ClogCtl->shared->group_lsn[lsnindex];

  LWLockRelease(CLogControlLock);

  return status;
}      

到此 PG 的可见性检查基本实现链路以及 如何借用 snapshot 和 clog 进行可见性判断的过程大体就清楚了,这样大家在看相关代码的时候就会事半功倍了。

总结

到此整个 PG 的基本事务架构就描述完了,当然更多的细节比如 PG 为复制/同步同步功能 实现的 2pc 以及 围绕整个事务体系构建的 庞大的锁管理体系本节都没有提及,那是与 并发控制 相关的部分。

  • tuple 的 t_xmin, t_xmax 以及 t_ctid 是主要参与的字段。
  • clog 会先将要更新的事务状态 缓存到共享内存中的 slot中,每一个slot 可以存储 (8 *1024*8 / 2) 个事务状态,当需要扩展slot的时候 会出发上一个slot的 pg_fsync 刷盘操作,同时也有后台的 checkpointer进程定期刷脏页。
  • Snapshot 保存了当前 PG 内部的全局活跃事务。snapshot 是一个纯内存的数据结构,并不会落盘,是在有需要的时候通过 ​

    ​GetTransactionSnapshot​

    ​构造的。