Lazy loaded image
5、事务
00 分钟
2024-10-9

隔离级别

 

可串行的(serializable)  隔离等级意味着数据库保证事务的效果如同串行运行(即一次一个,没有任何并发)。影响性能

读已提交(Read Committed)

最基本的事务隔离级别是 读已提交(Read Committed)
它提供了两个保证:
  1. 从数据库读时,只能看到已提交的数据(没有 脏读,即 dirty reads)。
  1. 写入数据库时,只会覆盖已经写入的数据(没有 脏写,即 dirty writes)。
某些数据库支持甚至更弱的隔离级别,称为 读未提交(Read uncommitted)。它可以防止脏写,但不防止脏读。
notion image

快照隔离和可重复读

如果只从表面上看读已提交隔离级别你就认为它完成了事务所需的一切,这是情有可原的。它允许 中止(原子性的要求);它防止读取不完整的事务结果,并且防止并发写入造成的混乱。事实上这些功能非常有用,比起没有事务的系统来,可以提供更多的保证。
但是在使用此隔离级别时,仍然有很多地方可能会产生并发错误。例如 图 7-6 说明了读已提交时可能发生的问题。
notion image
图 7-6 读取偏差:Alice 观察数据库处于不一致的状态
爱丽丝在银行有 1000 美元的储蓄,分为两个账户,每个 500 美元。现在有一笔事务从她的一个账户转移了 100 美元到另一个账户。如果她非常不幸地在事务处理的过程中查看其账户余额列表,她可能会在收到付款之前先看到一个账户的余额(收款账户,余额仍为 500 美元),在发出转账之后再看到另一个账户的余额(付款账户,新余额为 400 美元)。对爱丽丝来说,现在她的账户似乎总共只有 900 美元 —— 看起来有 100 美元已经凭空消失了。
这种异常被称为 不可重复读(nonrepeatable read) 或 读取偏差(read skew):如果 Alice 在事务结束时再次读取账户 1 的余额,她将看到与她之前的查询中看到的不同的值(600 美元)。在读已提交的隔离条件下,不可重复读 被认为是可接受的:Alice 看到的帐户余额时确实在阅读时已经提交了。
不幸的是,术语 偏差(skew) 这个词是过载的:以前使用它是因为热点的不平衡工作量(请参阅 “负载偏斜与热点消除”),而这里偏差意味着异常的时序。
快照隔离(snapshot isolation)【28】是这个问题最常见的解决方案。想法是,每个事务都从数据库的 一致快照(consistent snapshot) 中读取 —— 也就是说,事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。
快照隔离对长时间运行的只读查询(如备份和分析)非常有用。如果查询的数据在查询执行的同时发生变化,则很难理解查询的含义。当一个事务可以看到数据库在某个特定时间点冻结时的一致快照,理解起来就很容易了。
快照隔离是一个流行的功能:PostgreSQL、使用 InnoDB 引擎的 MySQL、Oracle、SQL Server 等都支持【23,31,32】。

实现快照隔离

与读取提交的隔离类似,快照隔离的实现通常使用写锁来防止脏写(请参阅 “读已提交”),这意味着进行写入的事务会阻止另一个事务修改同一个对象。但是读取不需要任何锁定。从性能的角度来看,快照隔离的一个关键原则是:读不阻塞写,写不阻塞读。这允许数据库在处理一致性快照上的长时间查询时,可以正常地同时处理写入操作。且两者间没有任何锁定争用。

写入偏差 幻读

