数据库
首页 > 数据库> > PostgreSQL数据库事务系统——三层系统

PostgreSQL数据库事务系统——三层系统

作者:互联网

事务系统架构

PostgreSQL的事务系统是一个三层系统。底层实现了低级事务(Low-Level Transactions)和子事务(SubTransactions),在子事务和低级别事务之上是主循环的控制代码,后者又实现了用户可见的事务和保存点(SAVEPOINT)。
在这里插入图片描述
中间层的代码由postgresql .c在处理每个查询之前和之后,或者在检测到错误之后调用。同时,用户可以通过发出SQL命令BEGIN、COMMIT、ROLLBACK、SAVEPOINT、ROLLBACK TO或RELEASE来改变系统的状态。事务块的状态是通过上层函数和中层函数同时控制的,而底层函数则主要控制事务的状态。
在这里插入图片描述
事务管理器将这些调用分别重定向到顶层例程。根据系统的当前状态,这些函数调用低级函数来激活真实的事务系统。
此外,在事务中,调用CommandCounterIncrement()来增加命令计数器,这允许将来的命令在同一事务中“看到”以前命令的效果。注意,这是由CommitTransactionCommand()在事务块内的每个查询之后自动完成的,但是一些实用程序函数也在内部完成这一操作,以允许某些操作(通常在系统目录中)被同一实用程序命令中的未来操作看到。(例如,在DefineRelation()中,它是在创建堆之后完成的,因此pg_class行是可见的,以便能够锁定它)。

事务命令执行

例如,考虑以下用户命令序列:

1)  BEGIN
2)  SELECT * FROM foo
3)  INSERT INTO foo VALUES (...)
4)  COMMIT

在主处理循环中,这将导致以下函数调用序列:
(1) BEGIN ;
在这里插入图片描述
(2) SELECT * FROM foo ;
在这里插入图片描述
(3) INSERT INTO foo VALUES ( . . .) ;
在这里插入图片描述
(4) COMMIT ;
在这里插入图片描述
本示例的重点是演示StartTransactionCommand()和CommitTransactionCommand()需要是状态智能的——它们应该在对BeginTransactionBlock()和EndTransactionBlock()的调用之间调用CommandCounterIncrement(),在这些调用之外,它们需要执行正常的启动、提交或中止处理。

此外,假设“SELECT * FROM foo”导致了一个中止条件。在这种情况下,将调用AbortCurrentTransaction(),并将事务置于中止状态。在此状态下,除事务终止语句或ROLLBACK TO 命令外,将忽略任何用户的输入。

事务终止可以通过两种方式发生:
(1) 用户类型: ROLLBACK。在该情形1中,用户不喜欢他/她看到的内容,并输入了ABORT。用户希望终止事务,并返回到默认状态;
在这里插入图片描述
(2) 系统死于某些内部原因(比如语法错误等)。
在这里插入图片描述
在情形2中,可能会有更多的命令出现在我们面前,它们是同一个事务块的一部分;我们必须忽略这些命令,直到看到COMMIT或ROLLBACK。
内部中止由AbortCurrentTransaction()处理,而用户中止UserAbortTransactionBlock()处理。它们都依赖于AbortTransaction()来完成所有实际工作。唯一的区别是我们在AbortTransaction()完成它的工作后进入的状态:AbortCurrentTransaction() 将我们留在 TBLOCK_ABORT,UserAbortTransactionBlock() 将我们留在 TBLOCK_ABORT_END。

低级别事务中止处理分为两个阶段:
(1) 一旦我们意识到事务失败,就会执行AbortTransaction()。它应该释放所有共享资源(锁等),这样我们就不会不必要地延迟其他后端进程。对于后端进程,可扩展阅读 PostgreSQL数据库体系架构。
(2) 当我们最终看到用户COMMIT或ROLLBACK命令时,执行CleanupTransaction();它清理了一切,让我们完全脱离了事务。特别是,在此之前,我们不能销毁TopTransactionContext()。

