事务是为用户提供的最核心、最具吸引力的数据库功能之一。简单地说,事务是用户定义的一系列数据库操作(如查询、插入、修改或删除等)的集合,从数据库内部保证了该操作集合(作为一个整体)的原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),这些特性统称事务的 ACID特性。其中:
-
A:原子性是指事务中的所有操作要么全部执行成功,要么全部执行失败。一个事务执行以后,数据库只可能处于上述两种状态之一,即使数据库在这些操作执行过程中发生故障,也不会出现只有部分操作执行成功的状态。
-
C:一致性是指事务的执行会导致数据从一个一致的状态转移到另一个一致的状态,事务的执行不会违反一致性约束、触发器等定义的规则。
-
I:隔离性是指在事务的执行过程中,所看到的数据库状态受并发事务的影响程度。根据该影响程度的轻重,一般将事务的隔离级别分为读未提交、读已提交、可重复读和可串行化四个级别(受并发事务影响由重到轻)。
-
D:持久性是指一旦事务提交以后,即使数据库发生故障重启,该事务的执行结果不会丢失,仍然对后续事务可见。
本文主要结合openGauss的事务机制和实现原理,阐述openGauss是如何保证事务的 ACID特性的。
一、openGauss事务概览
经过前面几篇文章的介绍,大家已经知道openGauss是一个分布式的数据库。同样的,openGauss的事务机制也是一个从单机到分布式的双层构架。图1为openGauss集群事务组件构成示意图。
图1 openGauss集群事务组件构成示意图
如图1所示,在openGauss集群中,事务的执行和管理主要涉及 GTM、CN 和DN 三种组件,其中:
-
GTM(GlobalTransaction Manager,全局事务管理器),负责全局事务号的分发、事务提交时间戳的分发以及全局事务运行状态的登记。对于采用多版本并发控制(Multi-Version Concurrency Control,MVCC)的事务模型,GTM 本质上可以简化为一个递增序列号(或时间戳)生成器,其为集群的所有事务进行了全局的统一排序,以确定快照(Snapshot)内容并由此决定事务可见性。在本文openGauss并发控制中,将进一步详述 GTM的作用。
-
CN(CoordinatorNode,协调者节点),负责管理和推进一个具体事务的执行流程,维护和推进事务执行的事务块状态机。
-
DN(DataNode,数据节点),负责一个具体事务在某一个数据分片内的所有读写操作。本文主要介绍显式事务和隐式事务执行流程中,CN 和 DN 上事务块状态机的推演,以及单机事务和分布式事务的异同。
(一)显式事务和隐式事务
显式事务是指用户在所执行的一条或多条 SQL后,显式添加了开启事务START TRANSACTION 语句和提交事务 COMMIT 语句。
隐式事务是指用户在所执行的一条或多条 SQL 语句的前后,没有显式添加开启事务和提交事务的语句。在这种情况下,每一条 SQL 语句在开始执行时,openGauss内部都会为其开启一个事务,并且在该语句执行完成之后,自动提交该事务。
以一条SELECT 语句和一条INSERT 语句为例,简要描述显式事务和隐式事务在openGauss集群中的主要执行流程。
显式事务的SQL语句如下(假设表t只包含一个整数类型字段a,且为分布列):
START TRANSACTION;
SELECT * FROM t;
INSERT INTO t(a) VALUES (100);
COMMIT;
(1)START TRANSACTION
该SQL语句只在 CN 上执行,CN 显式开启一个事务,并将 CN 本地事务块状态机从空闲状态设置为进行中状态,然后返回客户端,等待下一条SQL命令。
(2)SELECT * FROM t
该SQL语句首先在 CN 上执行,由于openGauss分片采用一致性哈希算法,因此对于不带分布列上谓词条件的查询语句,CN 需要将该SQL语句发送到所有 DN 上执行。对于每一个分片对应的 DN,由于采用了显式事务,CN 会先发送一条 START TRANSACTION 命令给该 DN,让该 DN 显式开启事务(DN 上的事务块状态机从空闲状态变为进行中状态),然后 CN 将 SELECT 语句发送给该 DN。此后,CN 在收到所有DN的查询结果之后,返回客户端,等待下一条SQL命令。
(3)INSERTINTOt(a)VALUES(100)
该SQL语句首先在 CN 上执行,由于a为表t的分布列,因此 CN 可以根据被插入记录中a的具体取值,决定应该由哪个数据分片对应的 DN 执行实际的插入操作(这里 假 设 该 分 片 为 DN1)。由于采用了显式事务,CN先发送一条 START TRANSACTION 命令给 DN1,由于经过第(2)步,DN1的事务块状态机已经处于进行中状态,因此 对 于 该 语 句,DN1 并 不 会 执 行 什 么 实 际 的 操 作,然 后,CN将具体的INSERT 语句发送给 DN1,并等待 DN1执行插入成功之后,返回客户端,等待下一条SQL命令。
(4)COMMIT
该SQL语句首先在CN 上执行,CN 进入提交事务阶段后,将COMMIT 语句发送给所有参与第(2)步和第(3)步的 DN,让这些 DN 结束该事务,并将 DN 本地的事务块状态机从进行中状态置为空闲状态。CN 在收到所有 DN 的事务提交结果之后,再将CN 本地的事务块状态机从进行中状态置为空闲状态。然后,CN 返回客户端,该事务执行完成。
上述操作的隐式事务语句如下(假设表t只包含一个整数类型字段 a,且为分布列):
SELECT * FROM t;
INSERT INTO t(a) VALUES (1);
(1)SELECT * FROM t
该SQL语句首先在 CN 上执行,CN 隐式开启一个事务,将 CN 本地的事务块状态机从空闲状态置为开启状态(注意不同于显式事务中的进行中状态)。然后,CN 需要将该语句发送到所有 DN 上执行。对于每一个分片对应的 DN,由于采用了隐式事务且该语句为只读查询,CN 直接将SELECT 语句发送给该 DN。
DN收到该SELECT语句之后,亦采用隐式事务:第一步,隐式开启事务,将DN本地的事务块状态机从空闲状态置为开启状态;第二步,执行该查询语句,将查询结果返回给CN;第三步,隐式提交事务,将 DN本地的事务块状态机从开启状态置为空闲状态。CN 在收到所有 DN 的查询结果之后,返回客户端,并隐式提交事务,将 CN 本地的事务块状态机从开启状态置为空闲状态。
(2)INSERT INTO t(a) VALUES(1)
该SQL语句首先在 CN 上执行,CN 隐式开启一个事务,将 CN 本地的事务块状态机从空闲状态置为开启状态。然后,CN 需要将该INSERT 语句发送到目的分片的DN 上执行(这里假设该分片为 DN1)。
虽然该语句采用了隐式事务,但是由于该语句为写操作,因此在 DN1上会采取显式事务:CN 会先发送一条START TRANSACTION 命令给 DN1,让 DN1显式开启事务(DN1上的事务块状态机从空闲状态变为进行中状态),然后 CN 将INSERT语句发送给 DN1,DN1执行完成后,返回执行结果给 CN。
CN 收到执行结果之后,进入提交事务阶段。先发送COMMIT语句到DN1。DN1收到 COMMIT 语句后,进行显式提交,将 DN1本地的事务块状态机从进行中状态置为空闲状态。CN 在收到 DN1的事务提交结果之后,本地再进行隐式提交事务, 将 CN 本地的事务块状态机从开启状态置为空闲状态,返回客户端,该事务执行完成。
综上,对于CN 来说,使用显式事务还是隐式事务,完全取决于用户输入的SQL语句;对于 DN 来说,只有当 SQL为隐式只读事务时,才会使用隐式事务,当 SQL为显式事务或者隐式写事务时,都会使用显式事务。
(二) 单机事务和分布式事务
在openGauss这样的分布式集群中,单机事务(亦称单分片事务)是指一个事务中所有的操作都发生在同一个分片(即 DN)上,分布式事务是指一个事务中有两个或以上的分片参与了该事务的执行。
对于单机事务,其写操作的原子性和读操作的一致性由该 DN 自身的事务机制就能保证;对于分布式事务,不同分片之间写操作的原子性和不同分片之间读操作的一致性,需要额外的机制来保障。下面结合 SQL 语句简要介绍分布式事务的原子性和一致性要求,具体的原理机制在openGauss分布式事务中说明。
首先,考虑涉及多分片的写操作事务,以如下事务 T1为例(假设表t只包含一个整数类型字段a,且为分布列):
START TRANSACTION;
INSERT INTO t(a) VALUES (v1);
INSERT INTO t(a) VALUES (v2);
COMMIT;
上面事务 T1的两条INSERT 语句均为只涉及一个分片的写(插入)事务,如果v1和v2分布在同一个分片内,那么该事务为单机事务,如果v1和v2分布在两个不同的分片内,那么该事务为分布式事务。
对于只涉及一个 DN 分片的单机事务,其对于数据库的修改和影响全部发生在同一个分片内,因此该分片的事务提交结果即是该事务在整个集群的提交结果,该分片事务提交的原子性就能够保证整个事务的原子性。在事务 T1示例中,如果v1和v2全分布在 DN1上,那么在 DN1上,如果事务提交,那么这两条记录就全部插入成功; 如果 DN1上事务回滚,那么这两条记录的插入就全部失败。
对于分布式事务,为了保证事务在整个集群范围内的原子性,必须保证所有参与图2 分布式事务原子性问题示意图写操作的分片要么全部提交,要么全部回滚,不能出现部分分片提 交,部 分 分 片 回 滚 的 “中 间态”。如图2所示,如果 v1插入到 DN1上,且 DN1提交成功,同时,v2插入到 DN2上,且DN2最终回滚,那么最终该事务只有一部分操作成功,破坏了事务的原子性要求。为了避免这种情况的发生,openGauss采用两阶段提交(Two Phase Commit,2PC)协议,来保证分布式事务的原子性,在后面内容中会对两阶段提交相关内容进行更详细的介绍。
图2 分布式事务原子性问题示意图
其次,考虑涉及多分片的读操作事务 T2,以如下 SQL 语句为例(假设表t只包含一个整数类型字段a,且为分布列):
START TRANSACTION;
SELECT * FROM t where a = v1 or a = v2;
COMMIT;
上面查询事务 T2中,如果 v1和 v2分布在同一个分片内,那么该事务为单机事务,如果v1和v2分布在两个不同的分片内,那么该事务为分布式事务。对于单机事务,其查询的数据完全来自于同一个分片内,因此该分片事务的可见性和一致性就能够保证整个事务的一致性。
在事务 T1和 T2示例中,考虑 T1和 T2并发执行的场景(假设 T1提交成功),如果v1和v2全分布在DN1上,那么,在DN1上,如果 T1对 T2可见,那么 T2就能查询到所有的两条记录,如果 T1对 T2不可见,那么 T2不会查询到两条记录中的任何一条。
对于分布式事务,其查询的数据来自不同的分片,单个分片的可见性和一致性无法完全保证整个事务的一致性,不同分片之间事务提交的先后顺序和可见性判断会导致查询结果存在某种“不确定性”。
仍考虑 T1和 T2并发执行的场景(假设 T1提交成功)。如图3所示,如果v1和v2分别分布在 DN1和 DN2上,若在 DN1上,T1事务提交先于 T2的查询执行,且对于 T2可见,而在 DN2上,T2的查询执行先于 T1事务提交(或 T1事务提交先于T2查询执行,但对 T2不可见),那么 T2最终只会查询到v1这一条记录。对于以银行为代表的传统数据库用户来说,这种现象破坏了事务作为一个整体的一致性要求。在分布式事务中,亦称为强一致性要求。
另一方面,如果 T1先完成提交,并等待足够长的时间以后(保证所有分片均完成T1的提交,并保证提交结果对 T2可见),再执行 T2,那么 T2将可以看到 T1插入的所有两条记录。在分布式事务中,这种一致性表现被称为最终一致性。与传统数据库用户不同,在互联网等新兴业务中,最终一致性是被广泛接受的。
openGauss通过全局一致性的时间戳(快照)技术和本地两阶段事务补偿技术),提供分布式强一致事务的能力,同时,对于追求性能的新兴数据库业务,也支持可选的最终一致性事务的能力。
图3 分布式事务一致性问题示意图
二 openGauss事务 ACID特性介绍
本文主要介绍openGauss中如何保证单机事务的 ACID特性,在此基础上,在后面内容将说明如何保证分布式事务的 ACID特性。
(一)openGauss中的事务持久性
和业界几乎所有的数据库一样,openGauss通过将事务对于数据库的修改写入可永久(长时间)保存的存储介质中,来保证事务的持久性。这个过程称为事务的持久化过程。持久化过程是保证事务持久性所必不可少的环节,其效率对于数据库整体性能影响很大,常常成为数据库的性能瓶颈所在。
最常用的存储介质是磁盘。对于磁盘来说,其每次读写操作都有一个“启动”代价,因此在单位时间内(每秒内),一个磁盘可以进行的读写操作次数(Input/Output Operations Per Second,IOPS)是有上限的。HDD 磁盘的IOPS一般在1000次/秒以下,SSD磁盘的IOPS可以达到10000次/秒。另一方面,如果多个磁盘读写请求的数据在磁盘上是相邻的,那么可以被合并为一次读写操作,这导致磁盘顺序读写的性能通常要远优于随机读写。
一般来说,尤其是在 OLTP场景下,用户对于数据库数据的修改是比较分散随机的。如果在持久化过程中,直接将这些分散的数据写入磁盘,那么这个随机写入的性能是比较差的。因此,数据库通常都采用 WAL(WriteAheadLog,预写日志)来避免持久化过程中的随机IO,如图10-4(a)所示。所谓预写日志,是指在事务提交的时候, 先将事务对于数据库的修改写入一个顺序追加的 WAL文件中。由于 WAL的写操作是顺序IO,因此其可以达到一个比较高的性能。另一方面,对于真正修改的物理数据文件,再等待合适的时机写入磁盘,以尽可能合并该数据文件上的IO 操作。
(a) WAL日志和数据页面的关系示意图
(b)WAL日志和故障恢复示意图
图4 WAL和事务持久性示意图
在一个事务完成日志的下盘操作(即写入磁盘)以后,该事务就可以完成提交动作。如果在此之后数据库发生宕机,那么数据库会首先从已经写入磁盘的 WAL 文件中恢复出该事务对于数据库的修改操作,从而保证事务一旦提交即具备持久性的特点。
下面结合图4(b)中的例子,简单说明数据库故障恢复的原理。假设一个事务需要在表 A(对应数据文件 A)和表 B(对应数据文件 B)中各插入一行新记录,在数据库内部,其执行的顺序如下:①记录修改数据文件 A 的日志;②记录修改数据文件 B的日志;③在数据文件 A 中写入新记录;④在数据文件 B中写入新记录。在上述过程中,如果在第④步执行时数据库发生宕机,那么该事务对于数据文件 B的修改可能全部或部分丢失。当数据库再次启动以后,在其能够接受新的业务之前,需要将这些可能丢失的修改从日志中找回来(该操作被称为日志回放操作)。
在日志回放过程中,数据库会根据日志记录的先后顺序,依次读取每个日志的内容,然后判断该日志记录的事务对数据库数据文件的修改是否和当前相关数据文件的内容一致。如果一致,说明上次数据库停机之前修改已经写入数据文件中,该日志修改无须回放;如果不一致,说明上次数据库停机之前修改还未写入数据文件中,上次数据库停机可能是异常宕机导致,该日志对应的事务操作需要重新在相关数据文件中再次执行,才能保证恢复成功。
对于本例,在数据库恢复过程中,首先读取到在数据文件 A 中插入记录的日志,将数据文件 A 读取上来之后,发现数据文件 A 中已经包含该记录,因此该日志无须回放;然后读取到在数据文件 B中插入记录的日志,将数据文件 B读取上来之后,发现数据文件 B中未包含新插入的记录,因此需要将日志中的记录再次写入到数据文件 B中,从而完成恢复。最终,该事务对于数据库所有的修改都得以恢复出来,事务的持久性得到了保证。
(二) openGauss中的事务原子性
如图5所示,openGauss通过 WAL、事务提交信息日志以及更新记录的多版本来保证写事务的原子性。
图5 openGauss事务的原子性示意图
(1)插入事务是原子性的,例如以下插入事务:
START TRANSACTION;
INSERT INTO t(a) VALUES (v1);
INSERT INTO t(a) VALUES (v2);
COMMIT;
通常将一条记录在数据库内部的物理组织方式称为元组,其在形式上类似一个结构体。在上述插入事务的执行过程中,对于每一条新插入的记录,在它们元组结构体头部的xmin 成 员 处都附加了插入事务的唯一标识,即一个全局递增的事务号(Transaction ID,XID)。如上文所述,这两条插入的记录(元组)连同它们的头部会被顺序写入WAL中。
在该事务的提交阶段,在 WAL中,会插入一条事务提交日志,以持久化该事务的提交结果,并会在专门的 CLOG(Commit LOG,事务提交信息日志)中记录该事务号对应的事务提交结果(提交还是回滚)。此后,如果有查询事务读到这两条记录,会首先去 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'这两行新版本记录,删除和插入事务的原子性已经在上面说明,因此更新事务亦是原子性的。
(三)openGauss中的事务一致性
在图3分布式事务一致性问题示意图中,对于并发执行的事务,如果没有一种机制来保障,那么其中的读事务,可能会只读到并发写事务的部分数据。事实上,对于并发的单机事务,也可能存在类似的现象。
仍考虑图3的例子,只是插入事务 T1和查询事务 T2都发生在同一个 DN 上。如图6所示,首先 T1在表t中插入v1和v2两条记录,在其提交之前,查询事务 T2 开始执行。在 T2顺序扫描表t的过程中,首先扫描到v1记录,但是由于此时v1记录的xmin对应的 XID1(T1的事务号)还没有提交,因此v1不可见。然后 T1完成提交, T2继续扫描,并扫描到v2记录,此时v2记录的xmin对应的 XID1已经提交,因此v2可见。这样,查询事务 T2只看到了 T1的部分插入数据,破坏了事务的一致性要求。
图6 单机事务一致性问题示意图
为了解决上面这个问题,openGauss采用 MVCC(多版本并发控制)机制来保证与写事务并发执行的查询事务的一致性。
MVCC的基本机制是:写事务不会原地修改元组内容,而是将被修改的元组标记为这条记录的一个旧版本(标记xmax),同时插入一条修改后的元组,从而产生这条记录的一个新版本;对于在一个查询事务开始时还没有提交的写事务,那么这个查询事务始终认为该写事务没有提交。
在上面的例子中,在 T2开始的时候,T1还没有提交,那么对于 T2扫描上来的v1和v2记录,T2会认为它们xmin对应的 XID1均为未提交的,即这两个新版本对于 T1均不可见,因此不会返回任何一条记录,也就不会发生读到部分事务内容的异常情况了。
在 MVCC中,最关键的技术点有两个:①元组版本号的实现;②快照的实现。下面详细说明这两个技术点在openGauss中的实现,在后面内容中将结合具体示例说明基于 MVCC机制的读-写并发控制实现方式。
在openGauss中,采用全局递增的事务号来作为一个元组的版本号,每个写事务都会获得一个新的事务号。如上所述,一个元组的头部会记录两个事务号 xmin和xmax,分别对应元组的插入事务和删除(更新)事务。xmin和 xmax决定了元组的命期,亦即该版本的可见性窗口。
相比之下,快照的实现要更为复杂。在openGauss中,有两种方式来实现快照。
(1)活跃事务数组方法。
在数据库进程中,维护一个全局的数组,其中的成员为正在执行的事务信息,包括事务的事务号,该数组即活跃事务数组。在每个事务开始的时候,复制一份该数组内容。当事务执行过程中扫描到某个元组时,需要通过判断元组xmin和xmax这两个事务(即元组的插入事务和删除事务)对于查询事务的可见性,来决定该元组是否对查询事务可见。以xmin为例,首先查询CLOG,判断该事务是否提交,如果未提交,则不可见;如果提交,则进一步判断该xmin是否在查询事务的活跃事务数组中。如果xmin在该数组中,或者xmin的值大于该数组中事务号的最大值(事务号是全局递增发放的),那么该xmin事务一定在该查询事务开始之后才会提交,因此对于查询事务不可见;如果xmin不在该数组中,或者小于该数组中事务号的最小值,那么该xmin事务一定在该查询事务开始之前就已经提交,因此对于查询事务可见。上述判断逻辑如图7所示。
图7 基于活跃事务数组方法的事务可见性判断示意图
判断元组xmax事务对查询事务的可见性与此类似。最终,xmin(元组的插入事务事务号)和xmax(元组的删除事务事务号)的不同组合,决定了该元组是否对于查询事务可见,如表1所示。
表1 事务可见性判断
xmax状态/xmin状态 | xmax对于查询可见 | xmax对于查询不可见 |
---|---|---|
xmin对于查询可见 | 记录不可见(先插入,后删除) | 记录可见(先插入,未删除) |
xmin对于查询不可见 | 不可能发生 | 记录不可见(未插入,未删除) |
(2)时间戳方法。
使用活跃事务数组方法,由于该数组一般比较大,无法使用原子操作,因此在其上的读-写并发操作需要加锁互斥,写-写并发操作亦需要加锁互斥。其中,读操作是指事务开始时复制数组内容获取快照的操作,写操作是指事务开始时将事务信息加入到该数组中以及事务结束时将事务信息从该数组中移除的操作。在高并发的场景下,活跃事务数组会成为加锁的热点和性能瓶颈。
获取快照,本质上是要获取事务运行状态与时间的映射关系f(t)。对每一个事务来说,该f(t)函数为一个阶梯函数,如图8所示,在该事务的提交时刻点tcommit 之前,f(t)为未提交状态,在tcommit 之后,f(t)为提交状态。
图8 事务运行状态与时间函数关系的示意图
由此,某一个事务 T的快照内容,即是其他所有事务Tother 的事务状态函数fother(t) 在该事务开始时刻点tstart 的取值状态。根据fother的定义,可知,若tstart <= ,则该事务Tother在T的快照中为未提交状态,其对数据库的写操作对事务T不可见;若tstart > ,则该事务Tother在T的快照中为提交状态,其对数据库的写操作对事务T可见。
在openGauss内部,使用一个全局自增的长整数作为逻辑的时间戳,模拟数据库内部的时序,该逻辑时间戳被称为提交顺序号(Commit Sequence Number,简称CSN)。每当一个事务提交的时候,在提交序列号日志中(CommitSequenceNumberLog,CSN日志)会记录该事务号 XID(事务的全局唯一标识)对应的逻辑时间戳 CSN 值。CSN日志中记录的 XID值与 CSN 值的对应关系,即决定了所有事务的状态函数f(t)。
如图9所示,在一个事务的实际执行过程中,并不会在一开始就加载全部的CSN 日志,而是在扫描到某条记录以后,才会去 CSN 日志中查询该条记录头部 xmin和xmax这两个事务号对应的 CSN 值,并基于此进行可见性判断。
图9基于时间戳方法的事务可见性判断示意图
(四) openGauss中的事务隔离性
在上文中,事务的一致性反映的是某一个事务在其他并发事务“眼中”的状态。本节要介绍的事务隔离性,是某一个事务在执行过程中,它“眼中”其他所有并发事务的状态。一致性和隔离性,两者相互联系,在openGauss中两者均是基于MVCC和快照实现的;同时,两者又有一定区别,对于较高的隔离级别,除了 MVCC和快照之外,还需要辅以其他的机制来实现。
如表2所示,在数据库业界,一般按由低到高将隔离性分为以下四个隔离级别: 读未提交、读已提交、可重复读、可串行化。每个隔离级别按照在该级别下禁止发生的异常现象来定义。这些异常现象包括:
-
脏读,指一个事务在执行过程中读到并发的、还没有提交的写事务的修改内容。
-
不可重复读,指在同一个事务内,先后两次读到的同一条记录的内容发生了变化(被并发的写事务修改)。
-
(3)幻读,指在同一个事务内,先后两次执行的、谓词条件相同的范围查询,返回的结果不同(并发写事务插入了新记录)。
表2 事务隔离级别 | 隔离级别 | 脏读 | 不可重复读 | 幻读 | | ------------ | ------------ | ------------ | ------------ | | 读未提交 | 允许 | 允许 | 允许 | | 读已提交 | 不允许 | 允许 | 允许 | | 可重复读 | 不允许 | 不允许 | 允许 | |可串行化 | 不允许 | 不允许 | 不允许 |
隔离级别越高,在一个事务执行过程中,它能“感知”到的并发事务的影响越小。在最高的可串行化隔离级别下,任意一个事务的执行,均“感知”不到有任何其他并发事务执行的影响,并且所有事务执行的效果就和一个个顺序执行的效果完全相同。
在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;
这三个事务的并发执行顺序如图10所示。考虑 T3事务三条查询的返回结果。如果采用“读已提交”的隔离级别,那么在第一条查询开始时,首次获取快照,T1和T2均没有提交,因此它们都在快照中,查询结果不会包含它们插入的新记录;在第二条查询开始时,第二次获取快照,T1已经提交,在第二条查询语句的快照中,只有T2,因此可以查询到 T1插入的记录 v1;同理,在第三条查询开始时,第三次获取快照,T1和 T2均已经提交,它们都不在第三条语句的快照中,因此可以查询到它们插入的记录v1和v2。
图10 读已提交和可重复读隔离级别在并发事务下的表现区别
另一方面,如果采用可重复读的隔离级别,对于 T3中的三条查询语句,均会采用第一条语句执行开始时的快照,而 T1和 T2均在该快照中,因此在该隔离级别下,T3的三条查询语句均不会返回v1和v2。
Gauss松鼠会是汇集数据库爱好者和关注者的大本营,大家共同学习、探索、分享数据库前沿知识和技术,互助解决问题,共建数据库技术交流圈。