数据库内核:PostgreSQL 事务

事务处理

事务简介

事务是一个应用层面的操作 ,通过一系列的数据库操作执行。一个事务会影响数据库的状态。

事务对数据库的影响

对于事务来说,会有很多限制,这些限制实质上是保障整个数据库的状态是合乎标准的,但是在执行事务期间,可能会有一部分限制被打破,但是如果该事务是合法的,那么在完成或放弃当前事务之后,数据库仍会处于一个合理的状态。

事务处理架构

事务状态

事务状态

当一个事务被提交(commit)时,那么数据库所有的改变都会被永久保存。但是在刚刚提交时,只是对部份进行了提交,只有当更新的数据被刷新到磁盘上后,整个事务才算提交完成。

当一个事务终止(abort)时,此时数据库所有的改变都不会被保存,因此需要将改变回滚,当所有的改变被回滚之后,这个事务才算终止完成。

ACID 特性

在实际情况中,一个数据库会被多个用户同时使用,这就涉及并行事务。为了确保并行事务不会出现问题,以及提高吞吐量,数据库需要满足 ACID 特性:

  • 原子性(Atomic):通过提交和终止机制得以保障的,提交会保证一切改变都被保留,而终止则会撤销所有已经做出的改变。事务是一个原子操作单元,要么全部执行成功,要么全部失败回滚。
  • 一致性(Consistent):要求事务在开始和结束时,数据库必须满足所有定义的规则和约束条件,以保持数据的一致性。
  • 隔离性(Isolated):每个事务的执行都应该与其他事务隔离开来,互不干扰。并发执行的多个事务之间应该相互隔离,以防止数据损坏或不一致。由并发控制机制来实现,例如 lock-based,timestamp-based,check-based 等等。还可以进行各种级别的隔离(例如可序列化)来实现。
  • 持久性(Durable):一旦事务提交成功,其所做的修改将永久保存在数据库中,即使在系统故障或重启之后也能恢复。通过实现稳定存储处理,比如使用 redundancy 处理硬件崩溃,logging/checkpoint 以恢复状态。

事务处理就是为了 ACID 特性进行的研究。一致性就是单纯表明一个事务是正确的。也就是说,该事务执行之后,数据库仍应处于合法状态。这一特性需要开发者来进行保障,所以数据库管理系统的设计只需要关注其余三个属性。

事务相关的术语

关于描述事务影响的术语:

  • READ:将数据从“磁盘”传输到内存
  • WRITE:将数据从内存传输到“磁盘”
  • ABORT:失败时终止事务
  • COMMIT:成功时终止事务

上面这些操作与 SQL 之间的关系为:

  • Select 操作会对数据库产生读操作
  • UpdateDelete 操作会产生读和写操作
  • Insert 操作会产生写操作
  • Begin 开始一个事务,这个与 PLpgSQL 中的 begin 关键词不同
  • Commit 提交并结束当前事务,一些数据库管理系统,例如 PostgreSQL 还提供 End 作为同义词,这个 End 与 PLpgSQL 中的 end 关键词不同
  • Rollback 终止当前事务,不做任何改变,一些数据库管理系统,例如 PostgreSQL 还提供 ABORT 作为同义词

在 PostgreSQL 中,事务不能在函数内部定义。READ, WRITE, ABORT, COMMIT 操作出现在一些事务 \(T\) 的背景下,涉及对 X、Y 等数据项的操作(读和写)。这些操作通常表示为:

  • \(R_T(X)\):表示在事务 \(T\) 中读对象 \(X\)
  • \(W_T(X)\):表示在事务 \(T\) 中写对象 \(X\)
  • \(A_T\):表示终止事务 \(T\)
  • \(C_T\):表示提交事务 \(T\)

Schedule

一个 Schedule 会在事务数量大于 1 时,给出一个操作序列。一般来说会有两种类型:

  • 连续时间表(Serial schedule)\(T_i\) 的所有操作必须在 \(T_{i+1}\) 开始之前结束,它能保证数据库的一致性。因为每个 \(T_i\) 必须在 \(T_{i+1}\) 之前提交。在\(T_i\) 之前,数据库是一致性的,在 \(T_i\) 之后,数据库依旧是 一致性的,所以在 \(T_{i+1}\) 之前,数据库是一致性的。

连续时间表例子

  • 并发时间表(Concurrent schedule):每个 \(T_i\) 的操作时相互交错的,所以可能会产生一个不一致的数据库。因此,只有在所有事务成功完成之后,才能保持数据库一致性。

并发时间表例子

事务异常

并行事务会出现以下异常:

  • 脏读(dirty read):一个事务读取了另一个事务未提交的数据。当一个事务修改了某个数据,但还没有提交时,另一个事务读取了这个未提交的数据。如果修改事务回滚,则读取事务读取的数据就是无效的或错误的。
  • 不可重复读(nonrepeateable read):在同一个事务中,多次读取同一数据,但得到的结果不一致。这是因为在多次读取之间,其他事务可能修改了该数据。
  • 幻读(phantom read):幻读是指在同一个事务中,多次执行相同的查询,但得到的结果集不一致。幻读主要发生在并发事务中涉及范围查询或插入删除操作的情况下。

