CockroachDB 20.1中的嵌套事务

2020-06-17 04:30:05

Engineering CockroachDB 20.1引入了对嵌套事务的支持,这是一个SQL特性,它简化了某些客户端应用程序的程序员的工作。在这篇文章中,我们将探索何时何地使用嵌套事务,何时不使用它们,以及它们如何在分布式环境中工作。

常规SQL事务将一条或多条语句分组在一起,并为它们提供ACID的A(原子性)和I(隔离性)。从运行其他事务的并发客户端或由同一客户端执行的后续事务的角度来看,这些是对事务效果的保证。原子性意味着事务内的所有语句看起来要么执行成功,要么根本不执行。隔离意味着并发事务看不到事务内部的中间步骤,只能在第一条语句开始之前或最后一条语句完成之后感知数据库的状态。

嵌套事务是发生在常规事务或其他嵌套事务(如俄罗斯玩偶)中的附加事务。由于其周围“最外层”事务的原子性和隔离性,嵌套事务对并发客户端是不可见的。因此,它们是一个仅为发出外部事务的客户端的利益而存在的功能。

更具体地说,嵌套事务是为了客户端应用程序的软件工程师的利益而发明的。

在基于组件的设计中,不同的程序员负责不同组件的内部结构,并且可能不知道彼此的工作。如果它们的组件需要在一个数据库上共同操作,那么它们将面临一个重要的挑战:在多组件事务的上下文中,每个组件如何安全地贡献其数据库工作的一部分?如果一个组件的工作失败,通常会希望另一个组件“接管”并以不同的方式完成工作,而不会放弃整个事务。嵌套事务通过为客户端应用程序中的子组件的工作提供原子性(全部成功,或全部失败),简化了这一点。

这方面的一个例子可以是您最喜欢的组装自己的家具商店的假设订单系统。当客户走在过道上并选择他们的部件时,他们的部分订单将启动一个事务,该事务将以增量方式锁定他们需要的供应品。在某个时候,他们可能会到达厨房区域,并使用提供的交互式控制台开始设计项目。当他们正在试验时,他们希望只有在所有部件都可用的情况下才能推进他们的新厨房。如果分配部件的逻辑遇到供应不足,并且无法按订单实例化整个厨房怎么办?

在这一点上,客户可能想要恢复他们的购物,没有厨房订单,但有他们以前购物车中的所有其他项目:他们想回滚厨房子事务,而不放弃周围的事务。嵌套事务有助于实现这一点。

嵌套事务在客户端应用程序的基于组件的设计中非常有用。一个足够大到可以使用基于组件的设计的应用程序很少让程序员直接使用SQL,数据库访问通常是在某个框架(例如Spring、Flyway或类似的ORM)中抽象出来的。

当数据库访问被适当抽象时,嵌套事务看起来像是“从”另一个事务“获得”的事务,而不是“从”数据库连接对象“获得”的常规事务。

#Regular txnmyTxn:=Conn.BeginTxn()myTxn.Execute(";Insert 1";)myTxn.Execute(";Insert 2";)myTxn.Commit()#嵌套子事务的常规Txn myTxn:=Conn.BeginTxn()if Component1.doSomething(MyTxn)!=成功:component2.doSomethingElse(myTxn)myTxn.Commit()#则在组件中。)如果r!=预期:subTxn.Rollback()#中止嵌套的TXN返回失败subTxn.Execute(";Insert B";)subTxn.Commit()#完成嵌套的TXN返回成功。

此示例提供了一个典型的基于组件的设计示例:顶级组件逻辑不需要知道子组件Component1和Component2正在对事务做什么。它可以假设他们离开TXN,他们是在一个良好的状态。它还可以假设,如果Component1.doSomething失败,它将使myTxn“保持进入时发现的状态”。如果组件1中的错误破坏了外部事务,并迫使顶级逻辑从头开始重新启动事务,那么这种关注点分离是不可能的。

嵌套事务看起来和感觉上都像客户端代码中的常规事务。然而,这只能归功于客户机SQL驱动程序的一些转换魔力。在幕后,SQL驱动程序映射开始、提交或回滚嵌套事务的请求,如下所示:

