Skip to content

Latest commit

 

History

History
184 lines (113 loc) · 19 KB

File metadata and controls

184 lines (113 loc) · 19 KB

串行化(Serializability)

可串行化隔离通常被认为是最强的隔离级别。它保证即使事务==可能会并行执行==,==但最终的结果与每次一个即串行执行结果相同==。这意味着,如果事务在单独运行时表现正确,那么它们在并发运行时结果仍然正确,换句话说,数据库可以防止所有可能的竞争条件。

实际串行执行

当满足以下约束条件时,串行执行事务可以实现串行化隔离:

  • 事务必须简短而高效,否则一个缓慢的事务会影响到所有其他事务的执行性能。
  • 仅限于活动数据集完全可以加载到内存的场景。有些很少访问的数据可能会被移到磁盘,但万一单线程事务需要访问它,就会严重拖累性能
  • 写入吞吐量必须足够低,才能在单个 CPU 核上处理;否则就需要采用分区,最好没有跨分区事务。
  • 跨分区事务虽然也可以支持,但是占比必须很小。

(注:实际的串行执行在两个事务完全没有冲突的情况下也会串行执行,和可串行化的区别在于,可串行化下没有冲突的事务可以并行执行。)

两阶段加锁(2PL)⭐

近三十年来,可以说数据库只有一种被广泛使用的串行化算法,那就是==两阶段锁==(two-phase locking, ==2PL==)。

有时也被称为==严格的两阶段加锁==(strong strict two-phase locking, ==SS2PL==),以区别于 2PL 的其他变体。

多个事务可以同时读取同一对象,但==只要出现任何写操作(包括修改或删除),则必须加锁以独占访问==:(注:即读写都加锁)

  • 如果事务 A 已经读取了某个对象,此时事务 B 想要写入该对象,那么 B 必须等到 A 提交或中止之才能继续。以确保 B 不会在事务 A 执行的过程中间去修改对象。
  • 如果事务 A 已经修改了对象,此时事务 B 想要读取该对象,则 B 必须等到 A 提交或中止之后才能继续。对于 2PL,不会出现读到旧值的情况(参见图 7-1的示例)。

因此 2PL 不仅在并发写操作之间互斥,读取也会和修改产生互斥。快照级别隔离的口号 “读写互不干扰”(参阅本章前面的 “实现快照级别隔离”)非常准确地点明了它和两阶段加锁的关键区别。另一方面,因为 2PL 提供了串行化,所以它可以防止前面讨论的所有竞争条件,包括==更新丢失==和==写倾斜==。

Tips:

丢失更新:产生丢失更新的原因是 T1 和 T2 两个事务先读后写了同一行数据,分为下面的场景(省略 W 之后的 COMMIT):

  1. T1 R(a) -> T2 R(a) -> T1 W(a) -> T2 W(a):第 3 步中,T1 W(a) 需要对 a 加写锁,但因为第 2 步中 T2 已经对 a 加了读锁,所以会锁等待
  2. T1 R(a) -> T2 R(a) -> T2 W(a) -> T1 W(a):第 2 步中,T2 W(a) 需要对 a 加写锁,但因为第 2 步中 T1 已经对 a 加了读锁,所以会锁等待

因此可能的执行顺序只能是 T1 R(a) -> T1 W(a) -> T2 R(a) -> T2 W(a)

写倾斜:产生写倾斜的原因是 T1 和 T2 两个事务先读了某个谓词,然后写了不同的数据。以值班医生为例子:

  • Alice: Read(on_call=true) 对 Alice 和 Bob 行都加读锁, Write(on_call=false where name = Alice) 对 Alice 行加写锁
  • Bob: Read(on_call=true) 对 Alice 和 Bob 行都加读锁, Write(on_call=false where name = Bob) 对 Bob 行加写锁

因为 Alice 和 Bob 行都加了读锁,所以当写数据的时候会锁等待(实际上会死锁),可能的执行调度只有串行执行。

对于写偏序的其他例子,读的时候无法对特定行加锁的场景,将在下面谓词锁一节介绍。

实现两阶段加锁

目前,2PL 已经用于 MySQL (InnoDB) 和 SQL Server 中的 ”可串行化隔离“,以及 DB2 中的 ”可重复读隔离“。

TIPS:

MySQL 只有在 SERIALEZABLE 隔离级别下才是默认使用 2PL,在 RR 下的读默认使用快照读,不过可以使用 SELECT LOCK IN SHARE MODE 或 LOCK FOR UPDATE 来显示加锁。