不可重复读与幻读之间的区别是他们的影响范围:不可重复读主要关注数据的修改操作,而幻读主要关注数据的插入或删除操作。可以认为,不可重复读是读到错误的数据项,而幻读是读到错误的结果集。

练习

考虑下面这个事务,以伪代码的形式展示:

// 关系表 Accounts(id,owner,balance,...)
transfer(src id, dest id, amount int){
    // R(X)
    select balance from Accounts where id = src; 
  	if (balance >= amount) {
        // R(X),W(X)
        update Accounts set balance = balance-amount where id = src;
        // R(Y),W(Y)
        update Accounts set balance = balance+amount where id = dest;
    }
}

如果此帐户同时发生了两次转账,即当前对该账户有两个事务在进行操作。给出一个会发生脏读的时间表。

\(R_{T_1}(X),R_{T_2}(X),R_{T_2}(X),W_{T_2}(X),R_{T_2}(Y),W_{T_2}(Y),R_{T_1}(X),W_{T_1}(X),R_{T_1}(Y),W_{T_1}(Y)\)

当事务 2 执行转账操作后,事务 1 读取了未提交的数据,即脏读发生了。

Schedule 的属性

如果一个对于一系列事务的并发时间表和对于这些事务的一个连续时间表效果完全相同,称该 schedule 是可序列化的 (serializable)。
可序列性是 schedule 的其中一个属性,主要与 Isolation 相关,其他的属性则与事务失败后如何进行恢复相关。一个可序列 schedule 能够消除所有的更新异常,但是也会降低系统的整体吞吐量。

事务终止可能会造成其他问题。下图是一个 schedule,共有 2 个事务,且都是连续时间表 :

事务终止例子1

终止会导致回滚,现在我们来考虑 3 个回滚可能发生的时间点:

事务终止例子2

  • 第一种情况:在时间点 [1] 发生了回滚,在事务 2 开始之前。因此,不会产生问题。
  • 第二种情况:在时间点 [2] 发生了回滚,此时事务 2 已经读取数据 X,但是这个数据 X 本来应该要回滚。因此,会产生问题。
  • 第三种情况:在时间点 [3] 发生了回滚,此时事务 2 已经进行了提交操作,一旦进行回滚,那么事务 2 进行的所有操作都会被回滚。

现在看一个可序列化的时间表,其中 Y 的最终值取决于 X 值:

事务终止例子3

这个时间表之所以是可序列化的,因为 \(T_2\) 的所有读写操作都是在 \(T_1\) 开始之前进行的。对于该时间表,X 的最终值应当是 \(T_1\)/\(T_2\) 开始之前的样子,因为 \(T_2\) 进行了回滚。而 \(T_1\) 读取且使用了一个最终会回滚的 X 值。这里尽管 \(T_2\) 被正确终止了,但是仍对数据库造成了影响。因此,可序列化的时间表也可能会产生 invalid state。而可恢复的时间表就可以避免这些问题。

为了获得可恢复的时间表,需要额外的约束。比如所有 \(T_1\) 写入的,被 \(T_2\) 使用的数据,必须在 \(T_2\) 提交之前进行提交。注意,可恢复性并不能阻止“脏读”。为了在存在脏读和中止的情况下使时间表可恢复,可能需要中止多个事务。在上面这个可序列化的时间表中,如果要将其变为可恢复的时间表,那么就需要延迟提交 \(T_1\),直到 \(T_2\) 提交才行。而如果 \(T_2\) 终止了,那么 \(T_1\) 也就不需要提交了。

事务终止例子4

这就是级联中止(cascading aborts)/级联回滚(cascading rollback)

级联回滚的例子:

级联回滚

\(T_3\) 回滚导致了 \(T_2\) 回滚,\(T_2\) 回滚导致了 \(T_1\) 回滚。这是因为 \(T_3\) 写入的数据 X 被 \(T_2\) 使用,\(T_2\) 写入的数据 Y 被 \(T_1\) 使用,即使 \(T_1\)\(T_3\) 没有直接关系。这类问题可能会影响很多并发的事务,也会严重影响系统的吞吐量。所以可以使用以下方法来避免级联中止。

  • 事务只能读已经被提交的(事务写入的数据)。 这样可以大大消除脏读的概率,但也会降低并发的概率,这被称为 ACR (Avoid Cascading Rollback) 时间表。

Strictness

Strict Schedules 可以降低写入脏数据的可能性。如果一个时间表是 Strict,那么:

  • 任何事务都不能读取其他未提交的事务写入的数据
  • 任何事务都不能写入其他未提交的事务写入的数据

严格的时间表简化了中止后回滚的任务。以下是一个非严格的时间表例子:

非严格时间表例子

\(T_1\) 终止时,不能进行回滚,因为需要保持 \(T_2\) 的写入。当 \(T_2\) 终止时,需要将数据库的状态回滚到 \(T_1\) 之前的状态。以下是不同类型的时间表之间的关系:

时间表类型之间的关系