将保存点subtxn123123&34;发送到数据库。这会告诉数据库“启动嵌套的TXN,并记住它被称为subtxn123123';。";

将";释放保存点subtxn123123";发送到数据库。这告诉数据库“提交名为subtxn123123.";

将回滚到保存点subtxn123123&34;发送到数据库。这告诉数据库“将名为subtxn123123的嵌套TXN回绕到其开始状态。";

然后发送";释放保存点subtxn123123";。这会提交嵌套的TXN;但是,由于它只是通过回滚进行了回滚,因此会取消其所有效果,从而中止它。

为什么回滚到保存点并不自动意味着释放保存点(即在任何情况下都需要释放),这与某些客户端驱动程序如何在面向对象的语言中抽象事务有关。特别是,驱动程序通常会提供在完成时自动提交的事务对象。例如:

在这种语言中,Transaction对象的析构函数/终结器(在本例中是“嵌套的”)将无条件地发出释放保存点。Rollback()调用问题只是回滚到保存点。如果回滚到保存点隐含释放,则释放将执行两次。

CockroachDB只从客户端驱动程序接收SQL语句;从它的角度来看,它只需要关心三件事:保存点、回滚到保存点和释放保存点语句。

在本节中,我们忽略CockroachDB在v20.1之前赋予这些语句的特殊含义(这将在下面介绍),而是考虑它们如何用于嵌套事务。

这些语句中的每一个都有一个保存点名称:客户端应用程序为嵌套事务指定的名称。由于基于组件的设计,持有嵌套事务的客户端组件可以使用自己的嵌套事务将其作为子组件的起点。因此,嵌套的事务可以像俄罗斯玩偶一样相互嵌套。这意味着数据库可以“在”另一个数据库下接收SAVEPOINT语句,并且需要记住名称的嵌套。例如,以下是有效的SQL:

保存点名称;保存点名称;--相同的名称,但这实际上是一个子嵌套事务释放保存点名称;--释放内部释放保存点名称;--释放外部。

换句话说,数据库必须记住并维护一堆嵌套的事务名称。这在CockroachDB中是通过为每个嵌套事务分配一个惟一的内部令牌,然后使用堆栈数据结构将客户端提供的名称映射到内部令牌来实现的。

可以在CockroachDB内使用(嵌套的)事务内的新语句show Savepoint status来观察此数据结构。

如果有人试图设计嵌套事务的实现,很可能会有几个想法接连出现。

(为方便起见,本节使用速记";回滚";来指代";回滚到保存点";。这与SQL的普通回滚有所不同,我们稍后将只讨论这一点。)。

第一个想法可能是使嵌套事务中的语句对数据的内存副本(或临时磁盘区域)进行操作,并将执行实际数据更新延迟到Release语句。这样,回滚只需删除副本,周围的事务就可以继续,就好像什么都没有发生一样。这种方法的问题在于,在常见情况下(没有回滚),工作必须做两次:一次是在副本上,另一次是在发布期间的实际数据上。这个很贵的。

下一个想法可能是让嵌套事务以与外部事务相同的方式更新实际数据,但要记住所做的所有更改。在通常情况下,只发布一次,不需要发生任何其他事情。但是,回滚可以重写到目前为止已修改的所有内容。这种方法的局限性在于,即使在不执行回滚的常见情况下,它也需要支付内存/磁盘空间来存储所有更改。

与大多数其他成熟的SQL引擎一样,CockroachDB中采用的解决方案是在普通情况下将成本降至最低,而在不太可能使用回滚的情况下,事务的其余部分会产生增量成本。

用标识嵌套事务的标记来标记事务中的每个数据更新。

在读取数据时,跳过任何已知要忽略的(嵌套)事务标记的数据更新。

此方法的开销是,当之前至少进行了一次回滚时,数据读取需要执行额外的工作。回滚会向“忽略列表”添加标记并导致其增长,后续每次读取操作的性能都会因不断增加的忽略列表而略有减慢。