此时数据库的每个对象都有一个读写锁来隔离读写操作。即锁可以处于共享模式或独占模式。基本用法如下:

  • ==如果事务要读取对象,必须先以共享模式获得锁==。可以有多个事务同时获得一个对象的共享锁,但是如果某个事务已经获得了对象的独占锁,则所有其他事务必须等待。
  • ==如果事务要修改对象,必须以独占模式获取锁==。不允许多个事务同时持有该锁(包括共享或独占模式),换言之,如果对象上已被加锁,则修改事务必须待。
  • ==如果事务首先读取对象,然后尝试写入对象,则需要将共享锁升级为独占锁==。升级锁的流程等价于直接获得独占锁。
  • ==事务获得锁之后,一直持有锁直到事务结束(包括提交或中止)==。这也是名字 “两阶段” 的来由,在第一阶段即事务执行之前要获取锁,第二阶段(即事务结束时)则释放锁。

两阶段加锁的性能

两阶段锁其事务吞吐量和查询响应时间相比于其他弱隔离级别下降非常多。部分原因在于锁的获取和释放本身的开销,但更重要的是其降低了事务的并发性。按 2PL 的设计,两个并发事务如果试图做任何可能导致竞争条件的事情,其中一个必须等待对方完成。

谓词锁(Predicate locks)

对于加锁,我们还忽略了一个微妙但重要的细节,如本章前面 ”写倾斜与幻读” 中的幻读问题,即一个事务改变另一个事务的查询结果,可串行化隔离也必须防止幻读问题。

以会议室预订为例,如果事务在查询某个时间段内一个房间的预订情况(参见示例 7-2) ,则另一个事务不能同时去插入或更新同一时间段内该房间的预订情况,但它可以修改其他房间的预订情况,或者在不影响当前查询的情况下,修改该房间的其他时间段预订。

