技术栈

主页 > 大数据 >

事务与隔离级别------《Designing Data-Intensive Applications》读书笔记10

技术栈 - 中国领先的IT技术门户

和数据库打交道的程序员绕不开的话题就是:事务,作为一个简化访问数据库的应用程序的编程模型。通过使用事务,应用程序可以忽略某些潜在的错误场景和并发问题,由数据库负责处理它们。而并非每个应用程序都需要事务,有时削弱事务性担保或完全放弃事务,可以获得更高的性能或更高的可用性。怎么样更好的理解数据库中的事务与隔离级别呢?我们借这篇文章来聊一聊吧~

1.ACID

1983年,Andreas Reuter and Theo Härder 提出了事务之中重要的四个特性:

  • 原子性(Atomicity)
    一般来说,原子指的是不能分解成更小的部分的东西。如果写操作被组合到一个原子事务中,并且由于一个错误,事务不能完成,那么事务将被中止,数据库必须丢弃或撤消它在该事务中所做的任何写入操作。原子性简化了数据库的数据模型:如果一个事务被中止时,应用程序可以确保它没有任何改变,因此可以被重试。

  • 一致性(Consistency)
    一致性的表述是:数据库之中的数据必须始终正确。例如,在一个会计系统,所有账户的收支必须平衡。应用程序有责任正确定义其事务,从而保持一致性。这不是数据库能保证的:如果你写了违反你的不变量的坏数据,数据库不能阻止你。应用程序可能会依赖于数据库的原子性和隔离性以达到一致性。

  • 隔离性(Isolation):
    数据库由多个客户端同时访问时,如果他们访问相同的数据库记录,你会遇到并发问题。如下图所示:


    隔离性意味着并发执行的事务彼此隔离,数据库确保当事务提交时,结果与它们顺序运行相同,即使它们实际上是并发运行的。

  • 持久性(Durability):
    持久性是一个承诺,一旦事务成功提交,它所写的任何数据将不会丢失,即使有硬件故障或数据库崩溃。在单节点数据库中,持久性通常意味着数据已写入非易失性存储(如硬盘驱动器或SSD)。它通常还需要写入日志,以便出现文件损坏时恢复工作。在分布式数据库中,持久性可能意味着数据已成功复制到一些节点上。

在几种特性之中,隔离性是DBA对数据库调优最为侧重的部分,接下来,我们着重来聊一聊事务的隔离性。

2. 隔离级别

如果两个事务不触及相同的数据,它们可以安全地并行运行,因为两者都不依赖于其他数据。当一个事务读取另一个事务同时修改的数据,或者两个事务试图同时修改同一数据时,便会出现并发问题。

并发错误很难通过测试发现,因为这种的错误触发具有偶然性,通常很难重现。并发性也很难推理,尤其是在大型应用程序中,因为开发人员不一定知道其他代码片段正在访问数据库。所以数据库通过提供事务的隔离性来隐藏应用程序开发者的并发问题,屏蔽了底层数据库的并发细节,提供了一个串行化的数据模型。

天下没有免费的午餐,串行化的隔离级别会带来额外的性能开销,所以许多数据库会提供一些弱隔离级别作为选择,它们可以防止一部分并发问题。所以,接下来,我们将一一梳理,不同的隔离级别之间的差异。

Read Committed

最基本的隔离级别是Read Committed

  • 当从数据库中读取数据时,只看到已提交的数据(没有脏读)。
  • 当写入数据库时,只覆盖已提交的数据(没有脏写)。
脏读:

一个事务已经向数据库写入了一些数据,但该事务尚未提交或中止。另一个事务可以看到未提交的数据,就称为脏读Read Committed的隔离级别可以防止脏读。所以当事务提交之后,事务中的写操作才对其他人可见。如下图所示:

脏写:

写操作覆盖了一个未提交的值,被称之为脏写Read Committed的隔离级别事务可以防止脏写,通常是通过延迟写操作直到前一个写事务已提交或中止时在继续写入。脏写会导致数据出现不一致,如下图所示:Alice和Bob要买同一个东西,脏写导致了最终的买家是Bob,而发票却寄给了Alice。

实现:

Read Committed是一种十分流行的隔离级别,许多数据库的默认隔离级别便是Read Committed。

数据库通过使用行级锁防止脏写:当事务要修改某个特定行时,它必须首先获取该行的锁。然后必须保留该锁,直到事务提交或中止为止。只有一个事务可以锁定任何给定行的锁;如果另一个事务要写入同一个行,则必须等到第一个事务提交或中止后才可获取锁并继续。

而使用行级锁避免脏读会产生很大的代价,容易找出读延迟。使用当事务正在进行时,读取同一行的任何其他事务都只给出旧值。只有当新值被提交时,事务才切换到读取新值。

Read Repeatable

Read Committed看起来是一个很好的隔离级别了,但是它也会产生一些问题,我们看下面这个例子:如图所示,Alice在一家银行有1000美元的存款,在两个账户上拆分,每个账户有500美元。现在,一个事务从她的帐户转到另一个帐户100美元。如果她很不幸地在事务正在进行的同一时刻查看她的账户余额清单,她可能会看到一个账户余额在收到的款项到达之前(余额为500美元),另一个账户在已进行的转移之后(新余额为400美元),而100美元消失了。

在Read Committed隔离级别之下出现的这种异常被称为不可重复读,我们需要寻找新的解决方案。

快照隔离

为了实现可重复读,我们需要快照隔离的技术。

每个事务都从数据库的快照中读取的,即事务在事务开始时看到数据库中提交的所有数据。即使数据随后被另一个事务更改,每个事务只看到来自特定时间点的旧数据。当事务可以看到数据库的数据,在特定时间点被冻结了。

