05. 可序列化
Serializable | 可序列化
可序列化(Serializability)隔离通常被认为是最强的隔离级别,它保证即使事务可以并行执行,最终的结果也是一样的,就好像它们没有任何并发性,连续挨个执行一样。它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简言之,它是在每个读的数据行上加上共享锁。在这个级别,可能导致大量的超时现象和锁竞争。该隔离级别代价也花费最高,性能很低,一般很少使用,在该级别下,事务顺序执行,不仅可以避免脏读、不可重复读,还避免了幻读。
目前大多数提供可序列化的数据库都使用了三种技术之一:
- 字面意义上的串行执行:如果每个事务的执行速度非常快,并且事务吞吐量足够低,足以在单个
CPU 核上处理,这是一个简单而有效的选择。 - 两阶段锁定:数十年来,两阶段锁定一直是实现可序列化的标准方式,但是许多应用出于性能问题的考虑避免使用它。
- 可串行化快照隔离(SSI
) :一个相当新的算法,避免了先前方法的大部分缺点。它使用乐观的方法,允许事务执行而无需阻塞。当一个事务想要提交时,它会进行检查,如果执行不可序列化,事务就会被中止。
真的串行执行
避免并发问题的最简单方法就是完全不要并发:在单个线程上按顺序一次只执行一个事务。这样做就完全绕开了检测
两个进展引发了这个反思:
RAM 足够便宜了,许多场景现在都可以将完整的活跃数据集保存在内存中。当事务需要访问的所有数据都在内存中时,事务处理的执行速度要比等待数据从磁盘加载时快得多。- 数据库设计人员意识到
OLTP 事务通常很短,而且只进行少量的读写操作。相比之下,长时间运行的分析查询通常是只读的,因此它们可以在串行执行循环之外的一致快照(使用快照隔离)上运行。
串行执行事务的方法在
在存储过程中封装事务
在数据库的早期阶段,意图是数据库事务可以包含整个用户活动流程。例如,预订机票是一个多阶段的过程(搜索路线,票价和可用座位,决定行程,在每段行程的航班上订座,输入乘客信息,付款
不幸的是,人类做出决定和回应的速度非常缓慢。如果数据库事务需要等待来自用户的输入,则数据库需要支持潜在的大量并发事务,其中大部分是空闲的。大多数数据库不能高效完成这项工作,因此几乎所有的
即使人类已经找到了关键路径,事务仍然以交互式的客户端
在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。
出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如下图所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘

存储过程在关系型数据库中已经存在了一段时间了,自
- 每个数据库厂商都有自己的存储过程语言(
Oracle 有PL/SQL ,SQL Server 有T-SQL ,PostgreSQL 有PL/pgSQL 等) 。这些语言并没有跟上通用编程语言的发展,所以从今天的角度来看,它们看起来相当丑陋和陈旧,而且缺乏大多数编程语言中能找到的库的生态系统。 - 与应用服务器相,比在数据库中运行的管理困难,调试困难,版本控制和部署起来也更为尴尬,更难测试,更难和用于监控的指标收集系统相集成。
- 数据库通常比应用服务器对性能敏感的多,因为单个数据库实例通常由许多应用服务器共享。数据库中一个写得不好的存储过程(例如,占用大量内存或
CPU 时间)会比在应用服务器中相同的代码造成更多的麻烦。
但是这些问题都是可以克服的。现代的存储过程实现放弃了
分区
顺序执行所有事务使并发控制简单多了,但数据库的事务吞吐量被限制为单机单核的速度。只读事务可以使用快照隔离在其它地方执行,但对于写入吞吐量较高的应用,单线程事务处理器可能成为一个严重的瓶颈。为了扩展到多个
但是,对于需要访问多个分区的任何事务,数据库必须在触及的所有分区之间协调事务。存储过程需要跨越所有分区锁定执行,以确保整个系统的可串行性。由于跨分区事务具有额外的协调开销,所以它们比单分区事务慢得多。
事务是否可以是划分至单个分区很大程度上取决于应用数据的结构。简单的键值数据通常可以非常容易地进行分区,但是具有多个二级索引的数据可能需要大量的跨分区协调。
在特定约束条件下,真的串行执行事务,已经成为一种实现可序列化隔离等级的可行办法。
-
每个事务都必须小而快,只要有一个缓慢的事务,就会拖慢所有事务处理。
-
仅限于活跃数据集可以放入内存的情况。很少访问的数据可能会被移动到磁盘,但如果需要在单线程执行的事务中访问,系统就会变得非常慢。如果事务需要访问不在内存中的数据,最好的解决方案可能是中止事务,异步地将数据提取到内存中,同时继续处理其他事务,然后在数据加载完毕时重新启动事务。这种方法被称为反缓存(anti-caching
) 。 -
写入吞吐量必须低到能在单个
CPU 核上处理,如若不然,事务需要能划分至单个分区,且不需要跨分区协调。 -
跨分区事务是可能的,但是它们的使用程度有很大的限制。
两阶段锁定(2PL)
大约
之前我们看到锁通常用于防止脏写:如果两个事务同时尝试写入同一个对象,则锁可确保第二个写入必须等到第一个写入完成事务(中止或提交
-
如果事务
A 读取了一个对象,并且事务B 想要写入该对象,那么B 必须等到A 提交或中止才能继续。 (这确保B 不能在A 底下意外地改变对象。 ) -
如果事务
A 写入了一个对象,并且事务B 想要读取该对象,则B 必须等到A 提交或中止才能继续。
在
实现两阶段锁
- 若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
- 若事务要写入一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式
) ,所以如果对象上存在任何锁,该事务必须等待。 - 如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得排他锁相同。
- 事务获得锁之后,必须继续持有锁直到事务结束(提交或中止
) 。这就是“两阶段”这个名字的来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。
由于使用了这么多的锁,因此很可能会发生:事务
两阶段锁定的性能
两阶段锁定的巨大缺点,以及
传统的关系数据库不限制事务的持续时间,因为它们是为等待人类输入的交互式应用而设计的。因此,当一个事务需要等待另一个事务时,等待的时长并没有限制。即使你保证所有的事务都很短,如果有多个事务想要访问同一个对象,那么可能会形成一个队列,所以事务可能需要等待几个其他事务才能完成。因此,运行
基于锁实现的读已提交隔离级别可能发生死锁,但在基于
谓词锁
前面我们讨论了幻读(phantoms)的问题。即一个事务改变另一个事务的搜索查询的结果。具有可序列化隔离级别的数据库必须防止幻读。在会议室预订的例子中,这意味着如果一个事务在某个时间窗口内搜索了一个房间的现有预订,则另一个事务不能同时插入或更新同一时间窗口与同一房间的另一个预订(可以同时插入其他房间的预订,或在不影响另一个预定的条件下预定同一房间的其他时间段
如何实现这一点?从概念上讲,我们需要一个谓词锁(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 查询中那样,它必须获取查询条件上的共享谓词锁(shared-mode predicate lock) 。如果另一个事务B 持有任何满足这一查询条件对象的排它锁,那么A 必须等到B 释放它的锁之后才允许进行查询。 -
如果事务
A 想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务B 持有匹配的谓词锁,那么A 必须等到B 已经提交或中止后才能继续。
这里的关键思想是,谓词锁甚至适用于数据库中尚不存在,但将来可能会添加的对象(幻象
索引范围锁
不幸的是谓词锁性能不佳:如果活跃事务持有很多锁,检查匹配的锁会非常耗时。因此,大多数使用
在房间预订数据库中,您可能会在room_id
列上有一个索引,并且start_time
和 end_time
上有索引(否则前面的查询在大型数据库上的速度会非常慢
-
假设您的索引位于
room_id
上,并且数据库使用此索引查找123 号房间的现有预订。现在数据库可以简单地将共享锁附加到这个索引项上,指示事务已搜索123 号房间用于预订。 -
或者,如果数据库使用基于时间的索引来查找现有预订,那么它可以将共享锁附加到该索引中的一系列值,指示事务已经将
12:00~13:00 时间段标记为用于预定。
无论哪种方式,搜索条件的近似值都附加到其中一个索引上。现在,如果另一个事务想要插入,更新或删除同一个房间和
这种方法能够有效防止幻读和写入偏差。索引范围锁并不像谓词锁那样精确(它们可能会锁定更大范围的对象,而不是维持可串行化所必需的范围
如果没有可以挂载间隙锁的索引,数据库可以退化到使用整个表上的共享锁。这对性能不利,因为它会阻止所有其他事务写入表格,但这是一个安全的回退位置。
序列化快照隔离(SSI)
一方面,我们实现了性能不好(2PL)或者扩展性不好(串行执行)的可序列化隔离级别。另一方面,我们有性能良好的弱隔离级别,但容易出现各种竞争条件(丢失更新,写入偏差,幻读等
今天,
悲观与乐观的并发控制
两阶段锁是一种所谓的悲观并发控制机制(pessimistic
相比之下,序列化快照隔离是一种乐观(optimistic)的并发控制技术。在这种情况下,乐观意味着,如果存在潜在的危险也不阻止事务,而是继续执行事务,希望一切都会好起来。当一个事务想要提交时,数据库检查是否有什么不好的事情发生(即隔离是否被违反
乐观并发控制是一个古老的想法,其优点和缺点已经争论了很长时间。如果存在很多争用(contention
顾名思义,
基于过时前提的决策
先前讨论了快照隔离中的写入偏差时,我们观察到一个循环模式:事务从数据库读取一些数据,检查查询的结果,并根据它看到的结果决定采取一些操作(写入数据库
当应用程序进行查询时(例如
数据库如何知道查询结果是否可能已经改变?有两种情况需要考虑:
- 检测对旧
MVCC 对象版本的读取(读之前存在未提交的写入) - 检测影响先前读取的写入(读之后发生写入)
检测旧MVCC 读取
快照隔离通常是通过多版本并发控制来实现的,当一个事务从

为了防止这种异常,数据库需要跟踪一个事务由于
为什么要等到提交?当检测到陈旧的读取时,为什么不立即中止事务
检测影响之前读取的写入
第二种情况要考虑的是另一个事务在读取数据之后修改数据。

在两阶段锁定的上下文中,我们讨论了索引范围锁,它允许数据库锁定与某个搜索查询匹配的所有行的访问权,例如
当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁,但锁并不会阻塞事务到其他事务完成,而是像一个引线一样只是简单通知其他事务:你们读过的数据可能不是最新的啦。事务
可序列化的快照隔离的性能
与往常一样,许多工程细节会影响算法的实际表现。例如一个权衡是跟踪事务的读取和写入的粒度(granularity
在某些情况下,事务可以读取被另一个事务覆盖的信息:这取决于发生了什么,有时可以证明执行结果无论如何都是可序列化的。
与串行执行相比,可序列化快照隔离并不局限于单个