快照隔离与可串行化

快照隔离与可串行化

什么是快照隔离

快照隔离(Snapshot isolation),指的是每次事务启动时,数据库就为这个事务提供一个当前数据库的一致性快照(数据库中所有已提交的记录),后续其他事务对数据的增删改操作,这个事务都看不到,它始终看到的是自己的一致性快照。

实现

快照隔离需要使用长写锁来防止脏写。(参阅[[2023-03-03-database-transactions]])。因此意味着进行写入的事务会阻止其他事务修改相同的对象。但读取则不需要加锁,从性能的角度来看,快照隔离的关键原则是:读不阻塞写,写不阻塞读

为了实现快照隔离,数据库需要保留一个对象的多个不同提交版本,因为各种正在进行的事务可能需要看到数据库在不同时间点的状态。因为数据库同时维护耽搁对象的多个版本,所以这种技术被称为多版本并发控制(MVCC, Multi-version concurrency control)

上图说明了 PostgreSQL 如何实现 MVCC 的快照隔离(其他实现类似)。当一个事务开始时,它被赋予一个唯一的,永远增长的事务ID(txid)。每当事务向数据库写入任何内容时,它所写入的数据都会被标记上写入者的事务ID。

事务ID是一个32位的整数,所以大约会在40亿次事务后溢出。PostgreSQL 的 Vacuum 过程会清理老旧的事务ID,确保事务ID溢出(回卷)不会影响到数据。

表中的每一行都有一个 created_by 字段,其中包含将改行插入到表中的事务ID。此外,每行都有一个 deleted_by 字段,保存删除该记录的事务ID。在稍后的时间,当确定没有事务可以再访问到被删除的数据时,数据库的垃圾收集过程会将所有带有删除标记的行移除,并释放空间。

UPDATE 操作在内部翻译为 DELETEINSERT 。例如在事务13从账户2中扣除 100 美元,将余额从500美元改为400美元。实际上包含两条账户2的记录:余额为$500的行被标记为被事务13删除,余额为 $400的行由事务13创建

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

当一个事务从数据库中读取时,事务ID可以决定该事务能看到哪些对象,看不到哪些对象。具体的规则如下:

  1. 在每次事务开始时,数据库列出当时所有其他尚未提交或尚未终止的事务,后面即使这些事务已提交,执行的任何写入当前事务都会忽略掉;
  2. 被终止事务所执行的任何写入都会被忽略;
  3. 具有较晚事务ID(即当前事务之后开始的事务)的事务所做的任何写入都会被忽略,不管这些事务是否已提交;
  4. 所有其他写入,对应用都是可见的;

换句话说,如果一下两个条件都成立,则对象对事务可见:

快照隔离级别

快照隔离是一个有用的隔离级别,特别对于只读事务而言。但许多数据库实现了它,却用不同的名字来称呼。在 Oracle 中称为 可串行化,在 PostgreSQL 和 MySQL 中称为 可重复读

这种命名混淆的原因是 SQL 标准中没有 快照隔离 的概念,因为快照隔离诞生于标准制定之后,因此数据库把快照隔离和标准概念中的可重复读等同,因为这两者在表现上很相似。

MySQL的可重复读级别实际上是快照隔离,甚至读已提交级别也是快照隔离,只是将每个事务一个快照改为了每次提交一个快照。

快照隔离不能防止 丢失更新 。[[2023-03-03-database-transactions]]

对于丢失更新的问题,有几种常见的处理方式:

原子写

如果数据库支持原子写入,类似于 r1[x] --- w1[x] --- c1 的序列就会变得安全。例如

update counters set value = value + 1 where key = 'foo';

MySQL 目前还不支持原子写入,需要在查询语句上显式添加 FOR UPFATEFOR SHARE 上锁。 see Locking Reads

显式锁定

MySQL 支持的方式。这种方式考验代码编写能力,如果忘记在某处加锁,很容易引起并发问题。

自动检测丢失更新

原子操作和加锁通过强制 读取 - 修改 - 写入序列 按顺序发生,从而防止丢失更新。另一种方式是允许它们并发执行,如果事务管理器检测到丢失更新,则终止事务并强制它们重试。

这种方式的优点是,数据库可以结合快照隔离高效地执行此检查。PostgreSQL的可重复读,Oracla的可串行化和SQL Server的快照隔离级别,都支持自动检测丢失更新。但 MySQL/InnoDB 的可重复读不支持自动检测。有些作者认为数据库只有防止丢失更新才称得上是提供了快照隔离,在这个定义下,MySQL不支持快照隔离。

比较并设置(CAS)

在不提供事务的数据库中可能支持该操作。

可串行化