前面的章节中,我们看到了 脏写 和 丢失更新,当不同的事务并发地尝试写入相同的对象时,会出现这两种竞争条件。为了避免数据损坏,这些竞争条件需要被阻止 —— 既可以由数据库自动执行,也可以通过锁和原子写操作这类手动安全措施来防止。
但是,并发写入间可能发生的竞争条件还没有完。在本节中,我们将看到一些更微妙的冲突例子。
首先,想象一下这个例子:你正在为医院写一个医生轮班管理程序。医院通常会同时要求几位医生待命,但底线是至少有一位医生在待命。医生可以放弃他们的班次(例如,如果他们自己生病了),只要至少有一个同事在这一班中继续工作【40,41】。
现在想象一下,Alice 和 Bob 是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮下班。图 7-8 说明了接下来的事情。
notion image
图 7-8 写入偏差导致应用程序错误的示例
在两个事务中,应用首先检查是否有两个或以上的医生正在值班;如果是的话,它就假定一名医生可以安全地休班。由于数据库使用快照隔离,两次检查都返回 2 ,所以两个事务都进入下一个阶段。Alice 更新自己的记录休班了,而 Bob 也做了一样的事情。两个事务都成功提交了,现在没有医生值班了。违反了至少有一名医生在值班的要求。

写偏差的特征

这种异常称为 写偏差【28】。它既不是 脏写,也不是 丢失更新,因为这两个事务正在更新两个不同的对象(Alice 和 Bob 各自的待命记录)。在这里发生的冲突并不是那么明显,但是这显然是一个竞争条件:如果两个事务一个接一个地运行,那么第二个医生就不能歇班了。异常行为只有在事务并发进行时才有可能。
可以将写入偏差视为丢失更新问题的一般化。如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),则可能发生写入偏差。在多个事务更新同一个对象的特殊情况下,就会发生脏写或丢失更新(取决于时序)。
我们已经看到,有各种不同的方法来防止丢失的更新。但对于写偏差,我们的选择更受限制:
  • 由于涉及多个对象,单对象的原子操作不起作用。
  • 不幸的是,在一些快照隔离的实现中,自动检测丢失更新对此并没有帮助。在 PostgreSQL 的可重复读,MySQL/InnoDB 的可重复读,Oracle 可串行化或 SQL Server 的快照隔离级别中,都不会自动检测写入偏差【23】。自动防止写入偏差需要真正的可串行化隔离(请参阅 “可串行化”)。
  • 某些数据库允许配置约束,然后由数据库强制执行(例如,唯一性,外键约束或特定值限制)。但是为了指定至少有一名医生必须在线,需要一个涉及多个对象的约束。大多数数据库没有内置对这种约束的支持,但是你可以使用触发器,或者物化视图来实现它们,这取决于不同的数据库【42】。
  • 如果无法使用可串行化的隔离级别,则此情况下的次优选项可能是显式锁定事务所依赖的行。在例子中,你可以写下如下的代码:
  • 和以前一样,FOR UPDATE 告诉数据库锁定返回的所有行以用于更新。

写偏差的更多例子

写偏差乍看像是一个深奥的问题,但一旦意识到这一点,很容易会注意到更多可能的情况。以下是一些例子:
  • 会议室预订系统
    • 比如你想要规定不能在同一时间对同一个会议室进行多次的预订【43】。当有人想要预订时,首先检查是否存在相互冲突的预订(即预订时间范围重叠的同一房间),如果没有找到,则创建会议(请参阅示例 7-2)[^ix]。
      [^ix]: 在 PostgreSQL 中,你可以使用范围类型优雅地执行此操作,但在其他数据库中并未得到广泛支持。
      例 7-2 会议室预订系统试图避免重复预订(在快照隔离下不安全)
      不幸的是,快照隔离并不能防止另一个用户同时插入冲突的会议。为了确保不会遇到调度冲突,你又需要可串行化的隔离级别了。
  • 多人游戏
    • 在 例 7-1 中,我们使用一个锁来防止丢失更新(也就是确保两个玩家不能同时移动同一个棋子)。但是锁定并不妨碍玩家将两个不同的棋子移动到棋盘上的相同位置,或者采取其他违反游戏规则的行为。按照你正在执行的规则类型,也许可以使用唯一约束(unique constraint),否则你很容易发生写入偏差。
  • 抢注用户名
    • 在每个用户拥有唯一用户名的网站上,两个用户可能会尝试同时创建具有相同用户名的帐户。可以在事务检查名称是否被抢占,如果没有则使用该名称创建账户。但是像在前面的例子中那样,在快照隔离下这是不安全的。幸运的是,唯一约束是一个简单的解决办法(第二个事务在提交时会因为违反用户名唯一约束而被中止)。
  • 防止双重开支
    • 允许用户花钱或积分的服务,需要检查用户的支付数额不超过其余额。可以通过在用户的帐户中插入一个试探性的消费项目来实现这一点,列出帐户中的所有项目,并检查总和是否为正值【44】。有了写入偏差,可能会发生两个支出项目同时插入,一起导致余额变为负值,但这两个事务都不会注意到另一个。

