暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

openGauss 事务ACID特性介绍

原创 小小亮 2021-11-22
528

本文主要介绍openGauss中如何保证单机事务的ACID

1  openGauss中的事务持久性

    业界几乎所有的数据库一样,openGauss通过将事务对于数据库的修改写入可以永久长时间保存的存储介质中,来保证事务的持久性。这个过程被称为事务的持久化过程。持久化过程是保证事务持久性所必不可少的环节效率对于数据库整体性能影响很大常常成为数据库的性能瓶颈所在

    最常用的存储介质是磁盘。对于磁盘来说,其每次读写操作都有一个“启动”代价,因此在单位时间内(每秒内),一个磁盘可以进行的读写操作次数(Input/Output Operations Per Second简称IOPS是有上限的。HDD磁盘的IOPS一般在1000/秒以下,SSD磁盘的IOPS可以达到10000/秒左右。另外一方面,如果多个磁盘读写请求的数据在磁盘上是相邻的,那么可以被合并为一次读写操作,这导致磁盘顺序读写的性能通常要远优于随机读写。

    一般来说,尤其是在OLTP场景下,用户对于数据库数据的修改是比较分散随机的。如果在持久化过程中,直接将这些分散的数据写入磁盘,那么这个随机写入的性能是比较差的。因此,数据库通常都采用预写日志(Write Ahead Log,简称WAL)来避免持久化过程中的随机IO如图(a)所示。所谓预写日志,是指在事务提交的时候,先将事务对于数据库的修改写入一个顺序追加的WAL日志文件中。由于WAL日志的写操作是顺序IO,因此其可以达到一个比较高的性能。另一方面,对于真正修改的物理数据文件,再等待合适的时机写入磁盘,以尽可能合并该数据文件上的IO操作。

    在一个事务完成日志的下盘操作(即写入磁盘)以后,该事务就可以完成提交动作。如果在此之后数据库发生宕机,那么数据库会首先从已经写入磁盘的WAL文件中恢复出该事务对于数据库的修改操作,从而保证事务一旦提交即具备持久性的特点。

    下面结合图(b)例子,简单说明数据库故障恢复原理。假设一个事务需要在A对应数据文件A)和表B(对应数据文件B插入一行新的记录,在数据库内部,执行的顺序如下:(1记录修改数据文件A日志,(2记录修改数据文件B日志,3)在数据文件A中写入新的记录,4在数据文件B中写入新的记录。在上述过程中,如果在第4)步执行时数据库发生宕机,那么该事务对于数据文件B修改可能全部或部分丢失。当数据库再次启动以后,在其能够接受新的业务之前,需要将这些可能丢失修改从日志中找回来(该操作被称为日志回放操作)

    日志回放过程中,数据库会根据日志记录的先后顺序,依次读取每个日志的内容然后判断该日志记录的事务对数据库数据文件的修改是否当前相关数据文件的内容一致。如果一致,说明上次数据库停机之前修改已经写入数据文件中该日志修改无需回放如果不一致,说明上次数据库停机之前修改未写入数据文件上次数据库停机可能是异常宕机导致日志对应事务操作需要重新相关数据文件再次执行才能保证恢复成功。

    对于本例,在数据库恢复过程,首先读取到数据文件A插入记录日志,将数据文件A读取上来之后,发现数据文件A已经包含该记录,因此该日志无需回放;然后读取到在数据文件B中插入记录的日志,将数据文件B读取上来之后,发现数据文件B包含插入的记录,因此需要将日志中记录再次写入到数据文件B中,从而完成恢复最终该事务对于数据库所有的修改都得以恢复出来事务的持久性得到了保证

(a) WAL日志和数据页面的关系示意图

(b)WAL日志和故障恢复示意图

WAL日志事务持久性示意图


2  openGauss中的事务原子性

    所示openGauss通过WAL日志、事务提交日志以及更新记录的多版本来保证写事务的原子性。

openGauss事务的原子性示意图


1)对于插入事务,例如以下插入事务:


START TRANSACTION;

INSERT INTO t(a) VALUES (v1);

