likes
comments
collection
share

一文搞懂数据库所有事务隔离级别

作者站长头像
站长
· 阅读数 11

先来简单复习一下事务:事务是将多个读写操作组合成一个逻辑单元的一种方式,整个事务要么成功(提交)要么失败(回滚)。事务是为了简化应用编程模型而创建的,通过事务,应用程序可以自由的忽略某些潜在错误情况和并发问题,这叫安全保证(safety guarantees)。

本文同时适用于单机数据库和分布式数据库,将探索数据库如何防范出错,深入并发控制领域,讨论各种可能发生的竞争条件,以及数据库如何实现已读提交快照隔离可串行化等隔离级别。

1 事务

1.1 ACID

ACID:原子性(Atomicity),一致性(Consistency),隔离性(Isolation)和持久性(Durability)

  • 原子性(Atomicity):在ACID中,原子性表示能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力。如果事务被中止(abort) ,应用程序可以确定它没有改变任何东西,所以可以安全地重试。

  • 一致性(Consistency):对数据的一组特定陈述必须始终成立,即不变量(invariants)。一致性(在ACID意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因为你就算违反了一致性,随便往数据库里面添加数值,数据库也无法阻止你。

  • 隔离性(Isolation):同时执行的事务是相互隔离的——它们不能相互冒犯。传统来讲加可序列化(Serializability) ,每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们按顺序运行(一个接一个)是一样的,尽管实际上它们可能是并发运行的。

  • 持久性(Durability):即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。数据已被写入非易失性存储设备,或者预写日志或类似的文件。

1.2 单对象操作和多对象操作

若是想同时修改多个对象,就需要多对象事务来保持多块数据同步。

多对象事务需要某种方式来确定哪些读写操作属于同个事务。在关系型数据库中,通常基于客户端与数据库服务器的TCP连接:在任何特定连接上,BEGIN TRANSACTIONCOMMIT 语句之间的所有内容,被认为是同一事务的一部分。

单对象写入

当单个对象发生改变时,原子性和隔离性同样适用。

  • 若发送到一半断网,数据库是否保存了那残缺的数据?
  • 若数据库在覆盖磁盘中一个值时断电,是否将新旧值拼在了一起?
  • 若另一个客户端在写入时读取文档,是否会看到部分更新的值?

存储引擎的一个目标是:对单个节点上的单个对象提供原子性和隔离性。

一些数据库也提供更复杂的原子操作,例如自增、比较和设置(CAS compare-and-set)。当值没用并发被其他人修改过时,才允许执行写操作。

单对象操作可以防止多个客户端写入同个对象时丢失更新,但这不是事务。CAS被称为“轻量级事务”

多对象事务的需求

许多分布式数据库已经放弃多对象事务,因为难以跨分区实现,而且妨碍高可用和高性能。

但是,还有些场景需要多对象事务:

  • 在关系型数据模型中经常使用外键引用。多对象事务能确保这些外键始终有效
  • 文档数据模型中,缺乏连接功能的文档数据库会鼓励非规范化,当需要更新非规范化信息时,需要更新多个文档。多对象事务可以防止非规范化的数据不同步。
  • 在具有二级索引的数据库中,每次更改值时都需要更新索引。
处理错误和终止

遇到错误时,无主复制的数据库不会回滚,对象关系映射框架不会重试中断的事务。

重试一个终止的事务会发生的事:

  • 事务实际上成功了,但是服务器向客户端确认提交成功时网络故障,那么重试事务会执行两次
  • 若错误是由于负载过大造成的,重试事务会使得负载更大。可以限制重试次数,使用指数退避算法。
  • 发生永久性错误(违反约束等)的重试是无意义的。
  • 若客户端在进程重试中失效,任何试图写入数据库的数据都将丢失。

2 弱隔离级别

2.1 读已提交

最基本的事务隔离级别——读已提交(Read Committed),同时也是个非常流行的隔离级别。它保证了:

  • 没有脏读:从数据库读时只能看到已提交的数据
  • 没有脏写:写入数据库时只能覆盖已经写入的数据
没有脏读

脏读:事务A读取时事务B写入了还没提交,事务A读到了事务B还没提交的值,然后事务B回滚了,此时事务A读到的值是无效的。

为什么要防止脏读:

  • 事务需要更新多个对象时,另一个事务可能只看到一部分更新。
  • 写入的内容可能回滚,另一个事务会看到稍后需要回滚的数据。
没有脏写

脏写:事务A写入时事务B还没提交,然后事务B回滚就把事务A写入的值也给回滚了。

为什么防止脏写:

  • 若事务更新多个对象,脏写会把写入的值覆盖。
  • 若发生回滚,会把脏写的数据也回滚掉。
实现读已提交

通常通过行锁(row-level lock)来防止脏写:当事务想要修改特定对象时,必须先获得该对象的锁。当事务完成时,将锁交给下一个事务。

但是,读锁的效果不好,这样会使得读取需要等到事务完成从而损失响应时间。那么如何防止脏读呢:对于写入的每个脏读,数据库会记住旧的已提交值,当其他事务读取时返回此旧值。

2.2 快照隔离和可重复读

读已提交不能防止不可重复读(nonrepeatable read,读取偏差):事务内多次读取同一个数据,同时另一个事务正在修改该数据,那么第一个事务连续几次读到的数据是不一样的。

例如我从微信往银行卡里转钱,但是我在钱转到之前点开了银行卡的app,那么我会发现虽然微信扣钱了但是银行卡的钱没增加。

有些情况下不能容忍这种暂时的不一致:

  • 数据库备份:备份时数据库仍会接受写入,因此备份中会包含一些旧部分一些新部分,若从这样的备份中恢复,那么不一致就会持久化。
  • 分析查询和完整性检查:运行一个查询扫描大部分数据库时,若这些查询在不同时间点观察数据库的不同部分,那么结果会毫无意义。
快照隔离

快照隔离:每个事务都从数据库的一致快照中读取。即事务可以看到事务开始时在数据库中提交的所有数据,即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。这意味着一个事务可以看到数据库在某个特定时间点冻结时的一致快照,它对长时间运行的只读查询非常有用。

思路:通过写锁来防止脏写,但读取不做任何锁定。快照隔离的关键原则是 “读不阻塞写,写不阻塞读” 。这允许数据库在处理一致性快照上的长时间查询时,可以正常地同时处理写入操作。

实现:

  • 多版本并发控制(MVVC):并排维护多个版本的对象。数据库必须能保留一个对象的几个不同提交版本,因为各种正在进行的事务可能需要看到数据库在不同时间的工作状态。
  • 支持快照隔离的存储引擎通常也使用MVCC来实现读已提交隔离级别。一种典型的方法是读已提交为每个查询使用单独的快照,而快照隔离对整个事务使用相同的快照。

需要保留几个版本的快照:若数据库只需要满足读已提交则不需要快照隔离,保留一个对象的新(未提交)旧两个版本就行

观察一致性快照的可见性规则

当一个事务从数据库中读取时,事务ID用于决定它可以看见哪些对象,看不见哪些对象。通过仔细定义可见性规则,数据库可以向应用呈现一致的数据库快照。工作流程:

  1. 每次事务开始时,数据库列出其他尚未完成的事务清单,即使之后提交了,这些事务的写入也将被忽略。
  2. 被中止事务所执行的任何写入都将被忽略
  3. 较晚事务所做的写入都被忽略
  4. 所有其他写入,对应用都是可见的

当以下两个条件成立时,可见一个对象:

  • 读事务开始时,创建该对象的事务已提交
  • 对象未被标记删除,或被标记删除但请求删除的事务在读事务开始时还未提交。
索引和快照隔离

索引如何在多版本数据库中工作?

法一:使索引指向对象的所有版本,使用索引查询来过滤掉当前事务不可见的任何对象版本。当垃圾收集删除掉任何事务不再可见的旧对象版本时,相应的索引条目也可以被删除。

法二:使用B树的仅追加变体,在更新时不覆盖树页面,而是为每个修改页面创建一个副本,从父页面直到树根都会级联更新以指向他们子页面的新版本。

使用仅追加的B树时,每个写入事务都会创造一颗新B树,当创建时,从该特定树根生长的树就是数据库的一个一致性快照。

可重复读与命名混淆

许多数据库实现了它,但是名字不同,Oracle中称为可序列化(Serializable),PostgreSQL和MySQL中称为可重复读(repeatable read)。命名混淆的原因的SQL标准没有快照隔离的概念。

2.3 防止丢失更新

两个事务同时从数据库中读一些值,修改然后写回,就有可能发生丢失更新问题。因为其中一个的修改可能会丢失,即第二个写入的内容没有包括第一个事务的修改。

例如:

  • 增加计数器或更新账户余额
  • 在复杂值中进行本地修改,例如添加元素进JSON文档中的一个列表
  • 两个用户同时编辑wiki页面,每个用户通过将整个页面内容发送到服务器来保存其更改,覆写数据库中当前的任何内容
原子写(Atomic Write)

不是所有写操作都可以用原子写方式来表达,但可以用的情况下,原子写是最好的选择。

实现方法:

  • 原子操作通常通过在读取对象时,获取其上的排它锁来实现。使得更新完成之前没有其他事务可以读取它。这种技术有时被称为游标稳定性(cursor stability)
  • 另一个选择是简单地强制所有的原子操作在单一线程上执行。

然而,ORM框架经常意外地执行不安全的读取-修改-写入序列,而不是数据库提供的原子操作,这经常产出微妙的bug。

显示锁定

让应用程序显示地锁定将要更新的对象,然后应用程序可以执行读取-修改-写入序列,当任何其他事务尝试同时读取这个对象时,需要强制等待第一个事务完成。

注意,需要仔细考虑应用逻辑,忘记在代码某处加锁很容易引入竞争条件。

自动检测丢失的更新

允许并行执行,但当事务管理器检测到丢失更新时,中止事务并强制他们重试其读取-修改-写入序列。

优点:数据库可以结合快照隔离高效地执行此检查。

比较并设置(CAS)

CAS是一种原子操作:只有当前值从上次读取时一直未改变,才允许更新发生。否则更新不起作用且重试读取-修改-写入序列。

冲突解决和复制

在复制数据库中,防止丢失的更新需要考虑:多个节点上副本的数据可以被并发的修改,需要一些额外的步骤防止丢失更新。

基于锁和CAS的操作无法保证有一份新的数据副本,因为多节点的数据库允许多个并发写入,并异步复制到副本上。

正如在“检测并发写入”一节中写道,这种复制数据库常见的方法是允许并发写入创建多个版本冲突的值,并使用代码或特殊数据结构在事实发生之后解决合并这些版本。

原子操作可以在复制的上下文中很好地工作,尤其当他们具有可交换性时。

另一方面,最后写入为准(LWW)的冲突解决方法很容易丢失更新,然而很多数据库默认使用LWW。

2.4 写偏差与幻读

写偏差

幻读:一个事务读取一些东西,根据它所看到的值作出决定,并将决定写入数据库,但是写入的时候,决定的前提不再是真实的。即,如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),则可能发生写入偏差。