导致写入偏差的幻读

所有这些例子都遵循类似的模式:
  1. 一个 SELECT 查询找出符合条件的行,并检查是否符合一些要求。(例如:至少有两名医生在值班;不存在对该会议室同一时段的预定;棋盘上的位置没有被其他棋子占据;用户名还没有被抢注;账户里还有足够余额)
  1. 按照第一个查询的结果,应用代码决定是否继续。(可能会继续操作,也可能中止并报错)
  1. 如果应用决定继续操作,就执行写入(插入、更新或删除),并提交事务。
    1. 这个写入的效果改变了步骤 2 中的先决条件。换句话说,如果在提交写入后,重复执行一次步骤 1 的 SELECT 查询,将会得到不同的结果。因为写入改变了符合搜索条件的行集(现在少了一个医生值班,那时候的会议室现在已经被预订了,棋盘上的这个位置已经被占据了,用户名已经被抢注,账户余额不够了)。
这些步骤可能以不同的顺序发生。例如可以首先进行写入,然后进行 SELECT 查询,最后根据查询结果决定是放弃还是提交。
在医生值班的例子中,在步骤 3 中修改的行,是步骤 1 中返回的行之一,所以我们可以通过锁定步骤 1 中的行(SELECT FOR UPDATE)来使事务安全并避免写入偏差。但是其他四个例子是不同的:它们检查是否 不存在 某些满足条件的行,写入会 添加 一个匹配相同条件的行。如果步骤 1 中的查询没有返回任何行,则 SELECT FOR UPDATE 锁不了任何东西。
这种效应:一个事务中的写入改变另一个事务的搜索查询的结果,被称为 幻读【3】。快照隔离避免了只读查询中幻读,但是在像我们讨论的例子那样的读写事务中,幻读会导致特别棘手的写入偏差情况。

物化冲突

如果幻读的问题是没有对象可以加锁,也许可以人为地在数据库中引入一个锁对象?
例如,在会议室预订的场景中,可以想象创建一个关于时间槽和房间的表。此表中的每一行对应于特定时间段(例如 15 分钟)的特定房间。可以提前插入房间和时间的所有可能组合行(例如接下来的六个月)。
现在,要创建预订的事务可以锁定(SELECT FOR UPDATE)表中与所需房间和时间段对应的行。在获得锁定之后,它可以检查重叠的预订并像以前一样插入新的预订。请注意,这个表并不是用来存储预订相关的信息 —— 它完全就是一组锁,用于防止同时修改同一房间和时间范围内的预订。
这种方法被称为 物化冲突(materializing conflicts),因为它将幻读变为数据库中一组具体行上的锁冲突【11】。不幸的是,弄清楚如何物化冲突可能很难,也很容易出错,而让并发控制机制泄漏到应用数据模型是很丑陋的做法。出于这些原因,如果没有其他办法可以实现,物化冲突应被视为最后的手段。在大多数情况下。可串行化(Serializable) 的隔离级别是更可取的。

两阶段锁定

