此页面为机器翻译。请阅读英文原文。 English

Firebird中的事务:ACID、隔离级别、死锁及更新冲突的解决

Alexey Kovyazin,在Vlad Khorsun和Dmitry Kuzmenko的帮助下,2019年4月8日

目录:

是否有必要了解事务的工作原理?

可能是因为事务的概念很简单,很多开发人员低估了在Firebird中正确使用事务的重要性。然而,只有在你深入理解了事务的工作原理之后,你才能理解许多与性能相关的神秘现象,比如数据库突然变慢(这与由于糟糕的事务管理而产生的过多记录版本有关)。

一般来说,事务的概念适用于任何从一个状态转移到另一个状态的动态系统。例如,一个经典的事务示例是从一个账户向另一个账户转账。通常,它看起来像这样:

Code

开始   --- 从账户1向账户2转账
 --减少账户1
 --增加账户2
结束 - 提交事务

这个例子的核心在于,钱必须同时从账户1消失并出现在账户2中,否则系统中会出现多余的钱或一段时间内无法解释的资金短缺。

从数据库的角度来看,事务通常被定义为一组对数据库执行的操作,这些操作被视为独立于其他事务。在我看来,这个定义并不比其他定义更好或更差,但正如任何定义一样,如果不了解DBMS的实际内部工作原理和逻辑,它就没有多大意义。

人们认为,数据库中的事务必须满足所谓的ACID要求:

Code
A - 原子性(Atomicity)
С - 一致性(Consistency)
I - 隔离性(Isolation)
D - 持久性(Durability)

许多数据库应用程序的开发人员对这个缩写词如此着迷,以至于在比较不同DBMS时,他们经常使用诸如"你的ACID中没有D"这样的论点(通常紧接着就是"我不在乎你怎么想")。

实际上,一切都很简单–ACID是特定DBMS中事务实现的一组要求,其中一些非常严格(例如,D–当然,持久性很重要!),而另一些则不那么严格–当我们查看事务隔离级别时,我们会发现隔离性可能会有所不同。

这就是为什么不值得试图立即理解这个缩写词的字面含义。相反,我们将研究DBMS(特别是事务)的工作逻辑,并从"它是如何实现的"而非"它意味着什么"的角度来看待ACID。

由于事务的各个方面很复杂,我们需要一种图形表示–一种图表,来展示事务的工作和交互。利用这些图表,我们将能够构建一个逻辑叙述,并深入了解事务的工作细节。

首先,我们将引入一个时间线,因为事务是在时间中发展的。时间线将按照我们需要的方式标记–我们既不需要秒也不需要分钟,而是需要事务之间交互的关键步骤:

然后我们将一个事务添加到这个时间线上–让我们以矩形的形式绘制它,矩形的边将对应事务的开始和结束。由于Firebird中的所有事务都有编号,我们还将指定事务编号。

因此,图表显示了事务编号11,它在时间t3开始,在时间t10结束。事务有两种结束方式–COMMIT,即应用事务中所有更改;以及ROLLBACK,即取消事务中所有更改。我们将以下列方式显示事务的结束方式:

为了继续下去,我们将不得不在这些图表上指定事务的各种参数,我们将在代表相应事务的矩形的左下角指定它们–这个示例显示事务#11具有快照(snapshot)隔离级别。

当我们说"事务X插入数据"或"事务Y读取了这些数据"时–这在形式上是错误的,因为我们应该说"更改是在事务X内进行的"。只有SQL语句才能读取或插入数据,所以,如果这对叙述很重要,我们将在事务矩形内显示这些语句:

在这个示例中,我们有对表T1、字段i1、值100的INSERT操作–这个操作在事务#11内执行并被提交。

此外,我们有时还需要显示操作的结果,例如,在以下示例中:

这个示例显示了以下内容:

  1. 隔离级别参数设置为snapshot的事务#11(隔离级别将在后面讨论,这里显示它只是为了描绘完整的图景)在时间t3开始
  2. 将值100插入表t1的字段i1的INSERT INTO T1(i1) values (100)操作在时间t5开始并在时间t7结束
  3. 返回i1值等于100的SELECT i1 from T1操作在时间t8开始
  4. 事务#11以COMMIT语句结束,即事务#11所做的更改被提交到数据库

