openGauss并发控制
在[openGauss事务机制(上)]的介绍中,已经了解当数据库中存在并发执行事务的情况下,要保证ACID特性,需要一些特殊的机制来支持。并发控制就是这样的一种控制机制,能够保证并发事务同时访问同一个对象或数据下的 ACID特性。
openGauss并发控制是十分高效的,其核心是 MVCC和快照机制。如[openGauss事务机制(上)]所述,通过使用 MVCC和快照,可以有效解决读写冲突,使得并发的读事务和写事务工作在同一条元组的不同版 本上,彼 此不会相互阻塞。对于并发的两个写事务, openGauss通过事务级别的锁机制(事务执行过程中持锁,事务提交时释放锁),来保证写事务的一致性和隔离性。
另一方面,对于底层数据的访问和修改,如物理页面和元组,为了保证读、写操作的原子性,需要在每次的读、写操作期间加上共享锁或排他锁。当每次读、写操作完成之后,即可释放上述锁资源,无须等待事务提交,持锁窗口相对较短。
(一)读-读并发控制
在绝大多数情况下,并发的读-读事务是不会也没有必要相互阻塞的。由于没有修改数据库,因此每个读事务使用自己的快照,就能保证查询结果的一致性和隔离性;同时,对于表的底层的物理页面和元组,如果只涉及读操作,只需要对它们加共享锁即可,不会发生锁等待的情况。
一个比较特殊的情况是执行SELECT FOR UPDATE 查询。该查询会对所查到的每条记录在元组层面加排他锁,以防止在查询完成之后,查询结果集被后续其他写事务修 改。该语句获取到的元 组排他锁,在事务提交时才会释放。 对于并发的SELECT FOR UPDATE事务,如果它们的查询结果集有交集,那么在交集中的元组上会发生锁冲突和锁等待。
(二) 读-写并发控制
如openGauss事务机制(上)图10的例子所示,openGauss中对于读、写事务的并发控制是基于MVCC和快照机制的,彼此之间不会存在事务级的长时间阻塞。相比之下,采用两阶段锁协议(Two-Phase Locking Protocol,2PL 协议)的并发控制(如IBM DB2数据库),由于读、写均在记录的同一个版本上操作,因此排他锁等待队列后面的事务至少要阻塞到持锁者事务提交之后才能继续执行。
另一方面,为了保证底层物理页面和元组的读、写的原子性,在实际操作页面和元组时,需要暂时加上相应对象的共享锁或排他锁,在完成对象的读、写操作之后,就可以放锁。对于所有可能的三种读-写并发场景,即查询-插入并发、查询-删除并发和查询-更新并发,在图1、图2和图3中分别给出了它们的并发控制示意图。
图1 查询-插入并发控制示意图
图2 查询-删除并发控制示意图
图3 查询-更新并发控制示意图
(三) 写-写并发控制
虽然通过 MVCC,可以让并发的读-写事务工作在同一条记录的不同版本上(读老版本,写新版本),从而互不阻塞,但是对于并发的写-写事务,它们都必须工作在最新版本的元组上,因此如果并发的写-写事务涉及同一条记录的写操作,那么必然导致事务级的阻塞。
写-写并发的场景有以下6种:插入-插入并发、插入-删除并发、插入-更新并发、删除-删除并发、删除-更新并发、更新-更新并发。下面就插入-插入并发、删除-删除并发和更新-更新并发的控制流程做简要描述,另外三种并发场景下的控制流程读者可自行思考。
图4为插入-插入事务的并发控制流程图。每个插入事务都会在表的物理页面中插入一条新元组,因此并不会在同一条元组上发生并发写冲突。然而,当表具有唯一索引时,为了避免违反唯一性约束,若并发插入-插入事务在唯一键上有冲突(即键值重复),后来的插入事务必须等待先来的插入事务提交以后,再根据先来插入事务的提交结果,才能进一步判断是否能够继续执行插入操作。如果先来插入事务提交了,那么后来插入事务必须回滚,以防止唯一键重复;如果先来插入事务回滚了,那么后来插入事务可以继续插入该键值的记录。
图4 插入-插入并发控制示意图
图5为删除-删除事务的并发控制流程图。对于并发的删除-删除事务,它们都会尝试去修改同一条元组的xmax值。一般通过页面排他锁来控制该冲突。对于后加上锁的删除事务,它在再次标记元组xmax值之前,首先需要判断先来删除事务(即元组当前xmax事务号对应的事务)的提交结果。如果先来删除事务提交了,那么该元组对后来删除事务不可见,后来删除事务无元组需要删除;如果先来删除事务回滚了,那么该元组对后来删除事务依然可见,后来删除事务可以继续执行对该元组的删除操作。
图5 删除-删除并发控制示意图
图6为更新-更新事务的并发控制流程图。并发的更新-更新事务与并发删除-删除事务类似,它们首先都会尝试去修改同一条元组的xmax值。一般通过页面排他锁来控制该冲突。对于后加上锁的更新事务,它在再次标记元组 xmax值之前,首先需要判断先来更新事务(即元组当前 xmax事务号对应的事务)的提交结果。如果先来更新事务提交了,那么该元组对后来更新事务不可见,此时,后来更新事务会去判断该元组更新后的值(先来更新事务插入)是否还符合后来更新事务的谓词条件(即删除范围),如果符合,那么后来的更新事务会在这条新的元组上进行更新操作,如果不符合,那么后来的更新事务无元组需要更新;如果先来更新事务回滚了,那么该元组对后来更新事务依然可见,后来更新事务可以继续在该元组上进行更新操作。
图6 更新-更新并发控制示意图
(四)并发控制和隔离级别
在上文介绍写-写并发控制的机制时,其实默认了使用读已提交的隔离级别。
回顾图4、图5和图6,可以发现,当在某条元组上发生并发写-写冲突时,原本先来事务是在后来事务的快照中的,后来事务是不应该看到先来事务的提交结果的,但是为了解决上述冲突,后来事务会等待先来事务提交之后,再去校验先来事务对元组的操作结果。这种方式是符合读已提交隔离级别要求的,但是显然后来事务在等待之后,又刷新了自己的快照内容(将先来事务从快照中移除)。
基于上述原因,在 MVCC和快照隔离的并发控制策略下,若使用可重复读的隔离级别,当发生上述写-写冲突时,后来事务不会再等待先来事务的提交结果,而是将直接报错回滚。这也是openGauss在可重复读隔离级别下,对于写-写冲突的处理模式。
进一步来说,如果要支持可串行化的隔离级别,对于使用 MVCC和快照隔离的并发控制策略,需要解决写偏序(Write Skew)的异常现象,有兴趣的读者可以参考2008 年SIGMOD最佳论文SerializableI solationf Or Snapshot Databases。
(五) 对象属性的并发控制
在上面并发控制的介绍中,覆盖了 DML和查询事务的并发控制机制。对于 DDL语句,其虽然不涉及表数据元组的修改,但是其会修改表的结构(Schema),因此很多场景下不能和 DML、查询并发执行。
以增加字段的 DDL事务和插入事务并发执行为例,它们的并发执行流程如图7 所示。首先,DDL事务会获取表级的排他锁,而 DML事务在执行之前,需要获取表级的共享锁。DDL事务持锁之后,会执行新增字段操作。然后,DDL事务会给其他所有并发事务发送表结构失效消息,告诉其他并发事务,这个表的结构被修改了。最后,DDL事务释放表级排他锁,提交返回。
图7 DDL-DML并发控制示意图
DDL事务放锁之后,DML事务可以获取到该表的共享锁。加锁之后,DML 事务首先需要处理所有在等锁过程中可能收到的表结构失效消息,并加载新的表结构信息。然后,DML才可以执行增删改操作,并提交返回。
(六) 表级锁、轻量锁和死锁检测
上文已经向读者初步介绍了在事务并发控制中,需要有锁机制的参与。图7 DDL-DML并发控制示意图实上,在openGauss中,主要有两种类型的锁:表级锁和轻量锁。
表级锁主要用于提供各种类型语句对于表的上层访问控制。根据访问控制的排他性级别,表级锁分为1级到8级锁。对于两个表级锁(同一张表)的持有者,如果他们持有的表级锁的级别之和大于等于8级,那么这两个持有者的表级锁会相互阻塞。
在典型的数据库操作中,查询语句需要获取1级锁,DML 语句需要获取3级锁,因此这两个操作在表级层面不会相互阻塞(这得益于10.3.2节中介绍 MVCC和快照机制)。相比之下,DDL语句通常需要获取8级锁,因此对同一张表的 DDL操作会和查询语句、DML语句相互阻塞。正如图7的例子所示,以修改表结构类型的 DDL语句为代表,如果允许在该 DDL 执行过程中同时插入多条数据,那么前后插入的数据的字段个数可能不一致,甚至相同字段的类型亦可能出现不一致。
另一方面,在创建一个表的索引过程中,一般不允许有并发的 DML 操作,否则可能会导致索引不正确,或者需要引入复杂的并发索引修正机制。在openGauss中,创建索引语句需要对目标表获取5级锁,该锁级别和 DML的3级锁会相互阻塞。
在openGauss中,为表级锁的所有等待者维护了等待队列信息。基于该等待队列,openGauss对于表级锁提供了死锁检测。死锁检测的基本原理是尝试在所有表级锁的等待队列中寻找是否存在能够构成环形等待队列的情况,如果存在环形等待队列,那么就表示可能发生了死锁,需要让其中某个等待者回滚事务退出队列,从而打破该环形等待队列。
在openGauss中,第二种广泛使用的锁是轻量锁。轻量锁只有共享和排他两种级别,并且没有等待队列和死锁检测。一般轻量锁并不对数据库用户提供,仅供数据库开发人员使用,需要开发人员自己来保证并发情况下不会发生死锁的场景。在本文中曾经介绍过的页面锁即是一种轻量锁,表级锁也是基于轻量锁来实现的。
openGauss分布式事务
前面简要介绍了单机事务和分布式事务的区别,也指出了在分布式情况下, 可能存在特有的原子性和一致性问题。本节主要介绍在openGauss中,如何保证分布式事务的原子性和强一致性。
(一)分布式事务的原子性和两阶段提交协议
为了保证分布式事务的原子性,防止出现[openGauss事务机制(上)]图2所示的部分 DN 提交、部分DN 回滚的“中间态”事务,openGauss采用两阶段提交(2PC)协议。
图8 两阶段提交流程示意图
如图8所示,两阶段提交协议将事务的提交操作分为两个阶段:
- 准备阶段(prepare phase),在这个阶段,将所有提交操作所需要用到的信息和资源全部写入磁盘,完成持久化;
- 提交阶段(commitphase),根据之前准备好的提交信息和资源,执行提交或回滚操作。
两阶段提交协议之所以能够保证分布式事务原子性的关键在于:一旦准备阶段执行成功,那么提交需要的所有信息都完成持久化下盘(写入磁盘),即使后续提交阶段图8 两阶段提交流程示意图。某个 DN 发生执行错误,该 DN 可以再次从持久化的提交信息中尝试提交,直至提交成功。最终该分布式事务在所有 DN 上的状态一定是相同的,要么所有 DN 都提交, 要么所有 DN 都回滚。因此,对外来说,该事务的状态变化是原子性的。
表1总结了在openGauss分布式事务中的不同阶段,如果发生故障或执行失败,分布式事务的最终提交/回滚状态,读者可自行推演,本文不再赘述。
表1 发生故障或执行失败时事务的最终状态
故障或执行失败阶段 | 事务最终状态 |
---|---|
SQL语句执行阶段 | 回滚 |
准备阶段 | 回滚 |
准备阶段和提交阶段之间 | 可回滚,亦可提交 |
提交阶段 | 提交 |
(二)分布式事务一致性和全局事务管理
为了防止[openGauss事务机制(上)]图3的瞬时不一致现象,支持分布式事务的强一致性,一般需要全局范围内的事务号和快照,以保证全局 MVCC 和快照的一致性。在 openGauss中, GTM 负责提供和分发全局的事务号和快照。任何一个读事务都需要到 GTM 上获取全局快照;任何一个写事务都需要到 GTM 上获取全局事务号。
在[openGauss事务机制(上)]图3加入 GTM,并考虑两阶段提交流程之后,分布式读-写并发事务的流程如图9所示。对于读事务来说,由于写事务在其从 GTM 获取的快照中,因此即使写事务在不同 DN 上的提交顺序和读事务的执行顺序不同,也不会造成不一致的可见性判断和不一致的读取结果。
图9 读-写并发下全局事务号和快照的分发流程示意图
细心的读者会发现,在图9的两阶段提交流程中,写事务 T1在各个 DN 上完成准备之后,首先第一步是到 GTM 上结束 T1事务(将 T1从全局快照中移除),然后第二步到各个 DN 上进行提交。在这种情况下,如果查询事务 T2是在第一步和第二步之间在 GTM 上获取快照,并到各个 DN 上执行查询,那么 T2事务读到的 T1事务插入的记录v1和v2,它们xmin对应的 XID1已经不在 T2事务获取到的全局快照中,因此v1和v2的可见性判断会完全基于 T1事务的提交状态。然而,此时XID1对应的T1事务在各个 DN 上可能还没有全部或部分完成提交,那么就会出现各个 DN 上可见性不一致的情况。
为了防止上面这种问题出现,在openGauss中采用本地二阶段事务补偿机制。如图10所示,对于在 DN 上读取到的记录,如果其xmin或者xmax已经不在快照中,但是它们对应的写事务还在准备阶段,那么查询事务将会等到这些写事务在 DN 本地 完成提交之后,再进行可见性判断。考虑到通过两阶段提交协议,可以保证各个DN上事务最终的提交或回滚状态一定是一致的,因此在这种情况下各个 DN 上记录的可见性判断也一定是一致的。
图10 读-写并发下本地两阶段事务补偿流程示意图
小结
本文主要结合openGauss的事务机制和实现原理,基于显式事务和隐式事务,介绍事务块状态机的变化,以及openGauss事务 ACID特性的实现方式。尤其对于分布式场景下的事务原子性和一致性问题,介绍openGauss采取的多种解决技术方案,以保证数据库最终对外呈现的 ACID不受分布式执行框架的影响。
Gauss松鼠会是汇集数据库爱好者和关注者的大本营,
大家共同学习、探索、分享数据库前沿知识和技术,
互助解决问题,共建数据库技术交流圈。