例如,至少需要一名医生值班,仅剩的两名医生同时点击下班,结果两人都下班了。

如何防止写偏差:

  • 由于涉及多个对象,单对象的原子操作不起作用。
  • 在一些快照隔离的实现中,自动检测丢失更新无法确认写偏差,所以自动防止写入偏差需要真正的可序列化隔离
  • 某些数据库允许配置约束,然后由数据库强制执行。但是为了指定至少有一名医生在线,需要一个设计多个对象的约束。大多数数据库没有内置对这种约束的支持,但是可以使用触发器或者物化视图来实现,这取决于不同的数据库。
导致写偏差的幻读

幻读:一个事务中的写入改变另一个事务的搜索查询的结果。

所有这些产生幻读的例子都遵循类似的模式:

  1. 一个SELECT查询找出符合条件的行,并检查是否符合一些要求。
  2. 按照第一个查询的结果,应用代码决定是否继续。
  3. 如果应用决定继续操作,就执行写入(插入、更新或删除),并提交事务。

例如刚刚的医生值班问题中,第一条运行的时候值还未改变,此时两个事务读取出来都是1。

快照隔离只避免了只读查询中幻读。

物化冲突

如果幻读的问题是没有对象可以加锁,也许可以人为地在数据库中引入一个锁对象?