因此,借助事务图表,我们可以详细描述数据库中发生的事情,并了解事务是如何工作的。

现在我们已经有了事务图表,让我们看看ACID缩写词真正意味着什么。

原子性

原子性意味着组成事务的所有操作要么全部执行,要么全部不执行:“要么全有,要么全无”。这看起来很简单,但随后细节就显现出来了。

首先,DBMS(不仅是Firebird,几乎所有DBMS)有两种类型的原子性:语句级别的原子性和事务内语句组级别的原子性。

语句级别的原子性意味着UPDATET1 SETX=1 WHEREY=2语句总是要么成功执行,要么不执行。

语句组级别的原子性工作方式不同(我们将在这里使用伪代码来标记事务何时开始和提交):

Code
开始事务 11
INSERT ..100
INSERT ..200
INSERT ..300
提交 11

这在图表上大致如下所示:

这意味着所有三个INSERT语句都成功执行,并且它们所做的更改在事务#11提交时被提交。

我在研讨会上经常问到的关于事务的问题是–如果INSERT INTO..300引发异常,事务11的COMMIT语句是否仍会成功执行:

相当大一部分听众总是回答COMMIT语句不会成功执行!(有趣的是,在其他一些DBMS中,这会导致事务被回滚!)

然而,事实并非如此–只需运行isql并对任何数据库进行实验(isql的操作实现简单直接,不会为用户"猜测")。

关键在于,语句组级别的原子性通过事务提交来保证,这是一个业务逻辑问题。应用程序的开发人员必须决定在第三个INSERT语句出现异常时是否应该提交事务。如果业务逻辑允许提交结果,那么COMMIT语句完全可以执行。

因此,ACID中的原子性要求是指DBMS能够提交或回滚在一个事务中执行的一组语句的结果。提交还是回滚的决定取决于你需要实现的业务逻辑。

让我们再次强调–尽管事务对一组语句的原子性意味着无论结果如何都可以提交或回滚整组(选择取决于业务逻辑),但单条语句的原子性是由DBMS的实现保证的,即不可能“不完整”(非原子地)执行一条语句(例如UPDATE)。

一致性

一致性意味着数据库中的数据不存在矛盾。当然,在这里我们可以看到一片推测的领域,因为“不存在矛盾”到底是什么意思?

通常会区分出两个一致性级别:

  1. 数据库级别,一致性指数据符合数据库约束,如主键、唯一键、外键、检查约束。这一级别的一致性由数据库约束确保,即约束不会允许插入不符合约束的数据:例如CHECK(x>0)不会允许向相应字段插入负数。
  2. 业务逻辑级别,一致性由应用程序开发者借助DBMS提供的工具(如事务)来保证。

事务如何帮助在业务逻辑级别确保一致性?很简单–以转账为例,开发者必须确保在发生异常时所有更改都被回滚,使用事务可以帮助他做到这一点。

Code
开始事务
减少账户1中的金额…. 成功
增加账户2中的金额… 失败
回滚 ---- 在发生异常时!

换句话说,开发者必须编写代码,使得在发生异常时数据被回滚,从而从业务逻辑的角度保持数据的一致性。

这样,ACID缩写中的一致性要求意味着DBMS必须有可能借助事务机制来维持数据的一致性。

隔离性

事务隔离的要求源于保证一组操作的结果无论以何种顺序执行都相同的必要性。

简单来说,每个事务必须产生相同的结果,无论是否有并发事务同时处于活动状态。

事务机制旨在确保业务逻辑级别的一致性,但它也旨在保护事务免受并发事务执行过程中可能出现的临时未确认数据的影响。

在实践中看起来是这样的:

我们看到事务#11在t2时刻启动,在t3-t5时刻向表中插入数据。事务#11在插入后并未立即提交,而是继续活动直到t8时刻。

同时,事务#12启动并执行SELECT语句,查询事务#11插入记录的表。第一个SELECT语句在t6时刻执行,此时插入操作已经完成,但该语句返回空结果,因为事务#12无法看到来自其他事务的未提交数据。

事务#11在t8时刻提交,事务#12中的SELECT语句在t9时刻执行。它返回结果为100,因为事务#11中创建的数据现已提交(并且因为事务#12的隔离级别是读已提交,但我们稍后会讨论这一点)。