时间表应该是可序列化和严格的,但更加可序列化或更严格,会降低并发。因此,数据库管理系统允许用户平衡安全与性能之间的关系。

事务隔离

Isolation

最简单的隔离形式为顺序执行,但是顺序执行的问题在于会造成比较差的吞吐量。并发控制方案(Concurrency control schemes, CCS)其目的是为了保证 “安全” 的并发操作。数据库管理系统中的并发机制的抽象图如下图所示:

并发机制的抽象图

Transaction Manager 会运行事务请求。Scheduler 会制定改用什么样的顺序执行这些事务 。最终,所有的数据都会与 Buffer Pool 关联,比如读就是从 Buffer Pool 读取数据,写就是将数据写入 Buffer Pool。

Serializability

考虑两个时间表 \(S_1\)\(S_2\),这两个时间表并发执行一批相同的事务。如果两个时间表执行结束后,数据库的状态相同,那么就可以说这两个时间表是等价的。如果一个时间表 \(S_1\) 与一个连续时间表等价,那么时间表 \(S_1\) 就是可序列化的时间表。

Serializability 有 2 种形式:

  • 冲突可串行性(conflict serializibility):关注的是事务之间的冲突关系,即是否存在竞争关系的操作。如果一个并发调度可以通过交换和重排序操作转换为某个串行执行序列,并且保持事务之间的冲突关系不变,那么该并发调度就是冲突可串行的。可以使用程序图来进行检查,如果图中没有闭环,那么就表明是可序列化的。
  • 视图可串行性(view serializibility):关注的是事务读取数据的一致性。如果一个并发调度可以通过交换和重排序操作转换为某个串行执行序列,并且对于每个事务的读写操作,其在并发调度中的执行结果与在某个串行执行中的结果一致,那么该并发调度就是视图可串行的。

view serializibility 的严格性比 conflict serializibility 弱。因此一个时间表可能是 view serializibility 但不一定是 conflict serializibility。

以下是判断 conflict serializibility 的伪代码(就是检查有没有形成环):

make a graph with just nodes, one for each Ti	// 根据并发调度中的事务集合创建一个图,每个事务对应图中的一个节点
for each pair of operations across transactions {	// 对于每对事务之间的操作,我们检查它们是否存在冲突操作
    if (Ti and Tj have conflicting ops on variable X) { // 如果事务Ti和Tj对于同一个变量X有冲突操作
      	put a directed edge between Ti and Tj	// 在图中添加一个从执行读取操作的事务到执行写入操作的事务的有向边
        where the direction goes from
        first tx to access X to second tx to access X
        if this new edge forms a cycle in the graph	// 检查图中是否存在循环
            return ”Not conflict serializable”	// 存在事务之间的循环依赖关系
    }
}
return ”Conflict serializable”

以下是判断 view serializibility 的伪代码:

// TC,i 表示并发事务中第i个事务
for each serial schedule S {	// 对于每个可能的串行调度S
    // TS,i 连续时间表中第i个事务
    for each shared variable X {	// 对于每个共享变量X
        if TC,i reads same version of X as TS,i	// 检查并发调度中的事务TC,i和串行调度中的事务TS,i是否读取了X的相同版本
          (either initial value or value written by Tj )	// 如果TC,i读取的是X的初始值或者由另一个事务Tj写入的值,则继续检查下一个变量
          continue
        else	// 如果TC,i读取了不同版本的X,即与TS,i读取的版本不一致,则说明并发调度不是视图可串行的,我们放弃这个串行调度
          give up on this serial schedule
        if TC,i and TS,i write the final version of X	// 检查TC,i和TS,i是否都写入了X的最终版本
          continue
        else	// 它们在写入X上存在冲突
          give up on this serial schedule
    }
    return "View serializable"
}
return "Not view serializable"

隔离级别

SQL 程序员的并发控制机制可以将事务设置为

  • 只读:所有操作都是读操作,此时绝对不会有任何冲突。
  • 读写:一旦引入写操作,那么就会有潜在的风险。

隔离级别如何影响并发冲突:

隔离级别如何影响并发冲突

  • 读未提交在 PostgreSQL 中不可用。

  • 读已提交是默认隔离级别。 读已提交最适合大多数场景,因为它可以防止脏读,同时提供良好的性能。 在读已提交的隔离级别下,有可能发生不可重复读和幻读,但这些只有在有多个 SELECT 语句时才会发生。

  • 可重复读与读已提交不同,因为即使一个事务更新了两个 SELECT 语句之间的行,另一个事务中的多个 SELECT 语句也会看到相同的结果。 如果另一个事务插入了新行,则这些行不会出现在第二个 SELECT 语句的结果中。

  • 可序列化隔离级别提供最高级别的事务隔离,其执行方式就像不同的事务在串行中运行一样,即一个接一个地运行。 可序列化隔离级别的缺点是,如果一个事务正在执行更新,则它更有可能被其他事务阻止并且必须等到这些事务完成,这将影响性能。

在 PostgreSQL 中为 4 个级别的隔离都提供了语法。只是将读未提交视为读已提交,可重复读作为可序列化,默认等级为读已提交。读未提交主要是因为 MVCC 机制,所以无法实现。