大约 30 年来,在数据库中只有一种广泛使用的串行化算法:两阶段锁定(2PL,two-phase locking) [^xi]
[^xi]: 有时也称为 严格两阶段锁定(SS2PL, strong strict two-phase locking),以便和其他 2PL 变体区分。
2PL不是2PC
请注意,虽然两阶段锁定(2PL)听起来非常类似于两阶段提交(2PC),但它们是完全不同的东西。我们将在 第九章 讨论 2PC。
之前我们看到锁通常用于防止脏写(请参阅 “没有脏写” 一节):如果两个事务同时尝试写入同一个对象,则锁可确保第二个写入必须等到第一个写入完成事务(中止或提交),然后才能继续。
两阶段锁定类似,但是锁的要求更强得多。只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入(修改或删除),就需要 独占访问(exclusive access) 权限:
  • 如果事务 A 读取了一个对象,并且事务 B 想要写入该对象,那么 B 必须等到 A 提交或中止才能继续(这确保 B 不能在 A 底下意外地改变对象)。
  • 如果事务 A 写入了一个对象,并且事务 B 想要读取该对象,则 B 必须等到 A 提交或中止才能继续(像 图 7-1 那样读取旧版本的对象在 2PL 下是不可接受的)。
在 2PL 中,写入不仅会阻塞其他写入,也会阻塞读,反之亦然。快照隔离使得 读不阻塞写,写也不阻塞读(请参阅 “实现快照隔离”),这是 2PL 和快照隔离之间的关键区别。另一方面,因为 2PL 提供了可串行化的性质,它可以防止早先讨论的所有竞争条件,包括丢失更新和写入偏差。

实现两阶段锁

2PL 用于 MySQL(InnoDB)和 SQL Server 中的可串行化隔离级别,以及 DB2 中的可重复读隔离级别【23,36】。
读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于 共享模式(shared mode) 或 独占模式(exclusive mode)。锁使用如下:
  • 若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
  • 若事务要写入一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式),所以如果对象上存在任何锁,该事务必须等待。
  • 如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得独占锁相同。
  • 事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)。这就是 “两阶段” 这个名字的来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。
由于使用了这么多的锁,因此很可能会发生:事务 A 等待事务 B 释放它的锁,反之亦然。这种情况叫做 死锁(Deadlock)。数据库会自动检测事务之间的死锁,并中止其中一个,以便另一个继续执行。被中止的事务需要由应用程序重试。

两阶段锁定的性能

两阶段锁定的巨大缺点,以及 70 年代以来没有被所有人使用的原因,是其性能问题。两阶段锁定下的事务吞吐量与查询响应时间要比弱隔离级别下要差得多。
这一部分是由于获取和释放所有这些锁的开销,但更重要的是由于并发性的降低。按照设计,如果两个并发事务试图做任何可能导致竞争条件的事情,那么必须等待另一个完成。
传统的关系数据库不限制事务的持续时间,因为它们是为等待人类输入的交互式应用而设计的。因此,当一个事务需要等待另一个事务时,等待的时长并没有限制。即使你保证所有的事务都很短,如果有多个事务想要访问同一个对象,那么可能会形成一个队列,所以事务可能需要等待几个其他事务才能完成。
因此,运行 2PL 的数据库可能具有相当不稳定的延迟,如果在工作负载中存在争用,那么可能高百分位点处的响应会非常的慢(请参阅 “描述性能”)。可能只需要一个缓慢的事务,或者一个访问大量数据并获取许多锁的事务,就能把系统的其他部分拖慢,甚至迫使系统停机。当需要稳健的操作时,这种不稳定性是有问题的。
基于锁实现的读已提交隔离级别可能发生死锁,但在基于 2PL 实现的可串行化隔离级别中,它们会出现的频繁的多(取决于事务的访问模式)。这可能是一个额外的性能问题:当事务由于死锁而被中止并被重试时,它需要从头重做它的工作。如果死锁很频繁,这可能意味着巨大的浪费。

谓词锁

