postgres 数据库事务 事务是数据库命令执行的基本单位,数据库通过事务实现了数据的可靠性,以及并发环境下能够正确地协同工作。

SQL命令执行流程当中可以发现SQL命令的执行过程对事务是非常依赖的,这里我们主要了解postgres是如何实现事务的ACID的,即事务的启动和提交的过程。

首先需要了解postgres中事务的设计理念来方便我们理解其实现过程。

事务系统概述

事务实现的层级

postgres的事务系统分为三层,最底层实现了底层的事务和子事务,顶层实现了主循环中的控制代码,提供了用户可见的事务与保存点。中间层的代码则是由postgres.c在每个查询执行前后调用的。 这里可以将这三层级别叫做低级、中级、顶级。 简单的来说:

  1. 低级实现了事务本身的机制
  2. 中级由postgres代码调用
  3. 顶级是用户可见的,即用户可以在编写sql的过程中对事务进行控制

中间层是在postgres.c当中调用的,每个查询处理前后,或者检测到错误

StartTransactionCommand  
CommitTransactionCommand  
AbortCurrentTransaction

在sql使用事务控制语句BEGIN, COMMIT, ROLLBACK, SAVEPOINT, ROLLBACK TO 或RELEASE会调用到对应的函数

BeginTransactionBlock  
EndTransactionBlock  
UserAbortTransactionBlock  
DefineSavepoint  
RollbackToSavepoint  
ReleaseSavepoint

依据当前事务系统的状态,这些调用最终又都会到最底层的事务系统

StartTransaction  
CommitTransaction  
AbortTransaction  
CleanupTransaction  
StartSubTransaction  
CommitSubTransaction  
AbortSubTransaction  
CleanupSubTransaction

事务的执行流程

此外在一个事务块中的每个query结束之后,还会调用CommandCounterIncrement函数增加命令计数器,使得同一事务当中后面的命令可以看到前面命令的执行结果,也就是同一事务内部的变更是可见的。

例如,有按照下面顺序执行的用户命令

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

在后端的循环的处理逻辑当中,就变为了

     /  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;

这个示例其实说明了StartTransactionCommandCommitTransactionCommand这两个中间层的函数是比较智能的,能够根据系统的状态来决定执行的逻辑。比如这里

  • 已经开启事务块之后,StartTransactionCommand后面调用不会再重复启动事务
  • 在事务块结束之前,CommitTransactionCommand只会增加命令计数,而在事务块结束之后,才会实际提交事务。

事务中止

命令执行出错就会调用AbortCurrentTransaction进入abort状态,在这个状态所有的用户输入都会被忽略,只有事务终止的命令和rollback to的命令会执行。

事务中止发生的场景有两个:

  1. 用户主动中止事务
  2. 发生了内部系统错误 内部错误导致的中止使用AbortCurrentTransaction处理,用户中止使用UserAbortTransactionBlock处理,这两个函数的实际实现都是调用AbortTransaction完成的,只不过最后设置的事务状态不同。
  • AbortCurrentTransaction设置为TBLOCK_ABORT
  • UserAbortTransactionBlock设置为TBLOCK_ABORT_END

低级事务的中止处理分为两阶段

  1. AbortTransaction:释放共享资源,避免阻塞其它的backend
  2. CleanupTransaction:commit或者rollback执行调用,完全从事务当中脱离出来。在这个调用点之前,必须保证TopTransactionContext不会被销毁。

事务提交

事务进行提交之后也不是立即就会关闭的。相反,会将事务块设置为TBLOCK_END的状态,在查询处理结束之后调用CommitTransactionCommand时,事务才会被关闭。 因此事务的提交也可以认为是两阶段的

  1. EndTransactionBlock:事务状态变更为TBLOCK_END
  2. CommitTransactionCommand:经过前一步变更为上面的状态之后,会实际调用CommitTransaction完成事务的关闭。

子事务处理

savepoint

子事务的场景出现在一个事务当中使用savepoint机制的时候,每一个savepoint都可以看作是一个子事务。

子事务在实现上是一个TransactionState结构体的栈,这个结构体当中存在一个指向其父事务的指针从而维持了事务之间的父子关系。

  • 子事务的开启:调用PushTransaction函数,会创建一个新的TransactionState实例,然后父事务指针指向当前事务
  • 子事务的启动:StartSubTransaction负责初始化TransactionState的值和相关的子系统。
  • 子事务的关闭:调用CommitSubTransaction提交子事务,或者AbortSubTransactionCleanupSubTransaction中止子事务。无论哪种,都会调用PopTransaction最终回到所属的父事务当中的。

关于子事务处理比较重要的一点是,为了响应单个用户命令,可能需要关闭多个子事务。这是因为savepoints拥有名称,postgres允许使用commit或rollback到指定名称的savepoint,这个savepoint并不一定是最后打开的savepoint。commit和rollback需要具有关闭子事务栈的能力,通过实用工具例程将整个子事务状态栈都标记为commit-pending和abort-pending,这样在回到主循环的CommitTransactionCommand时,就会完成真正的工作,这样做的目的在于,所有的子事务的状态都已经标记,就算在从状态栈当中弹出条目时出现错误,也不会影响剩余的条目该怎么处理。