在 PostgreSQL 中事务由一系列的 SQL Statements 组成:

SQL声明

隔离等级会影响数据库提供给每个 \(S_i\) 的视图:

  • 读已提交:每个 \(S_i\)\(S_i\) 的开头都能看到数据库的快照
  • 可重复读与可序列化: 每个 \(S_i\) 在事务的开头都能看到数据库的快照,而可序列化会检查额外的情况。

如果系统检测到对隔离等级的违反,那么事务就失败。

可重复读 VS 可序列化的例子

一个表 R(class, value) 包含 (1,10) (1,20) (2,100) (2,200) 这些信息。有两个事务:

  • 事务一 \(T_1\)X = sum(value) where class=1; insert R(2,X); commit
  • 事务二 \(T_2\)X = sum(value) where class=2; insert R(1,X); commit

如果是可重复读,那么两个事务都会提交,结果为:

(1,10) (1,20) (2,100) (2,200) (1,300) (2,30)

如果是可序列化,那么某个事务就必须在另一个事务已经提交之后才能执行:

  • 如果是先执行事务一,结果为 (1,10) (1,20) (2,100) (2,200) (2,30) (1,330)
  • 如果是先执行事务二,结果为 (1,10) (1,20) (2,100) (2,200) (1,300) (2,330)

PostgreSQL 认识到,同时提交两者都会违反序列化。

并发控制

并发控制简介

并发控制的方法有:

  • 基于锁:通过数据库中与锁相关的部份来同步事务执行。
  • 基于版本:多版本并发控制(multi-version concurrency control,MVCC),允许存在多个一致版本的数据,每个事务只能访问事务开始时存在的版本数据。
  • 基于验证:乐观的并发控制,执行所有事务,然后检查提交时的有效性问题。
  • 基于时间戳:通过分配给操作的时间戳组织事务执行。

基于锁的并发控制

在数据库管理系统中,锁带来了额外的机制,锁管理器通过时间表来管理锁。

锁的并发控制

上图中的锁表包括:

  • 锁的对象:对象可以是数据库、表、元组、数据项
  • 锁的种类:读锁、写锁
  • FIFO 队列:用于存储请求该锁的事务
  • 当前持有此锁的事务的数量(写锁 最多为 1)

锁定和解锁操作必须是原子的。如果一个事务拥有一个读锁,并且只有它一个事务拥有此锁,那么该锁就可以转化为写锁。

基于锁的并发控制需要读/写锁来进行并发控制,它有以下规则:

  • 在读 X 之前,需要获取一个对 X 的读锁(共享锁)
  • 在写 X 之前,需要获取一个对 X 的写锁(独享锁)
  • 如果有其他事务已经获得了对于 X 的写锁,那么另一个事务想要获取对 X 的读锁的话就需要等待
  • 如果有其他事务已经获得了对于 X 的任意一种锁, 那么另一个事务想要获取对 X 的写锁的话就需要等待

仅凭以上这些规则并不能保证可序列化。下面有一个使用锁的例子(\(L_r\) 为读锁,\(L_w\) 为写锁,\(U\) 为解锁):

使用锁的例子

这里的 \(a,b\) 表示两个时刻。在读 X, Y 之前,都先获取了对应的读锁,当读取完之后,进行了解锁。值得注意的是,\(T_2\)\(T_1\) 对 Y 解锁之前就先对 Y 请求了写锁,这个时候 \(T_2\) 就需要进行等待,直到 \(T_1\) 解锁之后,才能拿到这个写锁。

为了保证可序列化,需要一个额外的约束。在每个事务中,所有对锁的请求都必须先于所有的解锁请求。因此,每个事务都可以划分为以下几个阶段:

  • Growing Phase:获得锁的成长阶段
  • Action Phase:进行 “实际工作” 的行动阶段
  • Shrinking Phase:释放锁的收缩阶段

合适的进行锁可以保障可序列化,但是也会带来一些潜在的问题:

  • Deadlock:任何一个事务都不可以执行,每个事务都在等待另一个事务持有的某个锁,所有事务都相互等待。
  • Starvation:一个事务一直在等某个锁,但是一直等不到,导致这个事务一直无法执行。
  • Reduced performance:在等待释放锁时,锁定会带来延迟。

死锁

当两个事务都在等待另一个事务持有的某个数据的锁时,就会发生死锁。以下是一个死锁的例子:

死锁的例子

首先,事务 \(T_1\) 获得了数据 A 的写锁,接着事务 \(T_2\) 获得了数据 B 地写锁,然后事务 \(T_1\) 请求数据 B 的写锁,但是数据 B 的写锁在事务 \(T_2\) 那里,还没有释放,所以事务 \(T_1\) 就开始等待。而事务 \(T_2\) 在释放数据 B 的写锁之前,请求数据 A 的写锁,而事务 \(T_1\) 正在等待数据 B 的写锁,没空释放数据 A 地写锁,然后两个事务就相互等待了。

对死锁的问题有两类解决方案:

  • 预防死锁发生
  • 放任其发生,然后检测死锁,如果有就提供恢复手段