INSERT INTO t(a) VALUES (v2);

COMMIT;

 

    通常,我们将一条记录在数据库内部的物理组织方式为元组,其在形式上类似一个结构体。在上述插入事务的执行过程中,对于每一条新插入的记录,在它们元组结构体头部的xmin成员都附加了插入事务的唯一标识,即一个全局递增的事务号(Transaction ID简称XID。如10.2.1节中所述,这两条插入的记录(元组连同它们的头部会被顺序写入WAL日志中。

    在该事务的提交阶段,在WAL日志中,会插入一条事务提交日志,以持久化该事务的提交结果,并会在专门的事务提交信息日志(Commit LOGCLOG)中记录该事务号对应的事务提交结果(提交还是回滚)。此后,如果有查询事务读到这两条记录,会首先去CLOG中查询记录头部事务号对应的提交信息,如果为提交,并且通过可见性判断,那么这两条记录会在查询结果中返回如果CLOG中事务号为回滚状态或者CLOG事务号为提交状态但是该事务号对查询不可见,那么这两条记录不会查询结果中返回。如上,在没有故障发生的情况下,上述插入两行记录的事务是原子的,不会发生看到插入一条“中间状态”。

    下面考虑故障场景

  • 如果在事务写下提交日志之前,数据库发生宕机,那么数据库恢复过程中虽然会把这两条记录插入到数据页面中,但是并不会在CLOG中将该插入事务号标识为提交状态,后续查询也不会返回这两条记录。
  • 如果在事务写下提交日志之后,数据库发生宕机,那么数据库恢复过程中,不仅会把这两条记录插入到数据页面中。同时,还会在CLOG中将该插入事务号标识为提交状态,后续查询可以同时看见这两条插入的记录。如上,在故障场景下,上述插入两行记录的事务操作亦是原子的。

2)对于删除事务,例如:

START TRANSACTION;

DELETE FROM t WHERE a = v1;

DELETE FROM t WHERE a = v2;

COMMIT;

 

    在该删除事务的执行过程中,对于上面每一条被删除的记录,在它们元组头部的xmax成员都附加了删除事务的事务号。同时,与插入操作相同,该删除事务提交状态通过事务提交日志物化记录到CLOG中。从而无论正常场景还是故障场景下,如果后续查询涉及上述删除的那些记录它们的可见性取决于统一的CLOG记录的删除事务状态不会发生部分记录能查询到、部分记录不能查询“中间状态”。

3)对于更新事务,例如:

START TRANSACTION;

UPDATE t set a = v1’ WHERE a = v1;

UPDATE t set a = v2’ WHERE a = v2;

COMMIT;

 

    openGauss上述更新事务等同于先删除v1v2两行老版本记录,再插入v1v2两行新版本记录,删除插入事务的原子性已经在12说明,因此更新事务亦是原子的


3  openGauss中的事务一致性

    分布式事务一致性的例子中,对于并发执行的事务,如果没有一种机制保障,那么其中的读事务,可能只读到并发写事务的部分数据事实上,对于并发的单机事务,可能存在类似的现象。

    考虑上文中的例子,只是插入事务T1查询事务T2发生在同一个DN上。如下图所示首先T1t中插入v1v2两条记录在其提交之前,查询事务T2开始执行。在T2顺序扫描表t过程中,首先扫描到v1记录,但是由于此时v1记录的xmin对应XID1T1事务号)没有提交,因此v1不可见。然后T1完成提交,T2继续扫描,扫描到v2记录此时v2记录xmin对应XID1已经提交,因此v2可见。这样查询事务T2看到了T1部分插入数据,破坏了事务的一致性要求。