物化冲突将幻读变为数据库中一组具体行上的锁冲突。但它是最后的手段,因为有很多的缺点。在大多数情况下。可序列化(Serializable) 的隔离级别是更可取的。

3 可序列化

可序列化(Serializability) 隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行,最终的结果也是一样的,就好像它们没有任何并发性,连续挨个执行一样。因此数据库保证,如果事务在单独运行时正常运行,则它们在并发运行时继续保持正确 —— 换句话说,数据库可以防止所有可能的竞争条件。

本章将讨论实现可序列化的技术,包括真的串行执行、两相锁定、可序列化的快照隔离

3.1 真正的串行

避免并发的最简单办法就是不要并发,串行直接能绕开检测/防止事务间冲突的问题,由此产生隔离。

发展过程:一开始,多线程被认为是良好性能的体现,直到07年左右,单线程循环执行事务被认为是可行的。原因有两个:RAM变便宜了,OLTP事务通常很短且只进行少量读写。

在存储过程中封装事务

在数据库早期阶段,数据库事务包含整个用户活动流程,然而人类做出回应的速度非常慢,事务如果需要等待人类输入才能继续运行的话,效率会很低。因此变成了提交HTTP请求以开启事务。然而此时事务仍以交互式的客户端/服务器风格执行,一次一个语句。网络通信耗费了大量时间,若无法并发处理,吞吐量会非常糟糕,数据库会等待发出当前事务的下一个查询。