对死锁的处理会涉及事务的回退 (Back Off),因此,选择进度最少的那个事务进行回滚,不必进行完全回滚,可以只把出问题的部分进行回滚,还要预防饥饿现象的出现。

处理死锁的方法:

  • 超时(timeout):给每个事务设置超时时间
  • 等待图(waits-for graph):图中的节点表示事务,边表示为事务之间的等待关系。一旦图中出现闭环,就终止事务 \(T_i\)
  • 时间戳(timestamps):以事务的开始时间来决定优先级。(下面考虑 \(T_i\) 试图从 \(T_j\) 等来一个锁)
    • wait-die:如果 \(T_i\) 的时间戳小于 \(T_j\) ,则 \(T_i\) 等待,否则,\(T_i\) 直接终止
    • wound-die\(T_i\) 的时间戳小于 \(T_j\)\(T_j\) 回滚,否则,\(T_i\) 等待
    • wait-diewound-wait 都是公平的,wait-die 会回滚操作更少的那个事务,wound-die 可能会回滚已经做了重要工作的事务。

时间戳比等待图更容易实现。

乐观并发控制(Optimistic Concurrency Control,OCC

锁是并发控制的一种悲观方法,因为它为了确保不会发生冲突,限制了并发。因此,产生了相应的额外成本,例如锁管理机制和死锁处理方法。

在读操作远远多于写操作的场景下(乐观并发控制就是实现这一目标的策略):

  • 不使用锁,允许任意操作交错进行
  • 提交前检查是否未发生冲突
  • 如果有问题,回滚出现冲突的事务

乐观并发控制有三个阶段:

  • Reading:从数据库读取,修改数据的本地副本
  • Validation:查看更新中是否有冲突
  • Writing:将数据的本地副本提交到数据库

时间戳在 S、V、F 点处记录:

OCC

验证所需的数据结构:

  • S:读取数据并且进行了计算的一组事务
  • V:一组已通过验证但未提交的事务
  • F:一组已完成且已经提交的事务
  • \(RS(T_i)\)\(T_i\) 读取的所有数据项
  • \(WS(T_i)\)\(T_i\) 写入的所有数据项
  • \(V(T_i)\):使用 \(V\) 时间戳作为事务的排序

以下是两个事务的例子:允许事务 \(T_1\)\(T_2\) 不使用锁,检查 \(T_2\) 使用的对象有没有被 \(T_1\) 改变,除非 \(T_1\) 已经结束,如果有,需要回滚 \(T_2\) 并且重试。

  • 第一种情况:顺序执行,没有问题

OCC例子1

  • 第二种情况:一个事务的读阶段与另一个事务的验证或写阶段有重叠。\(T_2\) 开始的时候,\(T_1\) 还在验证或写阶段。如果有一些被 \(T_2\) 读取的对象 X 在 \(WS(T_1)\) 中,那么 \(T_2\) 或许没有读到被 \(T_1\) 修改后的版本,那么 \(T_2\) 需要重试。

OCC例子2

  • 第三种情况:一个事务的读或验证阶段与另一个事务的验证或写阶段有重叠。\(T_2\) 开始验证的时候,\(T_1\) 还在验证或写阶段。如果有一些被 \(T_2\) 写入的对象 X 在 \(WS(T_1)\) 中,那么 \(T_2\) 或许会覆写 \(T_1\) 的更新,那么 \(T_2\) 需要重试。

OCC例子3

事务 \(T\) 的验证检查:对于所有事务 \(T_i\neq T\)

  • 如果 \(T\in S\)\(T_i\in F\)(事务 \(T\) 刚开始,\(T_i\) 已结束),那么不会有问题,也就是对应第一种情况。
  • 如果 \(T\neq V\)\(V(T_i) < S(T) < F(T_i)\)\(T\) 开始读时,\(T_i\) 还在验证或写阶段),也就是对应第二种情况,此时需要检查 \(WS(Ti) \cap RS(T)\) 是否为空。
  • 如果 \(T \in V\)\(V(T_i) < V(T) < F(T_i)\)(事务 \(T\) 开始验证时,\(T_i\) 还在验证或写阶段),也就是对应第三种情况,此时需要检查 \(WS(T_i) \cap WS(T)\) 是否为空。

可以很容易的看到,OCC 可以有效避免事务 \(T\) 的脏读以及覆写 \(T_i\) 的更新的问题。但是它也存在问题:首先是增加了回滚的次数,经常会回滚整个事务,除此以外还有维持 \(S, V, F\) 的记录的额外开销。只是在 OCC 中,回滚相对成本会更低,因为在写之前,所有的改变都在本地进行,是对本地副本的修改。

Multi-version Concurrency Control(MVCC)

多版本并发控制旨在保留锁的好处,同时获得更多的并发性。它通过提供多个(一致)版本的数据项。实现方式是:

  • Reader 对每个数据项访问一个适当的数据版本
  • Writer 为它们修改的数据项制作新的版本