另外,请注意,在提交事务时,我们不会立即关闭它。而是将其置于TBLOCK_END状态,这意味着在查询完成处理后调用CommitTransactionCommand()时,必须关闭事务。这种区别很微妙但很重要,因为这意味着控制将让xact.c代码保持事务打开状态,而主循环将能够在同一个事务中进行处理。因此,在某种意义上,事务提交也分为两个阶段处理,第一个阶段是在EndTransactionBlock(),第二个阶段是在CommitTransactionCommand()(这是实际调用CommitTransaction()的地方)。这便是所谓的"2PC(Two Phase Commit,两阶段提交)"。
xact.c中的其余代码是支持创建和完成事务和子事务的例程。例如,AtStart_Memory()负责在主事务启动时初始化内存子系统。

子事务处理

子事务是使用TransactionState结构的堆栈实现的,每个结构都有一个指向其父事务结构的指针(struct TransactionStateData *parent)。当要打开一个新的子事务时,将调用PushTransaction(),这将创建一个新的TransactionState,其父链接(*parent)指向当前事务。StartSubTransaction负责将新的TransactionState初始化为合理的值,并正确初始化其他子系统(AtSubStart例程)。

//事务状态结构
typedef struct TransactionStateData
{
 FullTransactionId fullTransactionId; /* my FullTransactionId */
 SubTransactionId subTransactionId;   /* my subxact ID */
 char    *name;                       /* savepoint name, if any */
 int   savepointLevel;                /* savepoint level */
 TransState state;                    /* low-level state */
 TBlockState blockState;              /* high-level state */
    . . . . . //省略若干结构体成员
 struct TransactionStateData *parent; /* back link to parent */
} TransactionStateData;

子事务的层次描述用一个链栈实现。栈顶元素拥有一个指向其父事务的指针。当启动一个新的子事务时,系统调用PushTransaction函数把描述该子事务的TransactionState结构变量压入栈中,这个变量可以标识该事务。相应的,PopTransactionState函数的功能是把栈顶事务弹出。PushTransaction函数为子事务创建一个TransactionState并压入事务状态堆栈中。在函数执行过程中,CurrentTransactionState会被切换到新创建的事务状态。PopTransaction函数将当前事务状态弹出堆栈,把CurrentTransactionState切换到父事务状态并转换并转换资源所有者以及事务内存上下文。

当关闭子事务时,必须调用CommitSubTransaction()(如果子事务正在提交),或者调用AbortSubTransaction()和CleanupSubTransaction()(如果它正在中止)。在这两种情况下,都会调用PopTransaction(),以便系统返回到父事务。

关于子事务处理的一个要点是,在响应单个用户命令时可能需要关闭多个子事务。这是因为保存点有名称,我们允许按名称提交或回滚保存点,而保存点不一定是上次打开的那个。另外,COMMIT或ROLLBACK命令必须能够关闭整个堆栈。我们通过让实用程序命令子例程将所有的状态堆栈项标记为commit-pending或abort-pending来处理这个问题,然后当主循环到达CommitTransactionCommand()时,真正的工作就完成了。这样做的主要目的是,如果我们在弹出状态堆栈项时出现一个错误,其余的堆栈项仍然显示我们需要完成的操作。

在ROLLBACK TO 的情况下,我们通过保存点名称标识的子事务中止所有子事务,然后使用相同的名称重新创建子事务级别。所以就内部而言,这是一个全新的子事务。

允许其他子系统启动“内部”子事务,这些子事务由BeginInternalSubTransaction()处理。这是为了允许实现异常处理,例如在PL/pgSQL中。ReleaseCurrentSubTransaction()和RollbackAndReleaseCurrentSubTransaction()允许子系统关闭这些子事务。这与SAVEPOINT/RELEASE之间的主要区别在于,我们在每个子例程中立即执行完整的状态转换,而不是将一些工作推迟到CommitTransactionCommand()。另一个区别是,当没有建立显式事务块时,允许BeginInternalSubTransaction(),而不允许DefineSavepoint()。

事务和子事务编号

只有当事务和子事务第一次执行需要XID的操作时(通常是INSERT/UPDATE/DELETE元组),才会为它们分配永久的XID,尽管也有一些其他地方需要分配XID。如果子事务需要一个XID,我们总是首先将其分配给其父事务。这保持子事务的XID晚于其父事务XID的不变性,这在许多地方都是假定的。

