再谈数据库隔离

本篇是译文,原文地址

当两个或跟更多的工作单位(它可以是进程、线程、程序或任何可执行体)同时工作,特别是访问或者修改同一个数据,这时,并发问题就发生了。

举个现实中的例子,如果在同一个十字路口两辆车同时行进(非同向),没有停止信号或者交通灯,它们彼此看不到对方,最终它们驶向同一个车道,它们相撞了。

相似的,两个程序更新数据库中的同一行数据,其中一个程序可能就会覆写了另外一个程序的数据(考虑不使用数据库锁这种机制)。

即使两个程序都在单线程环境中可以正确执行,并发问题也会影响结果的正确性。

数据库事务以及它所带来的帮助

自然地,开发人员很擅长写单线程的程序。
数据库事务是一个非常好的想法。它给开发提供了单线程的环境错觉。它降低了并发性问题的复杂性。程序员只需要保障单线程环境下程序的正确性。

但是,数据库事务是如何提供这种幻觉的?如果你学习了数据库,你可能还记得 ACID 的概念。它代表了数据库事务的四大属性—原子性、一致性、隔离性和持久化。后续我们会说到它们,本文,我们关注隔离属性。

隔离,确保了事务非并发的执行。换而言之,事务执行是串行的。

然而,任何事的到来都是要付出代价的。我们在上面提到的,是非常严格的隔离保障,它的性能消耗,比如延时(事务执行所花费的时间)和吞吐量(每秒内数据库系统能处理完成多少事务)。实际上,出于性能考虑,现有的数据库实现默认不会提供这么严格或者默认关闭了这么严格的隔离。

正如其它计算机科学中的系统,这是一个平衡。为了获得更好的性能,人们通常会弱化隔离保障。因此,隔离等级的概念应时而出。不同的隔离等级,定义了不同程度的隔离严格程度。

为了回顾和理解不同的隔离等级,我们先从并发系统中的异常开始了解。

并发系统中的异常

异常,或者说并发bug,是在并发系统中常见的问题。注意,这些异常仅仅是出现在数据库系统中事务被并发执行的时候(时间上有重合),隔离,并不能完全确保异常被消除。一旦这些事务被串行执行,异常将不再存在。

更新丢失

这是一个写-写冲突问题—连各个事务并发更新同一行数据,其中一个的更新将丢失。
举例而言,数据库行 X 的初始值是 0,我们有两个事务 T1 和 T2,T1 读取 X(=0),并将该值加一。同时,T2 读取 X(=0),将值加二。
如我们所见,最终 X = 2, T1 的更新丢失了。如果串行执行两个事务,最终的结果是 X = 3.

脏写

当两个事务同时更新某一行时,将可能会发生脏写。第一个事务更新了行中的某些数据,但是接着又回滚了。另外一边,第二个事务读取了未提交的写入,并更新了该行数据。
举例来说,在数据库中,一行中 X = 0 是默认的。接着,

  • (1) T1 读取了 X(=0)
  • (2) T1 将 X 加一(X=1)
  • (3) T1 放弃并回滚
    如果,T2 在上述(2)(3)之间开始执行,此时,T2 读取到 X(=1),并将 X 加一(X=2)后提交。
    我们可有看到,这里是有问题的,因为 T2 写入数据 X,是基于 T1 中未提交i的数据。如果两个事务串行的执行,X应该等于 1.

脏读

和脏写类似,不同之处在于,第二个事务在它读取了未提交的数据之后,并不需要写入。
举例来说,如果 X 的初始值是 0,

  • (1) T1 读取 X(=0)
  • (2) T1 将 X 加一(X=1)
  • (3) T1 放弃并回滚
    此时,T2 在(2)(3)之间开始执行,T2 读取了 X(=1),并将该数据返回给客户端,因此在客户端可以看到 X = 1.
    如我们所见,用户就看到 X = 1 也是有问题的,因为这个数据并未提交,并且最终回滚了。如果这两个事务串行执行,用户将看到 X = 0.

不可重复的读

这是由于读写冲突造成的。当一个事务读取同样一行数据两次,但是,两次读取之间,另外一个事务提交了该数据的写入。
举例来说,T1 读取到 X = 0,T2 将 X 更新到 1 并提交了该数据。T1 再次读取 X,这时,它发现 X = 1。
T1 发现了由 T2 引发的数据变化。如果两个事务是串行执行的,我们的期望值是 T1 两次获取到的 X 是 0 或者 1(具体取决于两个事务的执行顺序)。

幻读

在同一个事务中,执行相同的两次查询,得出的查询结果不同。这中间,可能出现另外一个事务做了一些更新影响到了第二次的查询结果。
举例而言,初始时,A表中有两个记录。 T1(原文中是 T1,但是此处应该是 T2) 查询 A表 并找到了这两个记录。 T2(应该是 T1) 在 A表 中插入了一个新行。接着, T1(应该是 T2)次查询时,发现结果中有三条数据。

写偏差

当两个事务并发读取了 X 和 Y 两个行,两个事务又进行了并发的不相关联的更新(T1 更新了 X, T2 更新了 Y),最后,并发提交。两者对对方的更新互不可见。
问题在于,如果 X 和 Y 之间存在约束,比如 X + Y >0. 注意,约束检查在每次更新 X 或者 Y 时,是每次都执行的,但是,并发更新时,约束可能会失效。
如果串行执行两个事务,T1 或者 T2 都将由于约束而放弃执行。

隔离等级

