03.0.快照隔离与可重复读

快照隔离和可重复读(Repeatable Read)

当隔离级别设置为 Repeatable Read 时,可以避免不可重复读。不可重复读是指事务 T1 读取数据后,事务 T2 执行更新操作,使 T1 无法再现前一次读取结果。具体地讲,不可重复读包括三种情况:

  • 事务 T1 读取某一数据后,事务 T2 对其做了修改,当事务 T1 再次读该数据时,得到与前一次不同的值。例如,T1 读取 B=100 进行运算,T2 读取同一数据 B,对其进行修改后将 B=200 写回数据库。T1 为了对读取值校对重读 B,B 已为 200,与第一次读取值不一致。
  • 事务 T1 按一定条件从数据库中读取了某些数据记录后,事务 T2 删除了其中部分记录,当 T1 再次按相同条件读取数据时,发现某些记录神密地消失了。
  • 事务 T1 按一定条件从数据库中读取某些数据记录后,事务 T2 插入了一些记录,当 T1 再次按相同条件读取数据时,发现多了一些记录,也就是幻读。

这是 MySQL 的默认事务隔离级别,它确保在一个事务内的相同查询条件的多次查询会看到同样的数据行,都是事务开始时的数据快照。虽然 Repeatable Read 避免了不可重复读,但还有可能出现幻读。简单说,就是当某个事务在读取某个范围内的记录时,另外的一个事务又在该范围内插入新的记录。在之前的事务在读取该范围的记录时,就会产生幻行,InnoDB 通过间隙锁(next-key locking)策略防止幻读的出现。

不可重复读案例

如果只从表面上看读已提交隔离级别你就认为它完成了事务所需的一切,那是可以原谅的。它允许中止(原子性的要求);它防止读取不完整的事务结果,并排写入的并发写入。事实上这些功能非常有用,比起没有事务的系统来,可以提供更多的保证。但是在使用此隔离级别时,仍然有很多地方可能会产生并发错误,下图就是一种可能发生的问题:

读取偏差:Alice观察数据库处于不一致的状态

爱丽丝在银行有 1000 美元的储蓄,分为两个账户,每个 500 美元。现在一笔事务从她的一个账户中转移了 100 美元到另一个账户。如果她在事务处理的同时查看其账户余额列表,不幸地在转账事务完成前看到收款账户余额(余额为 500 美元),而在转账完成后看到另一个转出账户(已经转出 100 美元,余额 400 美元)。对爱丽丝来说,现在她的账户似乎只有 900 美元,看起来 100 美元已经消失了。

这种异常被称为不可重复读(nonrepeatable read)或读取偏差(read skew):如果 Alice 在事务结束时再次读取账户 1 的余额,她将看到与她之前的查询中看到的不同的值(600 美元)。在读已提交的隔离条件下,不可重复读被认为是可接受的:Alice 看到的帐户余额时确实在阅读时已经提交了。对于 Alice 的情况,这不是一个长期持续的问题。因为如果她几秒钟后刷新银行网站的页面,她很可能会看到一致的帐户余额。但是有些情况下,不能容忍这种暂时的不一致:

  • 备份:进行备份需要复制整个数据库,对大型数据库而言可能需要花费数小时才能完成。备份进程运行时,数据库仍然会接受写入操作。因此备份可能会包含一些旧的部分和一些新的部分。如果从这样的备份中恢复,那么不一致(如消失的钱)就会变成永久的。

  • 分析查询和完整性检查:有时,您可能需要运行一个查询,扫描大部分的数据库。这样的查询在分析中很常见,也可能是定期完整性检查(即监视数据损坏)的一部分。如果这些查询在不同时间点观察数据库的不同部分,则可能会返回毫无意义的结果。

快照隔离(snapshot isolation)是这个问题最常见的解决方案。想法是,每个事务都从数据库的一致快照(consistent snapshot)中读取。也就是说,事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。快照隔离对长时间运行的只读查询(如备份和分析)非常有用。如果查询的数据在查询执行的同时发生变化,则很难理解查询的含义。当一个事务可以看到数据库在某个特定时间点冻结时的一致快照,理解起来就很容易了。快照隔离是一个流行的功能:PostgreSQL,使用 InnoDB 引擎的 MySQL,Oracle,SQL Server 等都支持。

实现快照隔离

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