在前面关于锁的描述中,我们掩盖了一个微妙而重要的细节。在 “导致写入偏差的幻读” 中,我们讨论了 幻读(phantoms) 的问题。即一个事务改变另一个事务的搜索查询的结果。具有可串行化隔离级别的数据库必须防止 幻读
在会议室预订的例子中,这意味着如果一个事务在某个时间窗口内搜索了一个房间的现有预订(见 例 7-2),则另一个事务不能同时插入或更新同一时间窗口与同一房间的另一个预订 (可以同时插入其他房间的预订,或在不影响另一个预定的条件下预定同一房间的其他时间段)。
如何实现这一点?从概念上讲,我们需要一个 谓词锁(predicate lock)【3】。它类似于前面描述的共享 / 排它锁,但不属于特定的对象(例如,表中的一行),它属于所有符合某些搜索条件的对象,如:
谓词锁限制访问,如下所示:
  • 如果事务 A 想要读取匹配某些条件的对象,就像在这个 SELECT 查询中那样,它必须获取查询条件上的 共享谓词锁(shared-mode predicate lock)。如果另一个事务 B 持有任何满足这一查询条件对象的排它锁,那么 A 必须等到 B 释放它的锁之后才允许进行查询。
  • 如果事务 A 想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务 B 持有匹配的谓词锁,那么 A 必须等到 B 已经提交或中止后才能继续。
这里的关键思想是,谓词锁甚至适用于数据库中尚不存在,但将来可能会添加的对象(幻象)。如果两阶段锁定包含谓词锁,则数据库将阻止所有形式的写入偏差和其他竞争条件,因此其隔离实现了可串行化。

索引范围锁

不幸的是谓词锁性能不佳:如果活跃事务持有很多锁,检查匹配的锁会非常耗时。 因此,大多数使用 2PL 的数据库实际上实现了索引范围锁(index-range locking,也称为 next-key locking),这是一个简化的近似版谓词锁【41,50】。
通过使谓词匹配到一个更大的集合来简化谓词锁是安全的。例如,如果你有在中午和下午 1 点之间预订 123 号房间的谓词锁,则锁定 123 号房间的所有时间段,或者锁定 12:00~13:00 时间段的所有房间(不只是 123 号房间)是一个安全的近似,因为任何满足原始谓词的写入也一定会满足这种更松散的近似。
在房间预订数据库中,你可能会在 room_id 列上有一个索引,并且 / 或者在 start_time 和 end_time 上有索引(否则前面的查询在大型数据库上的速度会非常慢):
  • 假设你的索引位于 room_id 上,并且数据库使用此索引查找 123 号房间的现有预订。现在数据库可以简单地将共享锁附加到这个索引项上,指示事务已搜索 123 号房间用于预订。
  • 或者,如果数据库使用基于时间的索引来查找现有预订,那么它可以将共享锁附加到该索引中的一系列值,指示事务已经将 12:00~13:00 时间段标记为用于预定。
无论哪种方式,搜索条件的近似值都附加到其中一个索引上。现在,如果另一个事务想要插入,更新或删除同一个房间和 / 或重叠时间段的预订,则它将不得不更新索引的相同部分。在这样做的过程中,它会遇到共享锁,它将被迫等到锁被释放。
这种方法能够有效防止幻读和写入偏差。索引范围锁并不像谓词锁那样精确(它们可能会锁定更大范围的对象,而不是维持可串行化所必需的范围),但是由于它们的开销较低,所以是一个很好的折衷。
如果没有可以挂载范围锁的索引,数据库可以退化到使用整个表上的共享锁。这对性能不利,因为它会阻止所有其他事务写入表格,但这是一个安全的回退位置。

可串行化快照隔离

本章描绘了数据库中并发控制的黯淡画面。一方面,我们实现了性能不好(2PL)或者伸缩性不好(串行执行)的可串行化隔离级别。另一方面,我们有性能良好的弱隔离级别,但容易出现各种竞争条件(丢失更新,写入偏差,幻读等)。串行化的隔离级别和高性能是从根本上相互矛盾的吗?
也许不是:一个称为 可串行化快照隔离(SSI, serializable snapshot isolation) 的算法是非常有前途的。它提供了完整的可串行化隔离级别,但与快照隔离相比只有很小的性能损失。 SSI 是相当新的:它在 2008 年首次被描述【40】,并且是 Michael Cahill 的博士论文【51】的主题。
今天,SSI 既用于单节点数据库(PostgreSQL9.1 以后的可串行化隔离级别)和分布式数据库(FoundationDB 使用类似的算法)。由于 SSI 与其他并发控制机制相比还很年轻,还处于在实践中证明自己表现的阶段。但它有可能因为足够快而在未来成为新的默认选项。