如果定义比完美隔离弱化的隔离等级?(译者注:不采用严格的完美的串行化隔离策略,大概是性能消耗太大吧)
如果数据库系统要确保完美的隔离,它就要保障不会有并发异常发生。顺着这个思路,我们可以通过释放一些约束的方式定义弱化的隔离等级。
ISO SQL 标准定义了一些弱化的隔离等级,在这里,某些等级下异常是被允许的。
举例来说,最弱化的隔离等级—Read Uncommitted—允许三个异常—脏读,不可重复读以及幻读。而最严格的隔离等级—Serializable—不允许这三个异常发生。

你可能会好奇,上文中我们提到了三种异常,但是在 ISO SQL 标准中只提到了三种,剩下的三种怎么办?接着阅读。

ISO SQL 标准怎样定义隔离标准的很多问题,在这里已经提到了。你提出的问题,在其中也有描述。不幸的是,多次校正之后,ISO SQL 标准仍然没有修复它们。

让我们来回顾以下 ISO SQL 标准中已经定义的隔离等级的问题。

现有的隔离等级存在的问题

首先,目前的 ISO SQL 标准定义隔离标准时所考虑的异常并不够全面。它只提到了三种类型的异常,其它的异常比如更新丢失、脏写和写偏差都没有提到。因此,不同的数据库据系统实现厂家可能会自己根据不同异常类型定义不同的隔离等级,这将导致隔离等级的不一致性。如果这种差异没有被厂商以文档的形式很好的提供出来,这对于开发来说会更加糟糕。

其次,目前的 ISO SQL 标准没有提供对快照隔离等级的定义,这在实际过程中,实际上是很受欢迎的。因此,不同的数据库系统实现,实现方式也有差异。
快照隔离,确保在一个事务中所有的读操作所看到的是同一个数据库快照。数据库采用了快照一致性,每个快照都都是提交完成的数据。(在实际过程中,快照隔离典型地采用了 MVCC 多版本并发控制机制)

当一个事务开始,它将持有上个数据库快照的引用。事务内所有读操作的数据,来自同一个快照。重复读一个数据获得的结果是一样的。因此,非重复读异常将不会发生。另一方面,写入讲导致一个新的数据库快照产生。如果两个事务并发写入同一个数据,这时其中一个事务会放弃该操作。因此,更新丢失异常将不会发生。然而,如果两个并发事务写入的数据是没有任何关联的,那么写冲突将不会被检测到。因此,快照隔离容易发生写偏差。一些数据库系统实现也容易引发幻读异常。

最后,现有的隔离等级对串行化的定义比较模糊。一方面,SQL 标准所定义的串行化是正确的—事务之间串行执行,不会有重叠的部分;另外一方面,上述的隔离等级表似乎暗示着只要隔离等级不容易触发被上面提到的 3 种类型的异常,它就是串行化的。

作为一个应用开发者,我们可以从中学习到什么?

在本篇文章中,我们回顾了并发问题,以及数据库事务以及 ACID 属性对并发问题的处理。
我们深入了解了数据库事务属性—隔离。
我们知道,越加严格的隔离限制,意味着越大的性能问题。根据不同的用例,开发者需要自己做这个平衡,选择合适的隔离等级。
我们知道了六种并发异常。不幸地是,目前 ISO SQL 标准并没有清晰完整地定义好隔离标准,只定义了六种异常中的三种。这会导致很多冲突(译注:没有统一的标准)。
现今,不同的数据库厂商对不同的隔离等级提供了含糊不清的定义,使用者需要深入了解数据库系统的实现细节以更好的处理这些问题。

作为一个开发者,我们可以做什么

当我以前在 google 在分布式系统上数据一致性问题的时候,找到了 Daniel Abadi 教授的博客。在博客里发现了大量优质的内容。但是,不幸的是,离开学校后又忘记了。

然而,在重读了一遍又一遍之后(为了写这篇文章),我觉得很悲伤,作为一个应用开发者,我找不到一个更好的解决方式—如何能把分布式微服务写的更好。

在 Java 中,我们有接口和实体类,**** 接口提供了 API 协议,而实体类提供 API 的具体实现。**** 接口的使用者希望能够被清晰的定义,同时应保持一致性。从而,使用者可以很好的利用接口提供的功能,而不需要去理解背后的实现细节。这就是抽象。
抽象在计算机科学中随处可见,在我看来,这是现代计算机系统成功的基础。

数据库系统为它们的使用者提供了相同的抽象。ISO SQL 标准,在我看来,扮演着在应用和数据库系统之间接口的角色。然而,它的隔离等级定义的非常不好,以至于用户需要自己查看数据库系统的具体实现细节,以便做出更好的判断。这是不可接受的。

作为一个数据库领域的门外汉(我对数据库系统很感兴趣,并计划学习更多。现今,我依然认为我知道的很少),我的建议是:
首先,查找并搜索数据库系统实现的不同点。查找文档,如果文档不能提供一个关于不同隔离等级清晰的解释以及配置说明,那这个数据库可能不适合你。

第二,尽可能地选择更加严格的隔离等级。如上面提到的,你需要确认不同的缺陷以及它们会对应用造成的影响,以确定选择哪个等级。

虽然性能是非常值得考虑且重要的,但实际上这里仍然有变通的地方,比如:查找其它数据库系统的实现,看哪个在同等隔离等级下提供更好的性能;基于一些最佳实践,优化应用代码和数据库设计。

接下来怎么做

这篇文章仅仅是在讨论传统数据库。现今,我们看到对分布式数据库系统的需求越来越强烈。在分布式数据库系统中,为了高可用性和扩展性,数据被复制在多个数据库实例中。我们将在下篇文章中,讨论分布式数据库系统中的隔离问题。

参考引用

我非常推荐 Daniel Abadi 的博客。文中一些图片和思想来源于该博客中,但是原文的内容比此更多。
Introduction to Transaction Isolation Levels
Correctness Anomalies Under Serializable Isolation


译者添加的其它参考链接
并发异常那些事

© 2024 YueGS