如何实现呢?技术上讲,我们需要引入一种==谓词锁==(或者属性谓词锁,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 想要读取某些满足匹配条件的对象,例如采用 SELECT 查询,它必须以共享模式获得查询条件的谓词锁。如果另一个事务 B 正持有任何一个匹配对象的互斥锁,那么 A 必须等到 B 释放锁之后才能继续执行查询。
  • 如果事务 A 想要插入、更新或删除任何对象,则必须首先检查所有旧值和新值是否与现有的任何谓词锁匹配(即冲突)。如果事务 B 持有这样的谓词锁,那么A 必须等到 B 完成提交(或中止)后才能继续。

这里的关键点在于,==谓词锁甚至可以保护数据库中那些尚不存在但可能马上会被插入的对象(幻读)==。将两阶段加锁与谓词锁结合使用,数据库可以防止所有形式的写倾斜以及其他竞争条件,隔离变得真正可串行化。

索引区间锁(index-range locking/next­ key locking)⭐

不幸的是,谓词锁性能不佳:如果活动事务中存在许多锁,那么检查匹配这些锁就变得非常耗时。因此,大多数使用 2PL 的数据库实际上实现的是==索引区间锁==(index-range locking,或者 ==next­ key locking==),本质上它是对谓词锁的简化或者近似。

简化谓词锁的方式是将其保护的对象扩大化,首先这肯定是安全的。例如,如果一个谓词锁保护的是查询条件是:房间 123,时间段是中午至下午 1 点,则一种方式是通过扩大时间段来简化,即保护 123 房间的所有时间段;或者另一种方式是扩大房间,即保护中午至下午 1 点之间的所有房间(而不仅是 123 号房间)。这样,任何与原始谓词锁冲突的操作肯定也和近似之后的区间锁相冲突。

对于房间预订数据库,通常会在 room_id 列上创建索引,和/或在 start_ time 和 end time 上有索引(否则前面的查询在大型数据库上会很慢):

  • 假设索引位于 room_id 上,数据库使用此索引查找 123 号房间的当前预订情况。现在,数据库可以简单地将共享锁附加到此索引条目,表明事务已搜索了 123 号房间的所有时间段预订。
  • 或者,如果数据库使用基于时间的索引来查找预订,则可以将共享锁附加到该索引中的一系列值,表示事务巳经搜索了该时间段内的所有值(例如直到 2020年 1 月 1 日)。

无论哪种方式,查询条件的近似值都附加到某个索引上。接下来,如果另一个事务想要插入、更新或删除同一个房间和/或重叠时间段的预订,则肯定需要更新这些索引,一定就会与共享锁冲突,因此会自动处于等待状态直到共享锁释放。

这样就有效防止了写倾斜和幻读问题。的确,索引区间锁不像谓词锁那么精确(会锁定更大范围的对象,而超出了串行化所要求的部分),但由于开销低得多,可以认为是一种很好的折衷方案。

如果没有合适的索引可以施加区间锁,则数据库可以回退到对整个表施加共享锁。这种方式的性能肯定不好,它甚至会阻止所有其他事务的写操作,但的确可以保证安全性。

可串行化的快照隔离(Serializable Snapshot Isolation (SSI))⭐

悲观与乐观的并发控制(Pessimistic versus optimistic concurrency control)

==两阶段加锁是一种典型的悲观并发控制机制==。它基于这样的设计原则:如果某些操作可能出错(例如与其他并发事务发生了锁冲突),那么直接放弃,采用等待方式直到绝对安全。这和多线程编程中互斥锁是一致的。

某种意义上讲,串行执行是种极端悲观的选择:事务执行期间,等价于事务对整个数据库(或数据库的一个分区)持有互斥锁。而我们只能假定事务执行得足够快、持锁时间足够短,来稍稍弥补这种悲观色彩。

相比之下,==可串行化的快照隔离则是一种乐观并发控制==。在这种情况下,如果可能发生潜在冲突,事务会继续执行而不是中止,寄希望一切相安无事;而当事务提交时(只有可串行化的事务被允许提交),数据库会检查是否确实发生了冲突(即违反了隔离性原则),如果是的话,中止事务并接下来重试。

乐观并发控制的优缺点:

  • 如果冲突很多,则性能不佳(许多事务试图访问相同的对象),大量的事务必须中止。如果系统已接近其最大吞吐最,反复重试事务会使系统性能变得更差。
  • 如果系统还有足够的性能提升空间,且如果事务之间的竞争不大,乐观并发控制会比悲观方式高效很多。

顾名思义,SSI 基于快照隔离,也就是说,事务中的所有读取操作都是基于数据库的 “一致性快照”(请参阅本章前面的 “快照隔离” 和 “可重复读")。这是与早期的乐观并发控制主要区别。在快照隔离的基础上,SSI 新增加了相关算法来检测写入之间的串行化冲突从而决定中止哪些事务。

关于 悲观并发控制(PCC)、乐观并发控制(OCC)、多版本并发控制(MVCC):https://www.51cto.com/article/629317.html

基于过期的条件做决定

我们在讨论写倾斜(参阅本章前面的 ”写倾斜与幻读“)时,介绍了这样一种使用场景:事务首先查询某些数据,根据查询的结果来决定采取后续操作,例如修改数据。而在快照隔离情况下,数据可能在查询期间就已经被其他事务修改,导致原事务在提交时决策的依据信息已出现变化。

换句话说,事务是基于某些前提条件而决定采取行动,在事务开始时条件成立,例如 “目前有两名医生值班”,而当事务要提交时,数据可能已经发生改变,条件已不再成立。

当应用程序执行查询时(例如 “当前有多少医生在值班? ”),数据库本身无法预知应用层逻辑如何使用这些查询结果。==安全起见,数据库假定对查询结果(决策的前提条件)的任何变化都应使写事务失效==。换言之,查询与写事务之间可能存在因果依赖关系。为了提供可串行化的隔离,数据库必须检测事务是否会修改其他事务的查询结果,并在此情况下中止写事务。

数据库如何知道查询结果是否发生了改变呢?可以分以下两种情况:

  • 读取是否作用于一个(即将)过期的 MVCC 对象(读取之前已经有未提交的写入)。
  • 检查写入是否影响即将完成的读取(读取之后,又有新的写入)

检测是否读取了过期的 MVCC 对象⭐

回想一下,快照隔离通常采用多版本并发控制技术(MVCC,见图 7-10)来实现。当事务从 MVCC 数据库一致性快照读取时,它会忽略那些在创建快照时尚未提交的事务写入。例如图 7-10 中,事务 42(修改 Alice 的值班状态)未被提交,因此事务 43 中 Alice 查询到的 on_call 是 true;当事务 43 提交时,事务 42 巳经完成了提交。换言之,从快照读取时被忽略的写入已经生效,并且直接导致事务 43 做决定的前提已不再成立。

image-20220123144309007

为防止这种异常,==数据库需要跟踪那些由于 MVCC 可见性规则而被忽略的写操作。当事务提交时,数据库会检查是否存在一些当初被忽略的写操作现在已经完成了提交,如果是则必须中止当前事务==。

为什么要等到提交:当检测到读旧值,为何不立即中止事务 43 呢?可以考虑这些情况,首先,如果事务 43 是个只读事务,没有任何写倾斜风险,就不需要中止;而事务 43 读取数据库时,数据库还不知道事务是否稍后有任何写操作。此外,事务 43 提交时,有可能事务 42 发生了中止或者还处于未提交状态,因此读取的并非是过期值。通过减少不必要的中止,SSI 可以高效支持那些需要在一致性快照中运行很长时间的读事务。

检测写是否影响了之前的读⭐

第二种要考虑的情况是,在读取数据之后,另一个事务修改了数据。如图 7-11 所示。

image-20220123144634424

在 “两阶段加锁” 中,我们讨论了索引区间锁(参阅本章前面的 "索引区间锁"),它可以锁定与某个查询条件匹配的所有行,例如 WHERE shift_id = 1234。这里使用了类似的技术,只有一点差异:SSI 锁不会阻塞其他事务。

在图 7-11 中,事务 42 和事务 43 都在查询轮班 1234 期间的值班医生。如果在 shift_id 上建有索引,数据库可以通过索引条目 1234 来记录事务 42 和事务 43 都查询了相同的结果。如果没有索引,可以在表级别跟踪此信息。该额外记录只需保留很小一段时间,当并发的所有事务都处理完成(提交或中止)之后,就可以丢弃。

==当另一个事务尝试修改时,它首先检查索引,从而确定是否最近存在一些读目标数据的其他事务。这个过程类似于在受影响的字段范围上获取写锁,但它并不会阻塞读取,而是直到读事务提交时才进一步通知他们:所读到的数据现在已经发生了变化。==

图 7-11 中,事务 43 和事务 42 会互相通知对方先前的读已经过期。虽然事务 43 的修改的确影响了事务 42,但事务 43 当时并未提交(修改未生效),而事务 42首先尝试提交,所以可以成功;随后当事务 43 试图提交时,来自 42 的冲突写已经提交生效,事务 43 不得不中止。

可串行化快照隔离的性能

有许多工程方面的细节会直接影响算法在实践中的效果。例如,一个需要权衡考虑的是关于跟踪事务读、写的粒度。如果非常详细地跟踪每个事务的操作,确实可以准确推测有哪些事务受到影响、需要中止,但是记录元数据的开销可能很大;而粗粒度的记录则速度占优,但可能会扩大受影响的事务范围。

有时,读取过期的数据并不会造成太大影响,这完全取决于所处的具体场景。有时可以确信执行的最终结果是可串行化的,PostgreSQL 采用这样的信条来减少不必要的中止。

与两阶段加锁相比,可串行化快照隔离的一大优点是事务不需要等待其他事务所持有的锁。这一点和快照隔离一样,读写通常不会互相阻塞。这样的设计使得查询延迟更加稳定、可预测。特别是,在一致性快照上执行只读查询不需要任何锁,这对于读密集的负载非常有吸引力。

与串行执行相比,可串行化快照隔离可以突破单个 CPU 核的限制。FoundationDB 将冲突检测分布在多台机器上,从而提高总体吞吐量。即使数据可能跨多台机器进行分区,事务也可以在多个分区上读、写数据并保证可串行化隔离。

小结

  • ==脏读==:客户端读到了其他客户端尚未提交的写入。RC 以及更强的隔离级别可以防止脏读。
  • ==脏写==:客户端覆盖了另一个客户端尚未提交的写入。几乎所有的数据库实现都可以防止脏写。
  • ==读倾斜==(==不可重复读==):客户在不同的时间点看到了不同值。快照隔离是最用的防范手段,即事务总是在某个时间点的一致性快照中读取数据。通常采用多版本并发控制(MVCC)来实现快照隔离。
  • ==更新丢失==:两个客户端同时执行 read-modify-write 操作序列,出现了其中一个覆盖了另一个的写入,但又没有包含对方最新值的情况,最终导致了部分修改数据发生了丢失。快照隔离的一些实现可以自动防止这种异常,而另一些则需要手动锁定查询结果(SELECT FOR UPDATE)。
  • ==写倾斜==:事务首先查询数据,根据返回的结果而作出某些决定,然后修改数据库。当事务提交时,支持决定的前提条件已不再成立。只有可串行化的隔离才能防止这种异常。
  • ==幻读==:事务读取了某些符合查询条件的对象,同时另一个客户端执行写入,改变了先前的查询结果。快照隔离可以防止简单的幻读,但写倾斜情况则需要特殊处理,例如采用区间范围锁。

实现可串行化隔离的三种不同方法:

  1. 严格串行执行事务
  2. 两阶段加锁
  3. 可串行化的快照隔离 (SSI)