可串行化(Serializability) 隔离被认为是事务最强的隔离级别,它保证即使事务可以并行执行,最终结果也是一样的,就好像它们没有任何并发性,是连续挨个执行的一样。换句话说,数据库可以防止 所有 可能的竞态条件。

目前的可串行化都使用了以下三种技术之一:

真正的串行执行

虽然串行执行是最简单的避免并发问题的手段,但受限于性能要求,真正的串行执行过去几乎无人问津。直到服务器可以大规模增加内存,内存数据库逐步问世,才渐渐有了一些落地,例如被广泛使用的 Redis 就是串行执行写入的,对应的事务过程,可以通过 Lua 脚本在内存中执行。

但这种方式也有缺点,首先处理能力限制在CPU单核上,可伸缩性不强。另外,一旦有缓慢的事务,就会拖慢整个系统的处理。

两阶段锁定

参见[[2023-03-06-2pl]]。

可串行化快照隔离

前面提到的两种串行化方案,2PL的性能不好,串行执行的伸缩性不好,有没有一种方案能兼顾这两者?

答案是 可串行化快照隔离(SSI, serializable snapshot isolation),它提供了完整的可串行化隔离级别,但与快照隔离相比只有很小的性能损失。如今,SSI 既用于单节点数据库(PostgreSQL 9.1之后的可串行化隔离级别),也用于分布式数据库。

SSI 基于快照隔离。在快照隔离的基础上,添加了一种乐观算法来检测多个事务写入之间的串行化冲突,并确定要终止哪些事务。

前面提到的快照隔离级别不能阻止更新丢失的异相,根本原因是事务基于查询结果这个前提做出一些修改,也就是说查询和写入存在因果依赖。但这个前提可能被其他事务所改变,因此为了提供可串行化的快照隔离级别,数据库必须能检测到其他事务修改了当前事务的因果依赖,并终止事务。

数据库如何检测到当前事务的查询结果被修改?有两种情况需要考虑:

检测旧MVCC的读取

回想一下上个章节提到的快照隔离可见性规则,其中有一条是:当前事务会忽略取事务开始时尚未提交的其他事务的写入。在下图中,事务 43 认为 Alice 的 on_call = true ,因为事务 42(修改 Alice的待命状态)尚未提交。然而,当事务 43 想要提交时,事务 42 已经提交。这意味着在读一致性快照时被忽略的写入已经生效,事务 43 的前提不再为真。(因而导致 1234 班次没有可排班的医生)

为了防止这种异常,数据库需要跟踪一个事务由于 MVCC 可见性规则而忽略另一个事务的写入。当事务想要提交时,数据库检查是否有任何被忽略的写入现在已经被提交。如果是这样,事务必须中止。

为什么要等到提交?当检测到陈旧的读取时,为什么不立即中止事务 43 ?因为如果事务 43 是只读事务,则不需要中止,因为没有写入偏差的风险。当事务 43 进行读取时,数据库还不知道事务是否要稍后执行写操作。此外,事务 42 可能在事务 43 被提交的时候中止或者可能仍然未被提交,因此读取可能终究不是陈旧的。通过避免不必要的中止,SSI 保留了快照隔离从一致快照中长时间读取的能力。

检测影响之前读取的写入

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

图 7-11 在可串行化快照隔离中,检测一个事务合适修改另一个事务的读取

在 2PL 的章节中,我们提到了 索引范围锁,它允许数据库锁定与某个搜索查询匹配的所有行的访问权,例如 where shift_id = 1234。可以在这里使用类似的技术,除了 SSI 锁不会阻塞其他事务。

在图 7-11 中,事务 42 和 43 都在班次 1234 查找值班医生,如果在 shift_id 上有索引,则数据库可以使用索引项 1234 来记录事务 42 和 43 读取这个数据的事实。(如果没有索引,这个信息可以在表级别进行跟踪)。这个信息只需要保留一段时间:在一个事务完成(提交或终止),并且所有的并发事务完成之后,数据库就可以删除该信息。

当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于获取锁,但锁不会阻塞该事务的写入,而是通知其他事务:你们刚才读取的数据已经过时。

在上图中,事务 43 通知事务 42 其先前读已过时,反之亦然。事务 42 首先提交并成功,尽管事务 43 的写影响了 42 ,但因为事务 43 尚未提交,所以写入尚未生效。然而当事务 43 想要提交时,来自事务 42 的冲突写入已经被提交,所以事务 43 必须中止。

SSI 的性能

中止率显著影响 SSI 的整体表现。例如,长时间读取和写入数据的事务很可能会发生冲突并中止,因此 SSI 要求同时读写的事务尽量短(只读的长事务可能没问题)。对于慢事务,SSI 可能比两阶段锁定或串行执行更不敏感。SSI 的乐观检测机制,在频繁发生写入冲突的场景下也可能表现不佳。