悲观与乐观的并发控制

两阶段锁是一种所谓的 悲观并发控制机制(pessimistic) :它是基于这样的原则:如果有事情可能出错(如另一个事务所持有的锁所表示的),最好等到情况安全后再做任何事情。这就像互斥,用于保护多线程编程中的数据结构。
从某种意义上说,串行执行可以称为悲观到了极致:在事务持续期间,每个事务对整个数据库(或数据库的一个分区)具有排它锁,作为对悲观的补偿,我们让每笔事务执行得非常快,所以只需要短时间持有 “锁”。
相比之下,串行化快照隔离 是一种 乐观(optimistic) 的并发控制技术。在这种情况下,乐观意味着,如果存在潜在的危险也不阻止事务,而是继续执行事务,希望一切都会好起来。当一个事务想要提交时,数据库检查是否有什么不好的事情发生(即隔离是否被违反);如果是的话,事务将被中止,并且必须重试。只有可串行化的事务才被允许提交。
乐观并发控制是一个古老的想法【52】,其优点和缺点已经争论了很长时间【53】。如果存在很多 争用(contention,即很多事务试图访问相同的对象),则表现不佳,因为这会导致很大一部分事务需要中止。如果系统已经接近最大吞吐量,来自重试事务的额外负载可能会使性能变差。
但是,如果有足够的备用容量,并且事务之间的争用不是太高,乐观的并发控制技术往往比悲观的要好。可交换的原子操作可以减少争用:例如,如果多个事务同时要增加一个计数器,那么应用增量的顺序(只要计数器不在同一个事务中读取)就无关紧要了,所以并发增量可以全部应用且无需冲突。
顾名思义,SSI 基于快照隔离 —— 也就是说,事务中的所有读取都是来自数据库的一致性快照(请参阅 “快照隔离和可重复读取”)。与早期的乐观并发控制技术相比这是主要的区别。在快照隔离的基础上,SSI 添加了一种算法来检测写入之间的串行化冲突,并确定要中止哪些事务。

基于过时前提的决策

先前讨论了快照隔离中的写入偏差(请参阅 “写入偏斜与幻读”)时,我们观察到一个循环模式:事务从数据库读取一些数据,检查查询的结果,并根据它看到的结果决定采取一些操作(写入数据库)。但是,在快照隔离的情况下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在同一时间被修改。
换句话说,事务基于一个 前提(premise) 采取行动(事务开始时候的事实,例如:“目前有两名医生正在值班”)。之后当事务要提交时,原始数据可能已经改变 —— 前提可能不再成立。
当应用程序进行查询时(例如,“当前有多少医生正在值班?”),数据库不知道应用逻辑如何使用该查询结果。在这种情况下为了安全,数据库需要假设任何对该结果集的变更都可能会使该事务中的写入变得无效。 换而言之,事务中的查询与写入可能存在因果依赖。为了提供可串行化的隔离级别,如果事务在过时的前提下执行操作,数据库必须能检测到这种情况,并中止事务。
数据库如何知道查询结果是否可能已经改变?有两种情况需要考虑:
  • 检测对旧 MVCC 对象版本的读取(读之前存在未提交的写入)
  • 检测影响先前读取的写入(读之后发生写入)

检测旧MVCC读取