单机事务一致性问题示意图

    为了解决上面这个问题,openGauss采用多版本并发控制(MVCC来保证与写事务并发执行的查询事务的一致性。

    MVCC基本机制是:事务不会原地修改元组内容,而是修改元组标记为这条记录的一个旧版本(标记xmax,同时插入一条修改后的元组从而产生这条记录的一个新版本;对于一个查询事务开始时还没有提交的事务,那么这个查询事务始终认为该写事务没有提交。

    上面的例子中,在T2开始的时候,T1没有提交,那么对于T2扫描上来的v1v2记录T2会认为它们xmin对应XID1均为未提交的,这两个版本对于T1不可见,因此不会返回任何一条记录,也就不会发生读到部分事务内容异常情况了。

    MVCC中,最关键技术点有两个:①元组版本号的实现;②快照实现。下面详细说明这两个技术点在openGauss的实现10.3.2中将结合具体示例说明基于MVCC机制-写并发控制实现方式。

    openGauss,采用全局递增的事务号来作为一个元组的版本号每个写事务都会获一个新的事务号。如上所述,一个元组的头部会记录两个事务号xminxmax,分别对应元组的插入事务和删除(更新事务xminxmax决定了元组的生命期,亦即版本的可见性窗口。

    相比之下,快照的实现要更为复杂。openGauss,有两种方式来实现快照。

  • 方法一:活跃事务数组法

    在数据库进程中,维护一个全局的数组其中的成员为正在执行的事务信息包括事务的事务号,该数组即活跃事务数组。在每个事务开始的时候,拷贝一份该数组内容。当事务执行过程中扫描某个元组需要通过判断元组xminxmax两个事务(即元组的插入事务和删除事务)对于查询事务的可见性来决定该元组是否对查询事务可见。以xmin为例首先查询CLOG判断该事务是否提交,如果未提交,则不可见;如果提交,则进一步判断xmin是否在查询事务的活跃事务数组中如果xmin该数组中,或者xmin值大于该数组事务号的最大值(事务号全局递增发放的)那么该xmin事务一定在该查询事务开始之后才会提交,因此对于查询事务不可见;如果xmin不在数组中,或者小于该数组事务号的最小值,那么xmin事务一定在该查询事务开始之前就已经提交,因此对于查询事务可见。上述判断逻辑如图所示

基于活跃事务数组方法事务可见性判断示意图

    元组xmax事务对于查询事务的可见性判断类似最终,xmin(元组的插入事务事务号)xmax(元组的删除事务事务号)的不同组合,决定了该元组是否对于查询事务可见如表10-1所示

事务可见性判断

xmax状态

xmin状态

xmax对于查询可见

xmax对于查询可见

xmin对于查询可见

记录不可见(先插入,后删除)

记录可见(先插入,未删除)

xmin对于查询可见

不可能发生

记录不可见(未插入,未删除)

 

  • 方案二:时间戳方法

    使用活跃事务数组方法,由于该数组一般比较大,无法使用原子操作,因此其上的-写并发操作需要加锁互斥-并发操作需要加锁互斥。其中,读操作是指事务开始时拷贝数组内容获取快照的操作,写操作指事务开始时将事务信息加入到该数组中以及事务结束时将事务信息从该数组中移除的操作。在并发的场景下,活跃事务数组成为加锁的热点和性能瓶颈。

    获取快照本质上是要获取事务运行状态与时间的映射关系f(t)。对每一个事务来说,该f(t)函数为一个阶梯函数,如图所示,在该事务的提交时刻点tcommit之前,f(t)为未提交状态,在tcommit之后,f(t)为提交状态。

事务运行状态与时间函数关系的示意图

    由此,某一个事务T的快照内容,即是其它所有事务Tother的事务状态函数fother(t)在该事务开始时刻点tstart的取值状态。根据fother的定义,可知,若tstart <= ,则该事务TotherT的快照中为未提交状态,其对数据库的写操作对事务T不可见;若tstart > ,则该事务TotherT的快照中为提交状态,其对数据库的写操作对事务T可见。

    openGauss内部,使用一个全局自增的长整数来作为逻辑的时间戳,模拟数据库内部的时序,该逻辑时间戳被称为提交顺序号(Commit Sequence Number,简称CSN)。每当一个事务提交的时候,在提交顺序号日志中(Commit Sequence Number Log,简称CSN日志)会记录该事务事务号XID(事务的全局唯一标识)对应的逻辑时间戳CSN值。CSN日志中记录的XID值与CSN值的对应关系,即决定了所有事务的状态函数f(t)

    如图所示,在一个事务的实际执行过程中,并不会在一开始就加载全量的CSN日志,而是在扫描到某条记录以后,才会去CSN日志中查询该条记录头部xminxmax这两个事务号对应的CSN值,并基于此进行可见性判断。

基于时间戳方法事务可见性判断示意图


4  openGauss中的事务隔离性

    在上小节中,事务的一致性反映的是某一个事务在其它并发事务“眼中”的状态。本小节要介绍事务的隔离性,是某一个事务执行过程中,它“眼中”其它所有并发事务的状态。一致性和隔离性,两者相互联系,在openGauss中均是基于MVCC快照实现的;同时,两者又有一定区别,对于较高的隔离级别,除了MVCC快照之外,还需要辅以其它的机制来实现。

    如下表所示,在数据库业界,一般将隔离性按由低到高分为以下四个隔离级别,每个隔离级别按照在该级别下禁止发生的异常现象来定义。这些异常现象包括:

  • 脏读,指一个事务在执行过程中读到并发的、还没有提交的事务的修改内容
  • 不可重复读,指在同一个事务内,先后两次读到的同一条记录的内容发生了变化并发的事务修改)
  • 幻读,指在同一个事务内,先后两次执行、谓词条件相同的范围查询返回的结果不同(并发写事务插入了新的记录)。

    隔离级别越高,在一个事务执行过程中,它能“感知”到的并发事务的影响越小。在最高的可串行化隔离级别下,任意一个事务的执行,均“感知”不到有任何其它并发事务执行的影响,并且所有事务执行的效果就和一个挨一个顺序执行的效果完全相同。

事务隔离级别

隔离级别

脏读

不可重复读

幻读

读未提交

允许

允许

允许

读已提交

不允许

允许

允许

可重复读

不允许

不允许

允许

可串行化

不允许

不允许

不允许

 

    openGauss中,隔离级别的实现基于MVCC和快照机制,因此这种隔离方式被称为快照隔离(Snapshot Isolation,简称SI)。目前,openGauss支持读已提交(Read Committed)和可重复读(Repeatable Read)这两种隔离级别。两者实现上的差别在于在一个事务中获取快照的次数。

    如果采用读已提交的隔离级别,那么在一个事务块中每条语句的执行开始阶段,都会去获取一次最新的快照,从而可以看到那些在本事务块开始以后、在前面语句执行过程中提交的并发事务的效果。如果采用可重复读的隔离级别,那么在一个事务块中,只会在第一条语句的执行开始阶段,获取一次快照,后面执行的所有语句都会采用这个快照,整个事务块中的所有语句均不会看到该快照之后提交的并发事务的效果。

    我们通过具体的例子来说明一下读已提交和可重复读的区别。

    考虑以下三个并发执行的事务(表t包含一个整型字段a):

T1

START TRANSACTION;

INSERT INTO t VALUES (v1);

COMMIT;

 

T2:

START TRANSACTION;

INSERT INTO t VALUES (v2);

COMMIT;

 

T3:

START TRANSACTION;

SELECT * FROM t;

SELECT * FROM t;

SELECT * FROM t;

COMMIT;

 

    这三个事务的并发执行顺序如下图所示。我们考虑T3事务三条查询的返回结果。如果采用读已提交的隔离级别,那么在第一条查询开始时,首次获取快照,T1T2均没有提交,因此它们都在快照中,查询结果不会包含它们插入的新记录;在第二条查询开始时,第二次获取快照,T1已经提交,在第二条查询语句的快照中,只有T2,因此可以查询到T 1插入的记录v1;同理,在第三条查询开始时,第三次获取快照,T1T2均已经提交,它们都不在第三条语句的快照中,因此可以查询到它们插入的记录v1v2

    另一方面,如果采用可重复读的隔离级别,对于T3中的三条查询语句,均会采用第一条语句执行开始时的快照,而T1T2均在该快照中,因此在该隔离级别下,T3的三条查询语句均不会返回v1v2

读已提交和可重复读隔离级别在并发事务下的表现区别

最后修改时间:2021-11-22 13:27:12
「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论