MVCC 和标准的锁之间的主要区别是 MVCC 中读锁不会与写锁产生冲突,也就是说读不会阻塞写,写不会 阻塞读。为了实现 MVCC,需要使用 WTS 来记录事务写入该数据项的时间戳,所以一个元组跟随时间的版本变化为:\(tup_{oldest}\rightarrow tup_{older}\rightarrow tup_{newest}\)

  • 当 Reader \(T_i\) 访问数据库时:
    • 忽略所有 \(T_i\) 开始后创建的数据项,即 \(WTS(D)>TS(T_i)\)
    • 仅使用 \(T_i\) 可访问的最新版本 V,即 \(max(WTS(V)) < TS(T_i)\)
  • 当 Writer \(T_i\) 试图修改一个数据项时:
    • 找到最新版本 V,满足 \(WTS(V) < TS(T_i)\)
    • 如果没有更新的版本,创建一个新的版本
    • 如果有更新的版本,终止 \(T_i\) (这里的更新的版本,指的是该版本的创建时间晚于 \(T_i\) 的开始时间)

某些版本的 MVCC 还会维护一个 RTS (Read Timestamp)。不会允许 \(T_i\) 去写 D,如果 \(RTS(D) > TS(T_i)\)。MVCC 的优点在于可序列化所需的锁数量大大减少 。缺点在于:

  • 每次访问一个元组时,都需要检查这个元组是否对于当前事务可用
  • 如果使用 RTS,那么每次读取一个数据项,就需要更新其 RTS
  • 额外版本的数据项的存储开销
  • 删除过期版本数据项的开销。比如 \(V_j\)\(V_k\) 是同一个数据项的不同版本。\(WTS(V_j )\)\(WTS(V_k)\) 都早于 \(TS(T_i)\) ,那么就删除更旧的版本,一般会采取周期性访问数据块的方式进行删除。

PostgreSQL 中的并发控制

PostgreSQL 使用两类并发控制:

  • MVCC:主要是对元组使用,在进行 selectupdate 等操作时
  • two-phase locking (2PL):主要对 catalog 使用,比如在 create Table

在 PostgreSQL 中,仅提供读已提交和可序列化两种 隔离等级。如果使用后者,在进行 select 时,只能看到在事务开始之前已经提交的数据,无法看到并发事务引发的变化。
在 PostgreSQL 中,实现 MVCC 需要:

  • 一个 log 文件用来记录每个 \(T_i\) 的当前状态

  • 每个元组中:

    • xmin = 创建该元组的事务的 ID
    • xmax = 更新/删除该元组的事务的 ID
    • xnew = 与更新版本的链接
  • 对每个事务:

    • 一个事务 ID(时间戳)
    • 数据快照:\(T_i\) 开始时,活跃的事务的列表

元组需要满足以下条件才能被事务 \(T_i\) 访问:

  • Xmin(创建事务)值必须以及提交到 log 文件中,必须小于 \(T_i\) 的开始时间,在 \(T_i\) 开始时不处于活跃状态。

  • Xmax(删除/替换事务)值必须为空,或者指向一个 ABORT 事务。或者在 \(T_i\) 开始时间之后。或者该事务在数据快照时是处于活跃状态的。

具体看 utils/time/tqual.c 文件。

在 PostgreSQL 中,事务总是面对一个 consistent 的数据库状态,但有可能看不到 ”当前的“ 数据库状态。比如:\(T_1\) 在进行 select 操作,一个并行事务 \(T_2\) 在删除 \(T_1\) select 的一部分元组。此时 \(T_1\) 仍能得到所有的元组,这是因为在 \(T_1\) 开始的时候,所有元组都存在于数据库中

原子性和持久性

事务是原子的,如果一个事务提交了,那么数据库中所有的改变都会被永久保存。如果一个事务终止了,那么该事务带来的改变不会出现在数据库中。

事务的影响是持久的,如果一个事务提交了,它的影响是持久的,(即使之后发生(灾难性)了系统故障)。

原子性和持久性的实现是相互交织的。

持久性

需要处理的系统故障有:

  • 数据在内存到磁盘之间转移出现的比特错误
  • 磁盘上存储介质的衰减(导致一些数据被更改)
  • 整个磁盘设备出现了故障(数据无法访问)
  • 数据库管理系统进程的故障(例如 postgres 进程的崩溃)
  • 操作系统崩溃,机房停电
  • 完全摧毁正在运行数据库管理系统的计算机系统

最后一个故障需要将数据备份到其他地方,其他的故障都可以通过备份在本地恢复。

现在考虑下面这个场景:

持久性例子

系统重启之后的期望的行为是:

  • 事务 \(T_1\)\(T_2\) 的影响都持久化了
  • 事务 \(T_3\)\(T_3\) 进行回滚

持久性从一个稳定的磁盘存储子系统开始,例如 putPage()getPage() 总是能正常的工作。我们可以防止或最小化数据丢失和损坏。因为:

  • 内存和磁盘之间数据转移的损坏可以通过奇偶校验法来检查
  • 扇区的故障可以将这个块标记为 bad
  • 磁盘的故障可以通过 RAID 来解决
  • 计算机系统的故障可以通过其他地方的备份来恢复