快照隔离的实现通常使用写锁来防止脏写,这意味着编写的事务可以阻止写入同一对象的另一个事务的进程。实现快照隔离,数据库必须保留数据的几个不同的提交版本,因为各种正在进行的事务可能需要在不同的时间点查看数据库的状态,这种技术被称为多版本并发控制(MVCC)

如下图所示,每当一个事务向数据库写入任何内容时,它写入的数据都会用事务ID进行标记。

当事务从数据库中读取时,事务ID用于决定哪些数据可见,哪些数据是不可见的。在每次更改值时创建新版本,数据库可以提供快照隔离,而只产生较小的开销。

Serializability

Read Repeatable虽然解决了读取数据的问题,但是依然没有办法解决并发写的问题。我们来看看下面这个例子:医院通常在任何时候都要有几个值班医生,必须至少有一位医生在值班。医生可以调整他们的轮班,前提是至少有一个同事在医院值班。Alice和Bob是两位今天值班的医生。两人都想调整轮班,不幸的是,他们碰巧点击按钮大约在同一时间取消轮班。接下来发生的情况如图所示:

由于数据库的隔离级别是快照隔离,两个人都检查到目前有两个人值班,因此两个事务都进入下一个阶段。Alice认为请假没有问题,Bob也认为请假没有问题。两个事务都提交了,现在没有医生在值班了,数据库的一致性出现了问题。

Serializability 被看作是最强的隔离级别。数据库保证,如果事务在单独运行时行为正确,则它们在并发运行时仍然正确,换句话说,数据库防止所有可能的竞争条件。接下来我们将详细来聊一聊Serializability的隔离级别是如何实现的。

两阶段锁(2PL)

数据库发展几十年来,广泛使用的算法:两阶段锁(2PL)

  • 事务A获取了数据的读锁,而事务B想写对应的数据,则必须事务A提交或中止后方可继续写入操作。这可以确保事务B不会意外地改变事务A正在读取的数据。
  • 事务A获取了数据的写锁,事务B想读取对应的数据,事务B也必须等到事务A提交或中止后方可进行读取。
  • 事务A获取了数据的写锁,事务B想写对应的数据,事务B也必须等到事务A提交或中止后方可进行写入操作。

由上面三个规则可以看出,2PL提供串行化的访问,它可以防止任何的并发问题,但是由此带来的问题也显而易见,数据库的并发能力大大降低了。

共享锁与独占锁

两阶段锁的逻辑是通过共享锁与独占锁共同来实现的:
如果事务A要读取数据,则必须先获取共享锁。数据库允许多个事务同时拥有共享锁,但如果另一个事务拥有独占锁,则其他事务要获取共享锁则必须等待。

如果事务A要写入数据,则必须先获取独占锁。任何其他事务都不能同时拥有锁,(无论是共享还是独占)因此如果对象上存在任何锁,事务A必须等待。

如果事务A先读取数据,然后写入数据。它可以将共享锁升级为独占锁。升级与直接获得独占锁相同。

在事务获得锁之后,它必须继续持有锁直到事务结束(提交或中止)。这就是“两阶段”的名称:第一阶段在获取锁时,第二阶段释放锁。

由于使用了这么多锁,所以很容易发生事务A被卡住等待事务B释放它的锁,反之亦然。这种情况称为死锁。数据库自动检测死锁之后会终止事务,然后重启事务排队。

序列化的快照隔离(SSI)

两阶段锁(2PL)由于采取了悲观的并发控制,不但容易引起死锁,且性能低下。所以接下来我们要来看看序列化的快照隔离(SSI),它提供了完整的串行化,但是只有很小的性能损失相比两阶段锁。

当我们以前讨论快照隔离中的并发写问题,是因为事务从数据库读取一些数据,检查读取结果,并决定根据它看到的结果采取一些操作。然而,在快照隔离的情况下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在此期间进行了修改。所以查询和事务中的写之间可能存在因果依赖关系。为了提供串行化隔离,数据库可以检测到这种情况,并且终止不合法的事务。

检测是否读取旧的数据

快照隔离通常采用多版本并发控制实现,当一个事务读取一个数据库的一致性快照,它忽略了新的写入。为了防止这种异常,数据库需要跟踪事务时读取时是否忽略了另一个事务的写操作,当事务要提交时,数据库检查任何已忽略的写操作。如果忽略了写操作,则必须中止事务。

为什么要等到提交时,而不是检测到读取旧数据时就立即终止事务呢?那么,如果事务如果是只读事务,则不需要中止,在事务进行读取时,数据库还不知道该事务是否稍后将执行写入操作。上文Alice与Bob请假的例子可以通过这样的方式避免并发写的问题:

检测影响先前读取的写入

如果并没有检测到读取了旧的数据,仍然有可能出现并发写入的问题。

所以当事务写入数据库时,它记录读取受影响数据的任何其他事务的索引。一旦第一个事务是成功提交,其他所有相关的索引事务必须终止。通过这样快照隔离的方式,保证了并发写入的安全性。同样是上文的例子,下图暂时了索引终止技术:

许多工程细节影响算法在实践中的工作效果。跟踪事务的读写的粒度。如果数据库非常详细地跟踪每一个事务的活动,那么它就可以精确地判断哪些事务需要中止,但是这些开销会变得很大。而不太详细的跟踪事务会更快速,但可能导致更多的事务被中止。相比与两阶段锁,可串行化隔离快照是大有好处的:一个事务不需要阻塞等待另一个事务持有的锁。

小结:

我们在本篇之中总结了数据库事务与隔离运用到的多种策略与技术,希望大家能够更好的认识事务在数据库系统之中的重要意义,并且能够为自己的开发环境运用最恰当的隔离级别。

责任编辑:admin  二维码分享:
本文标签: 事务数据库隔离并发写入快照