为了实现快照隔离,数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它并排维护着多个版本的对象,所以这种技术被称为多版本并发控制(MVCC, multi-version concurrentcy control)。如果一个数据库只需要提供读已提交的隔离级别,而不提供快照隔离,那么保留一个对象的两个版本就足够了:提交的版本和被覆盖但尚未提交的版本。支持快照隔离的存储引擎通常也使用 MVCC 来实现读已提交隔离级别。一种典型的方法是读已提交为每个查询使用单独的快照,而快照隔离对整个事务使用相同的快照。

下图说明了如何在 PostgreSQL 中实现基于 MVCC 的快照隔离,当一个事务开始时,它被赋予一个唯一的,永远增长的事务 ID(txid)。每当事务向数据库写入任何内容时,它所写入的数据都会被标记上写入者的事务 ID。不过事实上,事务 ID 是 32 位整数,所以大约会在 40 亿次事务之后溢出。PostgreSQL 的 Vacuum 过程会清理老旧的事务 ID,确保事务 ID 溢出(回卷)不会影响到数据。

使用多版本对象实现快照隔离

表中的每一行都有一个 created_by(xmin)字段,其中包含将该行插入到表中的的事务 ID。此外,每行都有一个 deleted_by(xmax)字段,最初是空的。如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是通过将 deleted_by 字段设置为请求删除的事务的 ID 来标记为删除。在稍后的时间,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾收集过程会将所有带有删除标记的行移除,并释放其空间。

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

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

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

  • 在每次事务开始时,数据库列出当时所有其他(尚未提交或中止)的事务清单,即使之后提交了,这些事务的写入也都会被忽略。
  • 被中止事务所执行的任何写入都将被忽略。
  • 由具有较晚事务 ID(即,在当前事务开始之后开始的)的事务所做的任何写入都被忽略,而不管这些事务是否已经提交。
  • 所有其他写入,对应用都是可见的。

这些规则适用于创建和删除对象。当事务 12 从账户 2 读取时,它会看到 $500 的余额,因为 $500 余额的删除是由事务 13 完成的(根据规则 3,事务 12 看不到事务 13 执行的删除),且 400 美元记录的创建也是不可见的(按照相同的规则)。换句话说,如果以下两个条件都成立,则可见一个对象:

  • 读事务开始时,创建该对象的事务已经提交。
  • 对象未被标记为删除,或如果被标记为删除,请求删除的事务在读事务开始时尚未提交。

长时间运行的事务可能会长时间使用快照,并继续读取(从其他事务的角度来看)早已被覆盖或删除的值。由于从来不更新值,而是每次值改变时创建一个新的版本,数据库可以在提供一致快照的同时只产生很小的额外开销。

索引与快照隔离

索引如何在多版本数据库中工作?一种选择是使索引简单地指向对象的所有版本,并且需要索引查询来过滤掉当前事务不可见的任何对象版本。当垃圾收集删除任何事务不再可见的旧对象版本时,相应的索引条目也可以被删除。在实践中,许多实现细节决定了多版本并发控制的性能。例如,如果同一对象的不同版本可以放入同一个页面中,PostgreSQL 的优化可以避免更新索引。

在 CouchDB,Datomic 和 LMDB 中使用另一种方法。虽然它们也使用 B 树,但它们使用的是一种仅追加/写时拷贝(append-only/copy-on-write)的变体,它们在更新时不覆盖树的页面,而为每个修改页面创建一份副本。从父页面直到树根都会级联更新,以指向它们子页面的新版本。任何不受写入影响的页面都不需要被复制,并且保持不变。

使用仅追加的 B 树,每个写入事务(或一批事务)都会创建一颗新的 B 树,当创建时,从该特定树根生长的树就是数据库的一个一致性快照。没必要根据事务 ID 过滤掉对象,因为后续写入不能修改现有的 B 树;它们只能创建新的树根。但这种方法也需要一个负责压缩和垃圾收集的后台进程。

可重复读与命名混淆

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

这种命名混淆的原因是 SQL 标准没有快照隔离的概念,因为标准是基于 System R 1975 年定义的隔离级别,那时候快照隔离尚未发明。相反,它定义了可重复读,表面上看起来与快照隔离很相似。PostgreSQL 和 MySQL 称其快照隔离级别为可重复读(repeatable read),因为这样符合标准要求,所以它们可以声称自己“标准兼容”。

不幸的是,SQL 标准对隔离级别的定义是有缺陷的——模糊,不精确,并不像标准应有的样子独立于实现。有几个数据库实现了可重复读,但它们实际提供的保证存在很大的差异,尽管表面上是标准化的。在研究文献。中已经有了可重复读的正式定义,但大多数的实现并不能满足这个正式定义。最后,IBM DB2 使用“可重复读”来引用可串行化。

上一页
下一页