剩余的故障有:

  • 数据库管理系统进程或操作系统的故障
  • 事务的故障(终止)

这类故障可以通过 log 来保存数据库的改变,在发生故障的时候,使用这个 log 来恢复状态。

原子性和持久性的架构

原子性和持久性的架构

事务要处理三个地址空间:

  • 存储数据到磁盘上(代表全局数据库的状态)
  • 存储数据在内存缓冲区中(这里的数据是事务共享的)
  • 存储数据在它们自己本地变量中

每一个都可能有一个不一样的数据库对象的“版本”。在 PostgreSQL 进程中使用大量的共享缓冲池,事务不会处理太多的本地数据。

对数据转移可以使用的操作:

  • INPUT(X):读取一个包含数据 X 的页面到一个缓冲区中
  • READ(X,v):从缓冲区中复制值 X 的值到本地变量 v 中
  • WRITE(X,v):将本地变量 v 中的值复制到缓冲区 X 值存放的位置中
  • OUTPUT(X):将包含值 X 的缓冲区写到磁盘中

读和写是通过事务来实现的,输入和输出是通过缓冲区管理(日志管理)来实现的,输入和输出对应 getPage()putPage()

事务的执行

以下是一个事务执行的例子:

事务执行的例子

READ 访问缓冲区管理器,并可能导致 INPURCOMMIT 需要确保缓冲区内容进入磁盘。

以下上面的事务例子执行过程中的状态:

事务执行过程中的状态

  • 事务开始前 ,只有磁盘 A 和 B 中有数据,分别是 8 和 5
  • READ(A,v) 将缓冲区的数据 A 复制到变量 v 中,因此 v 和缓冲区数据 A 的值都是 8
  • 将变量 v 的数据乘以 2
  • 将变量 v 的数据写到缓冲区数据 A 所在的地方
  • READ(B,v) 将缓冲区数据 B 的值复制到变量 v 中,因此 v 和缓冲区数据 B 的值都是 5
  • 将变量 v 的数据加上 1
  • 将变量 v 的数据写到缓冲区数据 B 所在的地方
  • 将缓冲区数据 A 的值写出到磁盘相应的数据 A 中
  • 将缓冲区数据 B 的值写出到磁盘相应的数据 B 中

这个事务完成后,磁盘数据 A 的值为 8,磁盘数据 B 的值为 5 或者磁盘数据 A 的值为 16,磁盘数据 B 的值为 6。如果系统故障发生在第 8 步之前,就需要撤销磁盘的更改,如果发生在第 8 步之后,需要重写磁盘的更改。

事务和缓冲池

关于缓冲区会产生两个问题:

  • forcing:对每一个输出缓冲区都进行写磁盘操作。这虽然可以确保持久性,让磁盘的数据与缓冲池的数据保持一致,但是会带来比较差的性能,与使用缓冲池的目标发生冲突。
  • stealing:替代事务未提交的缓冲区。如果不这么做,会导致差的吞吐量,因为事务对缓冲区进行了加锁。而如果这么做,可能会导致原子性的问题。