这个例子足以说明隔离要求–与原子性和一致性不同,隔离是作为称为隔离级别的严格规则实现的,每个事务必须有一个参数来设置其工作的隔离级别。

持久性

持久性的概念使开发者能够完全信赖这样一个事实:在已提交事务中创建的数据会立即出现在数据库中,并且不会从数据库中消失(当然,除非有显式的删除或更改语句),无论之后发生什么。

正如你所看到的,持久性要求只是常识–很少有人会愿意使用一个数据可能突然消失的系统。

ACID:总结

ACID意味着事务必须如何工作的要求:

  • 原子性
    • 语句始终是原子的
    • 一组语句可以借助事务实现原子性
  • 一致性
    • 两个一致性级别:数据库约束和业务逻辑
  • 隔离性
    • 由事务机制通过为其设置的隔离级别来确保
  • 持久性
    • 所有已提交的数据变为永久数据

如你所见,一切都相当合乎逻辑。在实践中,主要的难点在于隔离级别,所以让我们详细看看它们是如何工作的。

事务的隔离级别定义了该事务可以看到哪些已提交的数据。

有一些通常被称为标准的隔离级别。它们在ANSI SQL标准(各种修订版)中有描述。据我所知,没有任何一个DBMS完全按照标准中的描述来实现它们,但没有人担心这一点,因为特定DBMS中事务的实际机制拥有实现业务逻辑所需的所有选项。

你可以在“A Critique of ANSI SQL Isolation Levels”中找到隔离级别的经典定义。

对于读过这篇文章的人,这里有一个表格,将经典隔离级别与Firebird中的类似级别进行比较。当然,对应关系并不是直接的,因为Firebird中的隔离级别以及其他DBMS中的隔离级别并不100%符合ANSI SQL定义,但它们非常相似。

ANSI隔离级别 Firebird中的隔离级别
读未提交 不适用
读已提交 读已提交
可重复读 快照
可串行化 快照表稳定性

与任何其他DBMS一样,Firebird在隔离的实现上有其特殊性。现在我们将专注于隔离级别在Firebird中如何工作,而不是它们与标准的符合程度。

快照隔离级别

快照隔离级别是InterBase原始代码中的第一个隔离级别,并且仍然是Firebird核心API和实用程序(例如isql.exe)的默认级别。这可能是它最容易理解的原因。

快照将事务与从其开始时刻起所做的任何更改隔离开来。

让我们看看下面的事务图:它显示了以快照隔离级别启动的事务#10。在此事务中,对表T1执行了几个SELECT语句,在此示例中该表没有记录。

并发事务#15在事务#10启动后启动,向表T1插入数据,该事务在t9时刻以COMMIT语句结束,即数据在此刻提交到数据库,并可供其他事务的语句使用。

然而,事务#10中在t10时刻(即事务#15提交之后)执行的语句看不到插入的数据,因为快照隔离级别只允许它看到在事务#10开始之前插入或更改的已提交数据。

因此,快照隔离级别允许你像数据库在事务启动时被冻结一样工作。它通常用于构建基于快速变化数据的复杂报告:使用快照是为了避免报告的第一部分基于某些数据而最后一部分基于不同数据的情况。

然而,这个强大的功能也有其代价–当我们稍后研究Firebird中隔离级别的实现方式时,您将看到,使用快照隔离级别启动超长事务会导致过多的记录版本和较低的性能。

读已提交隔离级别

使用读已提交隔离级别的事务可以看到在其活动期间其他事务已提交的数据(与快照级别不同,快照级别只能看到事务开始前已提交的数据)。

让我们通过下图来展示读已提交隔离级别的工作方式:

该图展示了一个与之前几乎相同的示例:两个并发事务,其中一个定期从表T1读取数据,而另一个插入并提交数据。

与快照隔离级别的情况不同,此示例中的事务#10可以看到事务#15插入并提交的数据。

这个示例让我们了解了读已提交隔离级别的影响:具有此隔离级别的事务中的语句可以看到在相应语句执行之前已提交的数据。

下图展示了一个示例,其中两个并发事务#11和#18修改数据。