获取XID上的锁并将其输入pg_subtrans和PG_PROC的辅助操作在分配XID时完成。对于没有XID的事务,仍然需要根据不同的目的进行标识,尤其是持有锁。为此目的,我们为每个顶级事务分配一个“虚拟事务ID(Virtual Transaction ID)”或VXID。VXID由两个字段组成,backendID和一个后端本地计数器(Backend-Local Counter);这种安排允许在事务开始时分配新的VXID,而不需要争夺共享内存。为了确保VXID不会在后端进程退出后很快重复被使用,我们在后端进程退出时将最后一个本地计数器值存储到共享内存中,并在后端进程启动时用同一个backendID槽的前一个值初始化它。在共享内存初始化时,所有这些计数器都将归零,但这没关系,因为VXID永远不会出现在磁盘上的任何地方。

在内部,后端需要一种方法来识别子事务,无论它们是否具有XID;但这种需求只会在父顶级事务存在时持续。因此,我们有SubTransactionId,它有点像CommandId,因为它是由我们在每个顶部事务开始时重置的计数器生成的。顶级事务本身具有SubTransactionId 1,子事务具有ID 2或以上(0是InvalidSubTransactionId保留的)。注意,子事务没有自己的VXID;它们使用父顶级事务的VXID。

联锁事务开始、事务结束和快照

我们努力将开始(Beginning)/结束(Ending)事务和快照(Snapshot)的频繁活动中涉及的开销和锁争用量降至最低。不幸的是,我们必须为此进行一些联锁,因为我们必须确保事务提交顺序的一致性。例如,假设xact A中的UPDATE被xact B之前对同一行的更新阻塞,并且xact B在xact C获取快照的同时执行提交。只要B释放它的锁,Xact A就可以完成并提交。如果xact C的GetSnapshotData()看到xact B仍在运行,那么最好看到xact A仍在运行,否则它将能够看到两个元组版本 — 一个由xact B删除,一个由xact A插入。另一个不好的原因是C会看到(在A插入的行中)B先前的更改,对于C来说,在数据库的其他地方看不到B的任何变化是不一致的。

形式上,正确性要求是“如果快照A认为事务X已提交,并且事务X的任何快照都认为事务Y已提交,那么快照A必须认为事务Y已提交”。

我们实际上强制的是使用快照的提交(commits)和回滚(rollbacks)的严格串行化:我们不允许任何事务在快照时退出正在运行的事务集。(此规则比一致性所需的规则更强大,但执行起来相对简单,并有助于解决一些其他问题,如下所述) 。

此规则的实现是GetSnapshotData()以共享模式接受ProcArrayLock(以便多个后端进程可以并行地获取快照),但是ProcArrayEndTransaction()必须以独占模式接受ProcArrayLock,同时在事务结束时清除MyPgXact->xid(要么提交,要么终止)。(为了减少上下文切换,当多个事务几乎同时提交时,我们让一个后端进程使用ProcArrayLock()并同时清除多个进程的XID。)

ProcArrayEndTransaction()在推进共享的latestCompletedXid变量时也持有锁。这允许GetSnapshotData()使用latestCompletedXid + 1作为其快照的xmax:不能有事务 >= 此xid值,快照需要将其视为已完成。

简而言之,规则是,从获取latestCompletedXid到完成快照构建之间,任何事务都不能退出当前运行的事务集。但是,这个限制只适用于具有XID(只读事务)的事务可以在不获得ProcArrayLock的情况下结束,因为它们不影响任何人或者latestCompletedXid。

事务开始本身与这些注意事项没有任何关联,因为我们不再在事务开始时立即分配XID。但是当我们决定分配一个XID时,GetNewTransactionId()必须在释放XidGenLock()之前将新的XID存储到共享ProcArray()中。这确保所有顶级XID <= latestCompletedXid要么出现在ProcArray中,要么不再运行。(这种保证不适用于子事务XID,因为subxid数组中可能没有空间容纳它们;相反,我们保证它们是存在的,或者设置了溢出标志)。如果一个后端在将其XID存储到MyPgXact之前释放了XidGenLock,那么另一个后端就有可能分配和提交一个更晚的XID,导致latestCompletedXid传递第一个后端的XID,在这个值在ProcArray中变得可见之前。这将破坏GetOldestXmin,如下所述。