后来,事务处理系统不允许交互式的多语句事务,程序将整个事务代码交给数据库。

存储过程的优点和缺点

存储过程将事务所需的所有数据都存在内存中,可以非常快的执行,且不需要等待网络或硬盘I/O。存储过程在关系型数据库存在了20多年,但缺点很多。

缺点:

  • 每个数据库厂商都有自己的存储过程语言,但是丑陋陈旧且生态不好
  • 管理、调试、测试起来困难,版本控制和部署、收集监控等很差
  • 数据库通常比应用服务器对性能敏感得多,一个写得不好的存储过程会比在应用服务器中相同的代码造成更多麻烦。

优点:存储过程与内存存储,使得单个线程上所有事务都变得可行,且不需要等待I/O,这保证了单线程的吞吐量。

分区

顺序执行使得事务并发控制变得简单,但数据库事务吞吐量被限制为单机单核的速度,对于写入吞吐量较高的应用会有性能瓶颈。

对数据集进行分区,以便事务只需要在单个分区中读写数据,这使得每个分区都能有自己独立运行的事务处理线程,为每个分区指派一个CPU核,事务的吞吐量就能和CPU核数线性扩展。

事务是否可以划分至单个分区,主要取决于应用数据的结构。键值数据很容易分区,而有多个二级索引的数据需要大量的跨分区协调。

串行执行小结
  • 每个事务都必须小而快,只要有一个缓慢的事务,就会拖慢所有事务处理。
  • 仅限于活跃数据集可以放入内存的情况。很少访问的数据可能会被移动到磁盘,但如果需要在单线程执行的事务中访问,系统就会变得非常慢。
  • 写入吞吐量必须低到能在单个CPU核上处理,如若不然,事务需要能划分至单个分区,且不需要跨分区协调。
  • 跨分区事务是可能的,但是它们的使用程度有很大的限制。