请注意,事务#11在读取数据的事务#14开始之前启动,而事务#18在其之后启动,但这不影响结果:如果数据已提交,则具有读已提交隔离级别的并发事务可以看到它。

这种可能性使读已提交隔离级别成为那些定期执行以显示最新数据库状态的SQL语句的自然选择(例如,为了显示最新订单)。

专门讨论垃圾回收的部分将表明,在Firebird 4.0及更早版本中,带有只读修饰符的读已提交事务是“无限”读事务的最佳选择,因为它们以预提交方式启动。

快照表稳定性隔离级别

关于快照表稳定性隔离模式(它是标准可串行化隔离模式的对应物)的故事,可以讲得很短,也可以讲得相当长且详细。

简短版本如下:该级别与快照级别完全相似,但额外锁定了表(必须在事务参数中显式指定该表)以进行写入和读取。这意味着可以启动一个事务,该事务将完全占用指定的表,而任何其他事务都将收到访问错误。

换句话说,具有快照表稳定性隔离级别的事务实际上会将所有对指定表的查询放入队列中。实际上,只有常规事务中的读取会按常规方式插队,而所有其他模式将形成队列(当然,这取决于交互方式)。

如果实现不当,可能会导致锁死和无法使用数据库,这就是为什么Firebird数据库应用程序开发人员可能害怕使用此隔离级别。

然而,如果实现正确,可串行化隔离级别可以轻松形成队列并对数据库记录进行顺序更改,这对于实现计数器、顺序文档编号和其他类似对象非常有用。

为了正确描述如何利用具有快照表稳定性隔离级别的事务形成队列,我们需要再看一个事务参数:wait/nowait–然后再回到队列示例。

之前我们研究了事务之间的一种交互方式:数据在一个事务中更改,在另一个事务中读取。

然而,在实践中经常发生不同事务尝试更改相同数据的情况,由于数据库中只保存一个结果,并发事务将收到冲突消息–实际上是一个异常,它将中断(并取消)尝试更改已更改数据的那个特定语句的执行。

wait选项定义了事务应如何应对更新冲突。有三种配置此选项的方式:

  1. Wait(无参数)= 等待直到并发事务结束
  2. Wait Timeout N sec = 等待直到并发事务结束,但不超过N秒
  3. Nowait - 不等待并发事务结束

请注意,这里wait选项以伪代码形式指定,而在API和特定组件中名称可能不同,但含义保持不变。

让我们详细看看在更新冲突情况下,使用wait选项的各种变体会发生什么。

Wait