ROLLBACK TO <savepoint>的情况下,我们中止所有通过了指定的名称的savepoint的子事务,然后用相同的名称重新创建该子事务级别,也就是在savepoint a包括a之后的子事务都会被中止,然后再重新创建一个名为a的子事务。就内部而言,这是一个全新的子事务。

内部子事务

在其它子系统当中也允许使用BeginInternalSubTransaction来开启内部子事务,这是为了异常处理,例如PL/pgSQL,通过内部子事务,使用PL/pgSQL编写的存储过程执行出错的话也可以在主事务的异常处理程序中独立提交或者回滚而不会影响到外部的事务。这种内部子事务的机制允许在异常处理过程中对部分操作进行回滚,同时保持其他操作的结果ReleaseCurrentSubTransactionRollbackAndReleaseCurrentSubTransaction用于关闭上面的事务。

内部子事务与savapoint的主要不同点在于内部子事务在于子例程中立即执行完整的状态转换,而不是将一些工作推迟到CommitTransactionCommand。另一个不同在于DefineSavepoint需要在用户执行了BEGIN显式建立了事务块之后才能调用,而BeginInternalSubTransaction则没有这个限制。

事务和子事务编号

事务和子事务只有在它们第一次执行需要永久XID的操作时才会被分配永久XID——通常是插入/更新/删除记录,尽管还有其他一些地方需要分配XID。 子事务需要xid的时候,总是先确保其父事务分配了xid,保证父事务的xid总是早于它的子事务的。

INFO

XID 是在事务执行期间的第一次读取或修改数据库时动态分配的。这是为了避免为不实际需要XID 的事务分配标识符,从而节省系统资源。

获取XID上的锁并将其输入pg_subtransPGPROC等辅助操作在分配XID时完成。

没有XID的事务在很多场景下仍然需要具有标识的能力,特别是持有锁的场景。为此,每个顶级事务都会分配一个虚拟事务id,简称为VXID。vxid由两个字段组成:backend id和backend局部计数器;这种安排允许在事务开始时分配新的VXID,而不会对共享内存产生任何竞态导致的并发问题。为了确保backend退出后VXID不会很快被重用,我们将最后一个backend局部计数器值在退出的时候存储在backend共享内存中。并且如果又使用相同的backend id启动了backend的话,就会使用前面保存的这个值作为新的backend局部计数器的初始值。当然共享内存初始化的话这个值也会初始化为0,但是没有关系,vxid这个值只是运行时的概念,并不会保存到磁盘当中。

子事务同样需要在没有XID的情况下的标识能力,不过这个要求只持续到父事务结束。因此有了SubTransactionId字段,这个字段也是基于backend的一个计数器生成的,这个计数器会在每次backend 的顶级事务启动的时候重置为0。顶级事务自己的SubTransactionId值为1,其子事务的id从2开始,0保留为InvalidSubTransactionId。

联锁事务的启动、结束和快照

postgres尽最大的努力将启动、结束事务,生成一个快照这样频繁的操作涉及的开销和锁的竞争降到最小。但是,为了确保事务提交顺序的一致性,必需使用联锁。

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

不允许任何事务生成快照的时候退出running事务集合。这个实现有助于GetSnapshotData以共享模式获取ProcArrayLock(即多个backend并发获取快照),但是ProcArrayEndTransaction必需独占模式获取ProcArrayLock并清除ProcGlobal->xids[]条目的场景。为了减少上下文的切换,让一个backend获取ProcArrayLock并一次清除多个进程的xid。

ProcArrayEndTransaction在推进共享的latestCompletedXid的值的时候也会持有锁。GetSnapshotData会使用latestCompletedXid+1作为生成快照当中的xmax,对于事务id>=xmax的直接认为不可见(快照在创建的时候还不存在的事务)。

总是就是,在获取latestCompletedXid到构建快照完成之间,没有事务可以退出当前运行事务集合,不过只对具有XID的事务有效。只读事务可以在不获取ProcArrayLock锁的情况下退出,因为它们不会影响其它快照或者latestCompletedXid。