如上所述,之所以可以“跳过忽略的更新”,是因为CockroachDB使用MVCC算法来访问数据。在数据写入期间,原始数据不会就地修改。取而代之的是,另一个数据项用新值写在它的旁边。这称为写入意图。在读取操作期间,所有写入意图(和原始数据记录)一起考虑以确定当前逻辑值。

Intent对象包含有关写入的元数据,而不仅仅是更新值。例如,它包含修改该值的事务的ID,以便其他事务知道跳过它(以便保证隔离)。

在v20.1(实际上是v19.1)之前一段时间,CockroachDB开始对事务中的每个写入进行编号,并在Intent对象中写入序列号。

这是在嵌套事务中重用的机制。编号本身没有改变;取而代之的是,现在还向读取操作提供了忽略序列号范围的列表(“序列号忽略列表”)。当读取考虑写入意图时,它现在检查其序列号是否包括在忽略列表中。如果是,则跳过,不计入。这使得忽略的意图对读取不可见,就好像从一开始就没有发生写入一样。

ROLLBACK TO SAVEPOINT语句采用当前写入序列号(在发出ROLLBACK时),然后是发出SAVEPOINT时的序列号,然后将此范围添加到忽略列表。(作为优化,重叠范围合并在一起。)。

在最外层的事务结束时,所有写入意图都被“解析”:

如果事务已提交,则意图将转换为实际数据值,以便后续事务不会再跳过它们。

意图解析算法还使用新的序列号忽略列表作为输入,并且简单地删除其序列号包括在列表中的所有意图-即使在最外层的事务提交时也是如此。

在客户端根本不使用嵌套事务的最常见情况下,忽略列表始终保持为空,并且不需要支付额外的性能代价。

在下一个最常见的情况下,客户端使用嵌套事务,但不使用回滚到保存点,忽略列表也始终保持为空,并且在读/写操作期间不需要支付额外的代价。唯一的代价是管理嵌套事务的名称堆栈。

在偶尔使用回滚到保存点的情况下,忽略列表开始增长;然后,同一事务的每个后续读取以及结束时的意图解析都必须执行一些额外的工作,以跳过标记为忽略的所有意图。

到目前为止的解释涵盖了使用回滚到保存点的情况来取消在其他方面是健康的事务(也称为“打开”)中的数据写入的影响。

此外,如果在数据库错误之后使用回滚到保存点,它还可以取消事务的错误状态。

使用状态图可以更好地理解这一点。关于错误,事务通常按如下方式操作:

这里的想法是,当事务内的语句遇到错误时,事务在内部被标记为";已中止";。它仍然存在,但在客户端使用COMMIT或ROLLBACK/ABORT取消事务之前,其他语句无法操作并遇到错误。(在ABORTED状态下,COMMIT和ROLLBACK/ABORT等效。)。

此一般原则对嵌套事务仍然有效。在嵌套事务内,错误还会将嵌套事务移至已中止状态,并阻止进一步的SQL语句。但是,回滚到保存点将清除已中止状态。这可以表示为:

这意味着可以使用ROLLBACK到SAVEPOINT来“恢复”逻辑错误,例如外键约束检查报告的错误(引用表中不存在行)、唯一索引(重复行)等。它还可以从查询中的错误(例如,列不存在)中恢复。客户端代码可以使用回滚到保存点来“掩盖”错误,并改为使用不同的语句继续。

(提醒:可以通过发出语句show transaction status在CockroachDB内观察事务的当前错误状态。)。

CockroachDB在v20.1中对嵌套事务的支持有三个明显的限制,这可能会影响新应用程序的设计以及与为PostgreSQL设计的应用程序的兼容性。

与其他SQL引擎一样,CockroachDB在写操作期间锁定更新的行,因此在第一个事务提交(或中止)之前,并发事务不能对它们进行操作。这些锁在INSERT或UPDATE等突变语句中是隐式的;在SELECT语句新引入的FOR UPDATE子句中是显式的。

根据SQL标准,回滚到保存点应该“回滚”嵌套事务到目前为止执行的所有效果;理论上这包括所有行锁。在其他SQL引擎中,也会发生这种情况:当回滚嵌套事务时,其行锁将被释放。