那么,让我们想象两个并发活动的事务(#11和#14),在其中执行UPDATE语句,该语句必须更改同一个表T1中的同一条记录。

事务#14使用wait选项运行(如果您使用isql重现示例,wait是默认设置的)。

事务#11中的UPDATE语句在时刻t3开始,在时刻t5结束,但事务尚未提交–即COMMIT语句直到时刻t6才出现。

下图展示了这种情况:

事务#14中也执行了UPDATE语句,它尝试更新同一表中的同一条记录,但开始较晚–大约在时刻t4。

由于与事务#11的更新存在更新冲突,并且事务#14中指定了wait,UPDATE语句将等待冲突的事务#11结束。

如果事务#11持续足够长的时间,从观察该语句执行的用户角度来看,事务#14中的UPDATE语句将看起来像被冻结了。

如果您使用两个isql.exe重现此情况,下图显示了第二个事务(确切地说是并发UPDATE语句较晚开始的事务–在我们的示例中是事务#14)等待第一个事务结束(在我们的示例中是事务#11)的时刻。

在事务#11中执行COMMIT语句后,等待它的事务#14将立即收到通知,冲突的更新将以异常结束。

下面您可以看到此类错误消息的示例(并发事务的编号与我们的示例不一致,因为每个数据库中的事务编号从开头开始,然后仅递增,只有在备份/恢复后才会重置):

Code
SQL> update T1 set i1 = 2 where i1=1;
Statement failed, SQLSTATE = 40001
deadlock
-update conflicts with concurrent update
-concurrent transaction number is 19
SQL>

请注意错误消息中的“deadlock”一词–实际上根据其经典定义,现在并没有死锁。相反,存在更新冲突,但Firebird开发人员不会更改错误消息,因为它已经使用了超过35年。我们稍后将处理真正的“经典”死锁。

因此,我们已经研究了并发UPDATE语句以COMMIT语句结束的情况。现在让我们看一个类似的情况,但这次是回滚–您可以在下图中看到:

情况与之前完全相似–两个UPDATE语句尝试更新同一条记录,但这次并发事务#20最终被回滚,因此事务#15中的更改被保存到数据库中而不会出错。

因此,wait选项使得可以组织更新业务逻辑,使冲突的更新无限期地在队列中等待,直到最后一刻希望与它们冲突的事务以ROLLBACK语句结束。

这种策略是否总是有意义?当然,这取决于业务逻辑的实现,但Firebird也提供了其他选项,借助wait选项来解决更新冲突。

带超时的Wait

首先,限制等待时间可能是个好主意–与其在冲突情况下无限等待,不如通过为wait选项指定超时来限制等待时间。

在isql.exe中,这样的参数可以通过以下语句指定:

Code
SET TRANSACTION WAIT LOCK TIMEOUT N;

其中N是并发事务等待冲突解决的时间(以秒为单位)。

你可以在Firebird Language Reference中找到关于事务控制语句的更多详细信息。请注意,在特定的驱动程序或访问组件中,指定超时的方式可能有所不同(通常通过API参数实现)。

你可以在下图中看到isql中的一个示例:

让我们借助事务图表来研究,如果为wait选项指定超时,事务之间是如何交互的。

所以,情况是一样的–两个并发事务#11和#14,在其中执行UPDATE语句,试图更新表T1中的同一条记录。

然而,在这种情况下,事务#14中的语句会等待,直到事务#11结束,或者直到指定的超时时间(3秒)到期–以先到者为准。

在这个示例中,超时先到期,语句以异常结束:

Code
SQL> update T1 set i1=6 where i1=1;
Statement failed, SQLSTATE = 40001
lock time-out on wait transaction
-deadlock
-update conflicts with concurrent update
-concurrent transaction number is 40

请注意,错误消息中再次出现了“deadlock”,但这仍然不是“真正的”死锁。

相应地,这种情况与使用wait选项的情况类似,但受超时限制–如果指定的超时时间早于并发事务结束的时间。

如果你确定所有写事务都非常短(例如,不超过1-2秒),那么指定带超时的wait选项可能是实现业务逻辑的一个好解决方案。

Nowait

从形式上讲,Nowait很容易解释–它就是零超时的wait。如果你在事务中指定nowait,冲突的更新将立即引发异常。

在这种情况下,我们再次有并发事务#11和#14(nowait),其中执行了并发的UPDATE语句。在nowait选项的事务中,语句在遇到并发更新时不会等待,而是在其更新时立即引发以下异常(只是事务号不同):

Code
SQL> update T1 set i1=5 where i1=1;
Statement failed, SQLSTATE = 40001
lock conflict on no wait transaction
-deadlock
-update conflicts with concurrent update
-concurrent transaction number is 50
SQL>

以下是两个isql工具示例中的样子:

请注意,nowait事务不关心带有并发UPDATE语句的事务何时以及如何结束–无论是COMMIT还是ROLLBACK语句,异常仍然会被引发。

从业务逻辑的角度来看,如果你确定并发更新必须毫无疑问地取消当前语句的操作,那么nowait事务可能很方便。

许多Firebird驱动程序将nowait选项作为默认值,而且由于许多开发人员不知道可以设置更宽松的更新冲突解决级别(例如,wait lock timeout 1),他们的应用程序(有时也包括用户)会因冲突而遭受不必要的错误。

由于关键字“deadlock”出现在每个与更新冲突相关的异常中,许多应用程序开发人员确信这就是真正的死锁(有些人甚至认为这个错误中Very Dead起了作用)。

同时,如果我们查看配置文件firebird.conf,我们会看到DeadlockTimeout参数(默认值为10秒),如果我们查看fb_lock_print实用程序的输出头,我们也会看到“Deadlock scans”参数。

问题是,Firebird中确实可能存在“真正的死锁”,而出现在所有与更新冲突相关的异常中的关键字“deadlock”与它没有直接关系。幸运的是,真正的死锁很少发生。

让我们看看这个“真正的死锁”是什么。为此,请看下面的事务交互图表:

我们有两个带有wait选项的并发事务,其中执行了UPDATE语句。与简单的更新冲突不同,这里我们看到的是相互依赖的更新冲突:

  • 事务#11更新键=20的记录,事务#12更新键=10的记录;
  • 之后,事务#11更新键=10的记录,事务#12更新键=20的记录;

结果,我们遇到了一种情况,每个事务都必须等待另一个事务结束,而且两者都可能无限等待,因为两者都指定了wait选项。当然,服务器不能允许这种情况发生,因此其中一个事务将在DeadlockTimeout参数指定的超时(默认值为10秒)之后被强制回滚。

我们可以借助两个isql来重现这种情况:

在第二个事务启动后,就会出现真正的死锁情况。为了确定这一点,服务器会启动一个名为Deadlock scan的过程–它以等于DeadlockTimeout(默认值为10秒)的间隔启动。

请注意,客户端(本例中为isql)会收到常规的更新冲突消息,但即使事务以wait选项启动,它也会在10秒后触发。

服务器检测到两个事务的相互依赖锁后,还会递增内部死锁计数器(你可以在fb_lock_print输出中看到)。

Snapshot Table Stability的实际应用

现在我们已经了解了事务如何处理冲突的UPDATE语句,我们可以回到Snapshot Table Stability隔离级别,并找到它的实际用途。

所以,当指定此隔离级别时,表会被锁定,禁止写入甚至读取。

请注意,如果事务参数中没有明确指定表,则此事务中语句访问的所有表都会被锁定,并且这发生在第一次访问表时。显然,如果不谨慎使用此隔离级别,很容易导致大量更新冲突。

Reserving TableNN子句允许你指定在事务开始时锁定特定表(或几个表)(也可以指定保留模式)。

这个强大的功能与wait选项结合,允许你实现一个非常有效的顺序队列来更改特定表。

在实践中,它看起来是这样的–那些需要为特定表创建队列的客户端启动SNAPSHOT TABLE STABILITY事务,指定该表,然后尝试在此事务中执行操作并立即结束它。

例如,我们想在一个只有一条记录的CREATE TABLE Table1(i1 integer not null)类型的表中创建一个顺序递增的计数器,但由于某种原因我们不能使用生成器。

伪代码大致如下:

Code
set transaction snapshot table stability reserving TABLE1 for protected write

UPDATE Table1 Set i1 = i1+1;
SELECT i1 from Table1;
COMMIT;

如果我们不是以快照表稳定性(Table1)隔离级别运行此代码,而是以较低的隔离级别运行,那么在事务开始之后、其 UPDATE 语句执行之前,可能会有并发的 UPDATE 语句介入。结果,我们要么立即收到更新异常(nowait),要么语句将冻结直到并发事务结束(wait),要么超时(wait interval)–换句话说,冲突将在语句级别以某种方式得到解决。

使用快照表稳定性隔离级别,我们可以免受此影响,因为表在事务开始时就被保留–它要么完全属于我们,要么完全不属于我们。如果我们指定 wait 选项来解决冲突,并行连接将自动形成队列,而无需处理任何错误。

当然,这种方法只能应用于短事务(就像我们示例中的那样)。

在实践中,快照表稳定性隔离级别用于在独占模式下形成队列和重新计算复杂逻辑(在相对较小的表中或没有其他用户时)。

在引擎内部,Firebird 使用快照表稳定性隔离级别来创建索引–即当您执行 ALTER INDEX indexname ACTIVE; 语句时,Firebird 将完全占用正在构建索引的表。

下一步是什么?

本文仅介绍 Firebird 事务概念的入门知识。要完全理解 Firebird 中事务的工作方式,有必要考虑多代架构(记录版本和垃圾回收概念),考虑事务标记(Oldest Interesting、Oldest Active、Oldest Snapshot 等)以及其他内容。

本文基于“All About Transactions”研讨会/工作坊的材料,该研讨会于 2013 年在 Firebird Tour 研讨会期间首次推出,并基于 IBSurgeon 的培训“Firebird Transaction in details”。

联系方式

[email protected] 如有任何问题或建议,请随时联系我们:[email protected]