回想一下,快照隔离通常是通过多版本并发控制(MVCC;见 图 7-10)来实现的。当一个事务从 MVCC 数据库中的一致快照读时,它将忽略取快照时尚未提交的任何其他事务所做的写入。在 图 7-10 中,事务 43 认为 Alice 的 on_call = true ,因为事务 42(修改 Alice 的待命状态)未被提交。然而,在事务 43 想要提交时,事务 42 已经提交。这意味着在读一致性快照时被忽略的写入已经生效,事务 43 的前提不再为真。
notion image
图 7-10 检测事务何时从 MVCC 快照读取过时的值
为了防止这种异常,数据库需要跟踪一个事务由于 MVCC 可见性规则而忽略另一个事务的写入。当事务想要提交时,数据库检查是否有任何被忽略的写入现在已经被提交。如果是这样,事务必须中止。
为什么要等到提交?当检测到陈旧的读取时,为什么不立即中止事务 43 ?因为如果事务 43 是只读事务,则不需要中止,因为没有写入偏差的风险。当事务 43 进行读取时,数据库还不知道事务是否要稍后执行写操作。此外,事务 42 可能在事务 43 被提交的时候中止或者可能仍然未被提交,因此读取可能终究不是陈旧的。通过避免不必要的中止,SSI 保留了快照隔离从一致快照中长时间读取的能力。

检测影响之前读取的写入

第二种情况要考虑的是另一个事务在读取数据之后修改数据。这种情况如 图 7-11 所示。
notion image
图 7-11 在可串行化快照隔离中,检测一个事务何时修改另一个事务的读取。
在两阶段锁定的上下文中,我们讨论了索引范围锁(请参阅 “索引范围锁”),它允许数据库锁定与某个搜索查询匹配的所有行的访问权,例如 WHERE shift_id = 1234。可以在这里使用类似的技术,除了 SSI 锁不会阻塞其他事务。
在 图 7-11 中,事务 42 和 43 都在班次 1234 查找值班医生。如果在 shift_id 上有索引,则数据库可以使用索引项 1234 来记录事务 42 和 43 读取这个数据的事实。 (如果没有索引,这个信息可以在表级别进行跟踪)。这个信息只需要保留一段时间:在一个事务完成(提交或中止),并且所有的并发事务完成之后,数据库就可以忘记它读取的数据了。
当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁,但锁并不会阻塞事务直到其他读事务完成,而是像警戒线一样只是简单通知其他事务:你们读过的数据可能不是最新的啦。
在 图 7-11 中,事务 43 通知事务 42 其先前读已过时,反之亦然。事务 42 首先提交并成功,尽管事务 43 的写影响了 42 ,但因为事务 43 尚未提交,所以写入尚未生效。然而当事务 43 想要提交时,来自事务 42 的冲突写入已经被提交,所以事务 43 必须中止。

可串行化快照隔离的性能

与往常一样,许多工程细节会影响算法的实际表现。例如一个权衡是跟踪事务的读取和写入的 粒度(granularity)。如果数据库详细地跟踪每个事务的活动(细粒度),那么可以准确地确定哪些事务需要中止,但是簿记开销可能变得很显著。简略的跟踪速度更快(粗粒度),但可能会导致更多不必要的事务中止。
在某些情况下,事务可以读取被另一个事务覆盖的信息:这取决于发生了什么,有时可以证明执行结果无论如何都是可串行化的。 PostgreSQL 使用这个理论来减少不必要的中止次数【11,41】。
与两阶段锁定相比,可串行化快照隔离的最大优点是一个事务不需要阻塞等待另一个事务所持有的锁。就像在快照隔离下一样,写不会阻塞读,反之亦然。这种设计原则使得查询延迟更可预测,变量更少。特别是,只读查询可以运行在一致快照上,而不需要任何锁定,这对于读取繁重的工作负载非常有吸引力。
与串行执行相比,可串行化快照隔离并不局限于单个 CPU 核的吞吐量:FoundationDB 将检测到的串行化冲突分布在多台机器上,允许扩展到很高的吞吐量。即使数据可能跨多台机器进行分区,事务也可以在保证可串行化隔离等级的同时读写多个分区中的数据【54】。
中止率显著影响 SSI 的整体表现。例如,长时间读取和写入数据的事务很可能会发生冲突并中止,因此 SSI 要求同时读写的事务尽量短(只读的长事务可能没问题)。对于慢事务,SSI 可能比两阶段锁定或串行执行更不敏感。
 
上一篇
flowable获取下一步会创建的用户任务
下一篇
Kafka实现oracle的CDC数据实时变更

评论
Loading...