3.2 两阶段锁定(2PL)

2PL又称严格两阶段锁定(SS2PL),是一种广泛使用的序列化算法。例如用于MySQL(InnoDB)和SQL Server中的可序列化隔离级别,以及DB2中的可重复读隔离级别。

类似于“没有脏写”一节中:两个事务同时尝试写入时,锁确保第二个事务在第一个事务完成后继续。

2PL使锁的要求更强,只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入,就需要独占访问(exclusive access)权限:

  • 事务A读取一个对象,B想写入需要等A完成才能继续。
  • 事务A写入对象,B想读取需要等A完成才能继续。

2PL中,一个事务的读取和写入都会阻塞其他事务的读取和写入。2PL提供了可序列化的性质,防止所有竞争条件,包括丢失更新和写入偏差。

两阶段锁是一种所谓的悲观并发控制机制,即悲观锁:如果事情可能出错,最好等情况安全后再做事情。这就像互斥,用于保护多线程编程中的数据结构。

实现两阶段锁定

读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于共享模式(shared mode)独占模式(exclusive mode) 。锁使用如下:

  • 事务在读取对象前需要先以共享模式获取锁,多个事务可以同时持有共享锁。当一个事务拥有排它锁时,有共享锁的事务需要等待。
  • 事务在写入对象前需要先以独占模式获取锁,没有其他事务可以同时持有锁,所以如果对象上存在任何锁,该事务必须等待。
  • 如果事务先读取再写入对象,则将共享锁升级成独占锁锁。锁升级过程与获得排他锁相同。
  • 事务获得锁后,必须继续持有锁直到事务结束,这就是“两阶段”的由来:第一阶段:获得锁,第二阶段:释放锁。

可能发生死锁情况:事务A等待事务B释放它的锁。数据库会自动检测事务之间的死锁,并中止其中一个。被中止的事务需要由应用程序重试。

两阶段锁定的性能

性能是其巨大的缺点。一部分由于获取和释放所有这些锁的开销,更重要的是并发性的降低。

传统的关系数据库不限制事务的持续时间,因此,运行2PL的数据库有相当不稳定的延迟,若工作负载中存在争用,那么高百分点位处的响应会非常慢(参阅“描述性能”)。也就是说,一个缓慢的事务,就可以拖慢所有事务。

当事务由于死锁而被中止重试时,需要从头重做所有工作,造成巨大浪费。

谓词锁

谓词锁(predicate lock)用于防止幻读,它类似于共享/排它锁,不属于特定对象,它属于所有符合某些搜索条件的对象,例如:

 SELECT * FROM bookings
 WHERE room_id = 123 AND
       end_time > '2018-01-01 12:00' AND 
       start_time < '2018-01-01 13:00';

谓词锁限制访问:

  • 事务想要读取某些条件的对象时,需要获得查询条件上的共享谓词锁。查询条件一致的查询需要等待此锁完成。
  • 若事务A想插入、删除、更新任何对象时,需要检查旧值或新值是否与现有的谓词共享锁匹配,是则需要等该谓词锁的事务提交后事务A才能继续进行。

缺点:性能差,如果活跃事务有很多锁,检查匹配的锁会非常耗时。

索引范围锁

由于谓词锁的性能不佳——如果活跃事务持有很多锁,检查匹配的锁会非常耗时。所以,大多数使用2PL的数据库实际上实现了索引范围锁(也称间隙锁),这是一个简化版的谓词锁。

通过使谓词匹配到一个更大的集合来简化谓词锁,原理:任何满足原始谓词的写入也一定会满足这种松散的近似。

例:某房间下午被预定了,那么直接锁定其所有时间段,或者锁定所有房间的下午。