事务在启动的时候不会立即分配XID,不过决定申请XID之后,GetNewTransactionId就需要在释放XidGenLock锁之前,将新生成的XID存储到共享的ProcArray当中。这可确保所有顶级 XID latestCompletedXid 的要么存在于 ProcArray 中,要么不再运行。 我们允许 GetNewTransactionId 将 XID 存储到 ProcGlobal->xids[] (或 subxid 数组) 中,而无需获取 ProcArrayLock锁。这曾经是避免死锁的必要条件;虽然现在不再是这种情况,但它仍然对性能有益。因此,我们依赖于 XID 的 fetchstore 是原子的,否则其他backend可能会看到部分设置的 XID。这也意味着 ProcArray xid 字段的读取者必须小心地只获取一次值,而不是假设他们可以多次读取该值并每次都获得相同的答案。(执行此操作时,请使用限定易失性指针,以确保 C 编译器完全按照您的指示执行操作

共享 ProcArray 的另一个重要点是ComputeXidHorizons,它必须确定系统范围内任何活动 MVCC 快照的最旧 xmin 的下限。每个backend在 MyProc->xmin 中公布其自己的快照的最小 xmin,如果当前没有实时快照(例如,如果它在事务之间或尚未为新事务设置快照),则为零。ComputeXidHorizons 对这些xmin字段进行MIN运算取得最小值)。它仅通过 ProcArrayLock 上的共享锁来实现此目的,这意味着与其他并发执行 GetSnapshotData 的后端存在潜在的竞争条件:我们必须确定即将设置其 xmin 的并发backend计算的 xmin 不会小于 ComputeXidHorizons 确定的 xmin。我们通过取所有活跃的 XID 以及有效的 xmin中的最小值。在不采用独占 ProcArrayLock 的情况下,事务无法退出的规则确保共享 ProcArrayLock 的并发持有者将计算当前活动的 XID 的最小值:当我们持有共享 ProcArrayLock 时,没有 xact(尤其是最旧的 xact)可以退出。。因此,ComputeXidHorizons 的最小活跃XID 视图将与任何并发 GetSnapshotData 的视图相同,因此它不会产生高估。如果根本没有活跃事务,ComputeXidHorizons 将使用 latestCompletedXid + 1,这是 xmin 的下限,可能由并发或更高版本的 GetSnapshotData 调用计算。(我们知道,由于上面讨论的 XidGenLock 互锁,不会出现小于此值的 XID。

由于 GetSnapshotData 对性能至关重要,因此它不会执行准确的 oldest-xmin 计算(直到 v14 之前都是这样)。快照的内容仅取决于其他后端的 xid,而不依赖于它们的 xmin。由于后端的 xmin 更改频率比其 xid 高得多,因此让 GetSnapshotData 查看 xmins 可能会导致许多不必要的操作。相反,GetSnapshotData 会更新近似阈值(一个保证可以删除所有早于它的已删除行,另一个确定已删除的行比它更新的行不能删除)。GlobalVisTest 使用这些阈值做出不可见决策,并在必要时回退到 ComputeXidHorizons

pg_xact 和 pg_subtrans

这两项和事务的磁盘持久化信息有关。通常来说事务的信息从内存当中读取就够了,但是对于运行时间长的事务,需要持久化其信息到磁盘上,避免事务在长时间运行期间数据库崩溃,导致无法恢复到原来的状态。

pg_xact记录每个分配了XID的事务的提交状态。一个事务的状态可以为InProgress, Committed, Aborted, sub-committed。最后一个状态表示一个子事务已经结束运行,但是其父事务还没有更新其状态。 将事务标记为sub-committed的主要作用是在事务状态分布在多个CLOG(Commit Log)页面时提供原子提交协议。每当事务状态分布在多个页面上时,我们必须使用两阶段提交协议:第一阶段是将子事务标记为sub-committed,然后我们标记顶级事务及其所有子事务(按此顺序)为committed。因此,未中止的子事务即使已经完成也显示为正在进行,并且sub-committed状态在主事务提交期间显示为非常短的过渡状态。子事务中止在发生后会立即记录在 clog。当事务状态都适合单个 CLOG 页面时,我们以原子方式将它们全部标记为已提交,而不必再考虑中间的sub-committed状态。

savepoint是使用子事务实现的,子事务就是事务当中的事务,它的commit或者abort状态 不仅取决于它自身提交与否还取决于其事务是否提交。为了实现多savepoint的能力,允许事务的潜逃深度为无限,因此一个指定的子事务的提交状态取决于其每个祖先事务

pg_subtrans 机制为每个具有 XID 的事务记录其父事务的 TransactionId。一旦为子事务分配了 XID,就会存储此信息。顶级事务没有父级,因此它们将其pg_subtrans条目设置为默认值零 (InvalidTransactionId)。 pg_subtrans用于检查有问题的事务是否仍在运行,---事务的主 Xid 记录在 ProcGlobal->xids[] 中,副本记录在 PGPROC->xid 中,但由于我们允许子事务的任意嵌套,我们无法将所有 Xid 放入共享内存中,因此我们必须将它们存储在磁盘上。但是请注意,对于每个事务,我们都会使用一个已知是事务树一部分当作 Xid 的“缓存”,这样就可以直接从缓存当中获取信息而不用查询存储pg_subtrans,除非我们知道缓存已溢出。更详细的内部可以了解storage/ipc/procarray.c

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

WAL系统

write-ahead log子系统(在代码中也称为 XLOG)的存在是为了保证崩溃恢复。它还可用于提供时间点恢复,以及通过日志传送进行热备用复制。 其中WAL也是事务原子性的重要保障。这一块的内容也是相当丰富,在WAL子系统当中详细说明。

在这里我们就只需要了解事务成功提交,那么就会保证这个事务整个的状态会被持久化。