本文主要介绍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 LOG,CLOG)中记录该事务号对应的事务提交结果(提交还是回滚)。此后,如果有查询事务读到这两条记录,会首先去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中,上述更新事务等同于先删除v1和v2这两行老版本记录,再插入v1和v2这两行新版本记录,删除和插入事务的原子性已经在(1)和(2)中说明,因此更新事务亦是原子的。
3 openGauss中的事务一致性
在分布式事务一致性的例子中,对于并发执行的事务,如果没有一种机制来保障,那么其中的读事务,可能会只读到并发写事务的部分数据。事实上,对于并发的单机事务,也可能存在类似的现象。
仍考虑上文中的例子,只是插入事务T1和查询事务T2都发生在同一个DN上。如下图所示,首先T1在表t中插入v1和v2两条记录,在其提交之前,查询事务T2开始执行。在T2顺序扫描表t的过程中,首先扫描到v1记录,但是由于此时v1记录的xmin对应的XID1(T1的事务号)还没有提交,因此v1不可见。然后T1完成提交,T2继续扫描,并扫描到v2记录,此时v2记录的xmin对应的XID1已经提交,因此v2可见。这样,查询事务T2只看到了T1的部分插入数据,破坏了事务的一致性要求。

单机事务一致性问题示意图
为了解决上面这个问题,openGauss采用多版本并发控制(MVCC)来保证与写事务并发执行的查询事务的一致性。
MVCC的基本机制是:写事务不会原地修改元组内容,而是将被修改的元组标记为这条记录的一个旧版本(标记xmax),同时插入一条修改后的元组,从而产生这条记录的一个新版本;对于在一个查询事务开始时还没有提交的写事务,那么这个查询事务始终认为该写事务没有提交。
在上面的例子中,在T2开始的时候,T1还没有提交,那么对于T2扫描上来的v1和v2记录,T2会认为它们xmin对应的XID1均为未提交的,即这两个新版本对于T1均不可见,因此不会返回任何一条记录,也就不会发生读到部分事务内容的异常情况了。
在MVCC中,最关键的技术点有两个:①元组版本号的实现;②快照的实现。下面详细说明这两个技术点在openGauss中的实现,在10.3.2节中将结合具体示例说明基于MVCC机制的读-写并发控制实现方式。
在openGauss中,采用全局递增的事务号来作为一个元组的版本号,每个写事务都会获得一个新的事务号。如上所述,一个元组的头部会记录两个事务号xmin和xmax,分别对应元组的插入事务和删除(更新)事务。xmin和xmax决定了元组的生命期,亦即该版本的可见性窗口。
相比之下,快照的实现要更为复杂。在openGauss中,有两种方式来实现快照。
- 方法一:活跃事务数组法。
在数据库进程中,维护一个全局的数组,其中的成员为正在执行的事务信息,包括事务的事务号,该数组即活跃事务数组。在每个事务开始的时候,拷贝一份该数组内容。当事务执行过程中扫描到某个元组时,需要通过判断元组xmin和xmax这两个事务(即元组的插入事务和删除事务)对于查询事务的可见性,来决定该元组是否对查询事务可见。以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 <=
,则该事务Tother在T的快照中为未提交状态,其对数据库的写操作对事务T不可见;若tstart >
,则该事务Tother在T的快照中为提交状态,其对数据库的写操作对事务T可见。
在openGauss内部,使用一个全局自增的长整数来作为逻辑的时间戳,模拟数据库内部的时序,该逻辑时间戳被称为提交顺序号(Commit Sequence Number,简称CSN)。每当一个事务提交的时候,在提交顺序号日志中(Commit Sequence Number Log,简称CSN日志)会记录该事务事务号XID(事务的全局唯一标识)对应的逻辑时间戳CSN值。CSN日志中记录的XID值与CSN值的对应关系,即决定了所有事务的状态函数f(t)。
如图所示,在一个事务的实际执行过程中,并不会在一开始就加载全量的CSN日志,而是在扫描到某条记录以后,才会去CSN日志中查询该条记录头部xmin和xmax这两个事务号对应的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事务三条查询的返回结果。如果采用读已提交的隔离级别,那么在第一条查询开始时,首次获取快照,T1和T2均没有提交,因此它们都在快照中,查询结果不会包含它们插入的新记录;在第二条查询开始时,第二次获取快照,T1已经提交,在第二条查询语句的快照中,只有T2,因此可以查询到T 1插入的记录v1;同理,在第三条查询开始时,第三次获取快照,T1和T2均已经提交,它们都不在第三条语句的快照中,因此可以查询到它们插入的记录v1和v2。
另一方面,如果采用可重复读的隔离级别,对于T3中的三条查询语句,均会采用第一条语句执行开始时的快照,而T1和T2均在该快照中,因此在该隔离级别下,T3的三条查询语句均不会返回v1和v2。

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