我们允许GetNewTransactionId()将XID存储到MyPgXact-> XID(或subxid数组)中,而不使用ProcArrayLock。这曾经是避免死锁所必需的;虽然现在已经不是这样了,但它仍然有利于性能。因此,我们依赖于XID的获取/存储来实现原子化,其他后端可能会看到部分设置的XID。这也意味着ProcArray xid字段的读者必须小心地只获取一次值,而不是假设他们可以读取多次,每次都得到相同的答案。(当这样做时,使用volatile限定的指针,以确保C编译器完全按照您的指示执行)。

另一个使用共享ProcArray的重要活动是GetOldestXmin,它必须确定系统范围内任何活动MVCC快照中最早的xmin的下限。每个后端都会在MyPgXact->xmin中公布它自己快照中最小的xmin,如果当前没有实时快照(例如,如果它在事务之间或者还没有为新事务设置快照),则为零。

GetOldestXmin()获取有效xmin字段的MIN()。它只使用ProcArrayLock上的共享锁来实现这一点,这意味着与同时执行GetSnapshotData()的其他后端存在潜在的竞争条件:我们必须确保即将设置其xmin的并发后端计算的xmin不会小于GetOldestXmin()返回的值。我们通过将所有活动的XID连同有效的xmins一起包含到MIN()计算中来确保这一点。

如果没有独占ProcArrayLock,事务就不能退出的规则确保了共享ProcArrayLock的并发持有者将计算当前活动xid的相同最小值:当我们持有共享ProcArrayLock时,没有xact,特别是不是最旧的,可以退出。因此,GetOldestXmin()的最小活动XID视图将与任何并发GetSnapshotData()的视图相同,因此它不会产生高估值。如果根本没有活动事务,GetOldestXmin()将返回latestCompletedXid + 1,这是xmin的下限,可能由并发或稍后的GetSnapshotData()调用计算。(我们知道,由于上面讨论的XidGenLock联锁,ProcArray中不会出现小于此值的XID)。

GetSnapshotData()还执行一个最老的xmin计算(最好与GetOldestXmin()的计算相匹配),并将其存储到RecentGlobalXmin中,它用于某些元组年龄截止检查,其中 GetOldestXmin() 的新调用似乎太昂贵。请注意,正如上文所述,虽然可以确定两次并发执行GetSnapshotData()将为它们自己的快照计算相同的xmin,但不确定它们是否会得出相同的RecentGlobalXmin估算值。

这是因为我们允许XID-less事务异步清除它们的 MyPgXact->xmin(不使用ProcArrayLock),因此一次执行可能会看到最早的xmin,而另一次则不会。这是可以的,因为RecentGlobalXmin只需要是一个有效的下限。如上所述,我们已经假设xid字段的fetch/store是原子的,所以假设xmin也是原子的也没有额外的风险。

pg_xact 和 pg_subtrans

pg_xact和pg_subtrans是事务相关信息的永久(磁盘上)存储。

内存中保存的每个页(page)的数量是有限的,因此在许多情况下不需要实际从磁盘读取。但是,如果有一个长时间运行的事务或后端处于空闲状态且有一个打开的事务,则可能需要能够从磁盘读取和写入该信息。它们还允许信息在服务器重新启动时保持永久性。

pg_xact记录已分配XID的每个事务的提交状态。事务可以是正在进行(Progress)、已提交(Committed)、已终止(Aborted)或“子事务提交(sub-committed)”。最后一个状态意味着它是一个不再运行的子事务,但是它的父事务还没有更新它的状态。没有必要将子事务的事务状态更新为子事务提交(sub-committed),所以我们可以把它推迟到主事务提交。将事务标记为子提交(sub-committed)的主要作用是在事务状态分散到多个阻塞页面时提供原子提交协议。

因此,每当事务状态跨多个页传播时,我们必须使用两阶段提交(Two Phase Commit, 2PC)协议:第一阶段是将子事务标记为子提交(sub-committed),然后我们将顶级事务及其所有子事务标记为已提交(按此顺序)。因此,未中止的子事务即使已经完成,也会显示为正在进行中,并且在主事务提交期间,子事务状态显示为非常短的临时状态。一旦发生子事务中止,总是在clog中标记。当事务状态都符合一个CLOG页面的要求时,我们会自动将它们都标记为已提交,而不需要操心中间的子提交(sub-commit)状态。