在CockroachDBv20.1中,情况并非如此:如上所述,回滚到保存点将保留到目前为止写入的所有意图。此外,其他并发事务无法看到序列号的“忽略列表”,因此无法感知意图何时被标记为忽略。因此,与其他SQL引擎和PostgreSQL不同,锁在回绕嵌套事务时保持不变。

为了说明这一点,考虑一下开头介绍的示例:一位顾客在他们的家具店里走来走去。假设他们已经开始了一个雄心勃勃的项目,要用蓝色瓷砖设计一个大厨房。然后,当他们下订单时,发现库存中没有足够的蓝色瓷砖来满足订单。如果该商店正在运行PostgreSQL,取消嵌套厨房事务的客户可以继续购物,而厨房区域的其他客户将立即可以使用蓝色瓷砖。使用CockroachDB,其他客户必须等待第一个客户检查他们的整个购物车,然后才能订购蓝色瓷砖。

因此,当前行为上的这种差异可能会真正影响面向商业的客户端应用程序或任何类型的OLTP系统的设计。我们承认这一点;这种与SQL标准的差异可能会在CockroachDB的未来版本中消除。

另一个限制是,回滚到保存点还不知道在某些情况下如何倒回DDL语句。

例如,在其他SQL引擎中,可以使用ROLLBACK TO SAVEPOINT回滚来回滚CREATE TABLE或DROP INDEX,并让事务继续进行,就好像DDL根本没有发生一样。

在CockroachDB中,尝试在DDL语句之后回滚已执行某些DDL语句的嵌套事务时,可能会遇到错误";ROLLBACK to SAVEPOINT&NOT SUPPOINT。

此限制可能会在后续版本中取消。同时,与以前的版本一样,CockroachDBv20.1可以在外部“常规”事务上使用ABORT/ROLLBACK取消DDL的影响。

这一限制的具体原因是CockroachDB缓存了有关由事务内的语句访问的表的元数据。回溯嵌套事务时,需要使这些缓存无效,但仅针对嵌套事务内更改的表的子集。这些高速缓存的管理中的这种额外的复杂性尚未实现。

此限制的细节还提示回滚到保存点实际上可以取消DDL的时间:如果没有使用SQL表(任何表!)的查询。在嵌套事务之前。对于由于基于组件的设计而需要嵌套事务的客户端应用程序来说,这并不是一个特别有趣的案例,但是出于一个无关的原因,CockroachDB客户端恰好很常见;请参阅下一节。

提醒:在各种情况下,数据读写操作或事务提交可能会与并发事务发生冲突。当当前事务和另一个事务碰巧对相同的数据进行操作,并且两者中的一个正在写入时,就会发生这种情况。CockroachDB非常努力地在内部处理此冲突,并对SQL客户端应用程序隐藏它。但是,在某些情况下,内部解决方案是不可能的,冲突将作为sql错误报告给客户端,同时SQLSTATE 40001和包含字符串变体的错误消息重新启动事务";。这种一般情况对于实现SERIALIZABLE隔离级别的所有SQL引擎都是常见的,包括从1.0版开始的CockroachDB和PostgreSQL。

从CockroachDB v20.1开始,回滚到保存点不能取消由可序列化冲突引起的错误状态,除非是在一个非常特殊的场景中:当要倒回的嵌套事务是最外层事务的直接子事务,并且其写入集与其完全重合时。换句话说,只有当外部事务在创建嵌套事务之前没有写入任何内容时才有可能;这包括隐藏写入,例如当事务访问存储的SQL表时执行的写入。

Begin;Insert;SavePoint Foo;--嵌套事务从此处开始,开始于SavePoint Insert;--遇到可串行化失败,无法回滚到SavePoint Foo;#34;--回滚本身导致错误40001--";重新启动事务:无法回滚到保存点";

遇到该错误的原因是,在创建嵌套事务之前,外部事务(第一次插入)中存在写入。

BEGIN;SAVEPOINT FOO;--嵌套事务开始于此处INSERT;--遇到冲突和错误40001回滚到保存点FOO;--OK INSERT;--其他内容释放保存点FOO;COMMIT;--结束txn。

以下内容也适用,并且还说明了。

..