优点:能防止幻读和写入偏差,开销比谓词锁低。

缺点:没有谓词锁精确,可能会锁定更大范围的对象。

3.3 序列化快照隔离(SSI)

可序列化快照隔离(SSI, serializable snapshot isolation)提供了完整的可序列化隔离级别,且只有很小的性能损失。能用于单节点数据库和分布式数据库它有可能因为足够快而在未来成为新的默认选项。

悲观与乐观的并发控制

众所周知,两阶段锁是一种悲观锁,而串行执行更可以说是悲观到了极点。相比之下,序列化快照隔离就是一种乐观的并发控制技术。当事务要提交时,数据库检查隔离是否被违反,如果是则中止且必须重试。只有序列化的事务才允许被提交。

当有足够备用容量,且事务之间的争用不是很高时,乐观的并发控制技术比悲观的要好。可交换的原子操作可以减少争用。但是当存在许多争用时表现不佳,因为这会导致很多事务需要中止,从而产生重试事务带来额外负载。

SSI基于快照隔离,事务中的所有读取都来自数据库的一致性快照。在快照隔离的基础上,SSL添加了一种算法来检测写入之间的序列化冲突,并确定要中止哪些事务。

基于过时前提的决策

在快照隔离的情况下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在同一时间被修改。

换而言之,事务基于一个前提(premise)采取行动(例如:有两个医生正在值班),之后当事务要提交时,原始数据可能已经改变:前提可能不再成立。

当应用程序进行查询时,数据库不知道应用逻辑如何使用该查询结果。在这种情况下为了安全,数据库需要假设任何对该结果集的变更都可能导致该事务中写入变得无效。事务中的查询与写入可能存在因果依赖,为了提供可序列化的隔离级别,若事务在过时的前提下执行操作,数据库必须能检测到这种情况,并中止事务。

数据库如何知道查询结果可能已经改变呢?

  • 检测对旧MVCC对象版本的读取(读之前存在未提交的写入)
  • 检测影响先前读取的写入(读之后发生写入)
检测旧MVCC读取

我们知道,快照隔离通常是通过多并发版本控制(MVCC)实现的。当事务从快照中读取时,将忽略快照后其他写入。为了防止这种情况,数据库需要跟踪一个事务由于MVCC可见性规则而忽略另一个事务的写入。事务想提交时,数据库检查是否有被忽略的写入已经被提交,如果是,则中止事务。

一文搞懂数据库所有事务隔离级别

如图,事务43读取后,42修改了43的前提,当准备提交时,检测到陈旧读取,中止事务43。

检测影响之前读取的写入

另一个事务在读取之后,读取的数据被修改了。为了解决这个问题,当事务写入数据库时,它可以在索引中查找最近曾读取受影响数据的其他事务,然后通知其他事务:你们读过的数据可能是旧的。

可序列化的快照隔离的性能

许多工程细节会影响算法的实际表现。如果数据库能跟踪每个事务的活动(细粒度),那么可以准确地确定哪些事务需要中止,但是簿记开销可能变得很显著。简略的跟踪速度更快(粗粒度),但可能会导致更多不必要的事务中止。

事务可以读取被另一条事务覆盖的信息。这取决于发生了什么,有时可以证明执行结果无论如何都是可序列化的。

与两阶段锁定相比的优点:事务不需要阻塞等待另一个事务的锁,因此查询延迟更可预测,变量更少。只读查询可以运行在一致的快照上,而不需要任何锁定,适合负载繁重的工作。

与串行执行相比:可序列化快照隔离并不局限单个CPU核的吞吐量,且在保证可序列化隔离等级的同时可以读写多个分区中的数据。

中止率显著影响SSI的整体表现,因此SSL要求同时读写的事务尽量短(只读长事务没问题),长时间的读取和写入事务容易发生冲突并中止。

转载自:https://juejin.cn/post/7343921947870789683
评论
请登录