保存点(SAVEPOINT)是使用子事务实现的。子事务是事务中的事务;它的提交或中止状态不仅取决于它是否提交了自己,还取决于它的父事务是否已提交。为了在一个事务中实现多个保存点,我们允许无限的事务嵌套深度,因此任何特定的子事务的提交状态都依赖于每个祖先事务的提交状态。

“subtransaction parent” (pg_subtrans)机制为每个具有XID的事务记录其父事务的TransactionId。一旦为子事务分配了XID,就会存储此信息。顶级事务没有父事务,因此它们将pg_subtrans项设置为默认值零(InvalidTransactionId)。

pg_subtrans用于检查所讨论的事务是否仍在运行——事务的主Xid记录在PGXACT结构中,但由于我们允许子事务的任意嵌套,我们无法将所有Xid都放入共享内存中,因此必须将它们存储在磁盘上。

typedef struct PGXACT
{
 TransactionId xid;   /* id of top-level transaction currently being
                       * executed by this proc, if running and XID
                       * is assigned; else InvalidTransactionId */

 TransactionId xmin;   /* minimal running XID as it was when we were
                        * starting our xact, excluding LAZY VACUUM:
                        * vacuum must not remove tuples deleted by
                        * xid >= xmin ! */

 uint8  vacuumFlags;   /* vacuum-related flags, see above */
 bool  overflowed;
 uint8  nxids;
} PGXACT;

但是请注意,对于每个事务,我们都保留一个Xids的“缓存(Cache)”,这些xid是事务树的一部分,所以我们可以跳过查看pg_subtrans,除非我们知道缓存已经溢出。详情请参阅storage/ipc/procarray.c

slru.c是pg_xact和pg_subtrans的支持机制。它实现了内存缓冲页的LRU策略。pg_xact的高级例程是在transam.c中实现的,而低级函数是在clog.c中实现的。pg_subtrans完全包含在subtrans.c中。

预写式日志(Write Ahead Log)

WAL子系统(代码中也称为XLOG)的存在是为了保证崩溃恢复。它还可以用来提供时间点恢复,,以及通过日志传送进行热备份复制。以下是一些关于其设计的非明显方面的注意事项。

预写式日志(Write-Ahead Log, WAL)的一个基本假设是,日志条目必须在它们所描述的数据页更改之前到达稳定的存储。这确保了将日志重放到其末尾将使我们达到一致的状态,其中没有部分执行的事务。为了保证这一点,每个数据页(无论是堆还是索引)都标记有影响该页的最新XLOG记录的LSN(Log Sequence Number, 日志序列号)——实际上是一个WAL文件位置)。

在缓冲区管理器可以写出脏页之前,它必须确保xlog已刷新到磁盘,至少刷新到页面的LSN。这种低级交互不会等到必要时才等待XLOG I/O,从而提高了性能。LSN检查只存在于共享缓冲区管理器中,而不存在于用于临时表的本地缓冲区管理器中;因此,对临时表的操作不能被WAL-logged记录。

在WAL重建过程中,我们可以检查页面的LSN,检测当前日志项记录的变化是否已经被应用(如果页面(page)LSN大于等于日志项的WAL位置,那么它已经被应用)。

通常,日志条目包含的信息仅够在一个页面(或一小组页面)上重复一次增量更新。只有当文件系统和硬件将数据页写入实现为原子操作时,这才有效,这样页就不会处于损坏的部分写入状态。由于这在实践中往往是一个站不住脚的假设,所以我们记录额外的信息,以允许完全重建修改过的页面。

在检查点之后,影响给定页面的第一个WAL记录包含了整个页面的副本,我们通过恢复页面副本而不是重新做更新来实现重建。(这比数据存储本身更可靠,因为我们可以检查WAL记录的CRC的有效性)。我们可以注意页面的旧LSN是否先于最后一个检查点(RedoRecPtr)的WAL结束来检测“检查点后的第一次更改”。

标签:状态,事务,XID,数据库,系统,快照,提交,xact,PostgreSQL
来源: https://blog.csdn.net/asmartkiller/article/details/120298895