理性情况下,我们是希望 stealing 且不是 forcing。而对 stealing 的处理如下:

  • 事务 \(T\) 加载页面 \(P\),并做了一些修改
  • 事务 \(T_2\) 需要一个缓冲区,而这个缓冲区存储的是 \(P\)
  • 因为页面 \(P\) 是脏的,所以要写出到磁盘中,然后缓冲区加载新的页面
  • 如果事务 \(T\) 终止了,但是有些操作以及提交了,例如对 \(P\) 的修改
  • 因此必须在“偷窃时间”记录 \(P\) 中由 \(T\) 更改的值
  • 然后对事务 \(T\) 的修改进行撤销(UNDO

no forcing 的处理:

  • 事务 \(T\) 进行了一些修改并且已经提交,然后发生了系统故障
  • 但是如果此时对页面 \(P\) 的修改还没有输出到磁盘中
  • 一旦更改,必须立即记录 \(P\) 中由 \(T\) 更改的值
  • 使用这些来支持重写(REDO)来恢复更改

日志

日志分为三类:

  • undo:移除每个事务的改变
  • redo:重复每个事务的改变
  • undo/redo:对前两者的结合

所有方法都需要:

  • 日志记录的顺序文件
  • 每个日志记录都描述了对数据项的更改
  • 日志记录是先写的
  • 数据的实际更改稍后会写入

这需要 write-ahead logging,在 PostgreSQL 中使用的是 WAL

Undo Logging

确保原子性的简单日志记录形式。日志文件由一系列小记录组成:

  • <START T>:事务 \(T\) 的开始
  • <COMMIT T>:事务 \(T\) 成功的完成
  • <ABORT T>:事务 \(T\) 失败(没有改变)
  • <T,X,v>:事务 \(T\) 对数据 X 进行了修改,变量 v 存放的是数据 X 的旧值

注意:<T,X,v> 一般为更新操作的日志记录,更新日志记录是有 WRITE 操作得到的,而不是 OUTPUT 操作。更新日志条目包含旧值(新值未记录)

数据必须按以下顺序写入磁盘:

  • <START> 事务日志记录
  • <UPDATE>:日志记录表示那些变化
  • 更改的数据元素本身
  • <COMMIT> 日志记录

以下是一个 Undo Logging 的例子:

Undo Logging的例子

使用 Undo Logging 进行恢复的简易视图,通过日志向后(从下往上)看:

  • 如果是 <COMMIT T>:标记 T 为已提交
  • 如果是 <T,X,v>,并且 \(T\) 还没有提交,就把变量 v 中的 X 值写到磁盘
  • 如果是 <START T>,并且 \(T\) 还没有提交,就把 <ABORT T> 写到日志中

假设我们扫描整个日志,使用检查点来限制扫描。对于 Undo Logging 进行恢复的伪代码如下:

committedTrans = abortedTrans = startedTrans = {}	// 分别用于存储已提交的事务、已中止的事务和已启动的事务
for each log record from most recent to oldest {	// 对于每个从最近到最旧的日志记录
    switch (log record) {
		<COMMIT T> : add T to committedTrans  
		<ABORT T>	: add T to abortedTrans
		<START T>	: add T to startedTrans
		<T,X,v>	: if (T in committedTrans)
							// 如果事务T在已提交的事务中,则不需要撤销已提交的更改
 							else
     							{ WRITE(X,v); OUTPUT(X) } // 否则需要执行回滚操作。在回滚操作中,将旧值X写回并输出。
}}
for each T in startedTrans {
		if (T in committedTrans) ignore 
    else if (T in abortedTrans) ignore 
    else write <ABORT T> to log	// 将<ABORT T>的日志记录写入日志,表示该事务需要回滚。
}

Checkpointing

这里有个问题就是存在多个并发的事务,应该如何知道这些事务都完成了呢?

可以定期地写日志记录 <CHKPT (T1,..,Tk)>(活跃事务表),继续正常处理(例如新的事务可以开始)。当 T1,...Tk 都完成了,写日志记录 <ENDCHKPT>,然后关闭日志。事务管理器回保持 chkpt 和活跃事务的信息。

当需要恢复的时候,通过日志文件向后扫描。当我们首先遇到 <ENDCHKPT><CHKPT...> 就停下来。如果我们是先遇到 <ENDCHKPT>,那么所有未完成的事务都来自前一个 <CHKPT...> 之后。因此,当我们到达 <CHKPT...> 就可以停止向后遍历了。如果我们先遇到 <CHKPT (T1,..,Tk)>,那么故障发生在这个检查点阶段,T1,...,Tk 中只有在故障之前提交的都是没有问题的,而对于故障之前未提交的事务就需要继续向后扫描了。

Redo Logging

Undo Logging 的问题在于在提交之前,所有更改的数据都必须输出到磁盘。这与缓冲池的使用发生冲突。

而另一种方法就是 Redo Logging

  • 在提交之后允许更改保留在缓冲区中
  • 写记录来表明哪些更改是“待定的”
  • 在发生一个故障之后,可以在恢复期间更改数据

Redo Logging 则需要 write-ahead rule,数据必须按以下方式写入磁盘:

  • 开始事务日志记录
  • 更新日志记录以表示更改
  • 然后提交日志记录 OUTPUT
  • 然后 OUTPUT 更改数据元素本身

注意:更新日志记录现在包含 <T,X,v’>,这里的 \(v'\) 是 X 的新值。下面是 Redo Logging 的一个例子:

Redo Logging的例子

这里第 8 步完成之后事务 \(T\) 就提交了。使用 Redo Logging 进行恢复的简易视图:

  • 向后遍历以确定所有提交了的事务

  • 然后通过日志向前看:

    • 如果遇到 <T,X,v>,并且事务 \(T\) 是已经提交了的,就将磁盘中数据 X 改为 v
    • 如果遇到 <START T>,并且事务 \(T\) 是还没有提交,就将 <ABORT T> 写到日志文件中

Undo/Redo Logging

前两种日志是不兼容的,因为他们输出 <COMMIT T> 和改变数据的顺序是不一样的,而 Undo/Redo Logging 就结合了它们两者,因此需要新的日志记录格式 <T,X,v,v’>,v 为数据 X 地旧值,v’ 为数据 X 新值,这样就可以移除他们的不兼容了。以下是 Undo/Redo Logging 的一个例子:

Undo/Redo Logging的例子

当第 10 步完成后,事务 \(T\) 就表示提交了。使用 Undo/Redo Logging 进行恢复的简易视图:

  • 遍历日志来确定提交和未提交的事务
  • 对每个未提交的事务 \(T\) 都添加 <ABORT T> 到日志文件中
  • 根据日志向后遍历,如果遇到 <T,X,v,w> 且事务 \(T\) 是未提交的,就将磁盘上数据 X 修改为 v
  • 根据日志向前遍历,如果遇到 <T,X,v,w> 且事务 \(T\) 是提交的,就将磁盘上数据 X 修改为 w
posted @ 2023-07-04 14:27  FireOnFire  阅读(41)  评论(0编辑  收藏  举报