百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术资源 > 正文

隔离级别的追溯与究明,带你读懂隔离级别(下)

lipiwang 2025-05-27 15:45 10 浏览 0 评论

本篇文章选自事务前沿研究系列文章。


在上篇文章中,我们分析了 ANSI SQL-92 和「A Critique of ANSI SQL Isolation Levels」对隔离级别做出的定义,并且指出了在现今的认知中,其中的一些缺陷。本篇将继续讨论隔离级别的问题,讲述实现无关的隔离级别定义和 TiDB 的表现和隔离级别。


Generalized Isolation Level Definitions

介绍


上文所讲的「A Critique of ANSI SQL Isolation Levels」这篇文章在定义隔离级别的时候,对事务的过程也提出了诸多的要求,然而「Generalized Isolation Level Definitions」仅对成功提交的事务做了约束,即所有异常现象都是由成功提交的事务产生的。在例 1-a 中,因为 T1 没有成功提交,所以并没有出现异常,而例 1-b 中 T1 读到了 abort 事务 T2 的写入内容并且提交成功了,产生了异常现象(G1a - Aborted Read)。




例 1-a - 提交是出现异常的必要条件 例 1-b - 提交是出现异常的必要条件


「Generalized Isolation Level Definitions」提出了与实现无关的隔离级别定义,并且更清晰的解释了 predicate 和 item 现象所带来的异常区别,提出了对标 ANSI SQL-92 的隔离级别。

依赖图


Adya 首先引入了三类依赖,可以简单的概括为写读(WR)读写(RW)写写(WW)。含有读的依赖按照读操作的 item 和 predicate 查询类别被细分为两种类型,item 指的是在一个 key 之上产生的依赖;而 predicate 则是指改变了一个 predicate 结果集,包括改变其中某个 item 的值和改变某个 item 在 predicate 下的命中状态。

两个事务间存在依赖则一定程度上代表了两个事务在现实时间中的先后关系,如果两个依赖中分别出现了 T1 先于 T2 和 T2 先于 T1 的现象,那么就证明出现了事务在现实事件中交叉出现的现象,破坏了 Serializable,这是本篇论文的核心观点。

Read Dependencies (WR)


WR 依赖指的是为 T2 读到了 T1 写入的值


例 2 是针对单个 key 的 WR 依赖,T2 读到了 T1 写入的值,称为 Directly item-read-depends。



例 2 - Directly item-read-depends


例 3 是 predicate 条件下的 WR 依赖,例 3-a 是将一个 key 从符合 predicate 条件改为了不符合条件,而例 3-b 是将一个 key 从不符合 predicate 条件改为了符合条件。




例 3-a - Directly predicate-read-depends 例 3-b - Directly predicate-read-depends


Anti-Dependencies(RW)


WR 依赖指的是为 T2 修改了 T1 读到的值


例 4 是针对单个 key 的 RW 依赖,T1 在 T2 读到的 key 之上写入了新值,称为 Directly item-anti-depends。



例 4 - Directly item-anti-depends


例 5 是 predicate 条件下的 WR 依赖,例 5-a 是将一个 key 从符合 predicate 条件改为了不符合条件,而例 5-b 是将一个 key 从不符合 predicate 条件改为了符合条件。




例 5-a - Directly predicate-anti-depends 例 5-b - Directly predicate-anti-depends

Write Dependencies(WW)


WW 依赖指的是两个事务写了同一个 key,例 6 中 T1 写入了 x 的第一个值,T2 写入了 x 的第二个值。



例 6 - Directly Write-Depends

DSG


DSG (Direct Serialization Graph) 可以被称为有向序列化图,是将对一系列事务进行以来分析后,将上述的三种依赖作为 edge,将事务作为 node 绘制出来的图。图 1 展示了从事务历史分析得到 DSG。如果 DSG 是一个有向无环图(如图 1 所示),那么这些事务间的依赖关系所决定的事务先后关系不会出现矛盾,反之则代表可能有异常,这篇文章根据出现异常时组成环的 edge 的依赖类型,定义了隔离级别



图 1 - 从事务历史分析 DSG


异常现象与隔离级别


为了不和「A Critique of ANSI SQL Isolation Levels」产生符号上的冲突,这篇文章使用 G 表示异常现象,使用 PL 表示隔离级别。

PL-1 & G0


G0 (Write Cycles) 和类似于脏写定义,但要求 P0 (Dirty Write) 现象实际产生异常,如果仅仅是两个事务写同一个 key 并且并行了,他们还是可以被视为 Serializable,只有当两个事务互相出现依赖的时候才属于 G0 现象。例 7-a 属于 P0 现象,但只看这个现象本身,是符合 Serializable 的,而例 7-b 同时发生了 P0 和 G0。




例 7-a - P0 (Dirty Write) 与 G0 对比 - P0 例 7-b - P0 (Dirty Write) 与 G0 对比 - G0


如果不会出现 G0 现象,则达到了 PL-1 的隔离级别。

PL-2 & G1


G1 现象有三条,其中 G1a 和 G1b 与依赖图无关,G1c 是依赖图上的异常。


G1a (Aborted Reads) 指读到了中断事务的内容,例 8 是 G1a 现象的两种情况,不管是通过 item 类型还是 predicate 类型的查询读到了中断事务的内容,都属于 G1a 现象。例 8-a 中,T1 将 x 写为 2,但是这个事务最后产生了 abort,而 T2 读到了 T1 写入的结果,产生了 G1a 现象;在例 8-b 中 T1 将 x 从 1 改写为 2,此时 sum 的值也会因此从 10 变为 11,但是因为 T1 最后产生了 abort,所以 T2 读取到 sum 为 11 的值也属于 G1a 现象。




例 8-a - G1a 现象 例 8-b - G1a 现象


G1b (Intermediate Reads) 指读到了事务的中间内容,例 9 是 G1b 的两种情况,item 类型和 predicate 类型的读取都属于 G1b 现象。在例 9-a 中,T1 将 x 从 1 修改为 2,最后修改为 3,但是对于其他事务而言,只能观察到 T1 最后修改的值 3,所以 T2 读取到 x=2 的行为属于 G1b 现象;在例 9-b 中,T2 虽然没有直接从 T1 读取到 x=2 的值,但是其读取到的 sum=11 也包括了 x=2 的结果,其结果而言仍然读取到了事务的中间状态,属于 G1b 现象。




例 9-a - G1b 现象 例 9-b - G1b 现象


G1c (Circular Information Flow) 指 WW 依赖和 WR 依赖组成的 DSG 中存在环,图 2 描述了 G1c 现象,这个例子可以理解为,T1 和 T2 同时写了 x,并且 T2 是后写的,所以 T2 应该晚于 T1 提交,同理 T3 应该晚于 T2 提交。而最后 T1 读到了 T3 写入的 z = 4,所以 T3 需要早于 T1 提交,发生了矛盾。



图 2 - G1c 现象


如果不会出现 G0 和 G1 的三个子现象,则达到了 PL-2 的隔离级别。

PL-3 & G2


G2 (Anti-dependency Cycles) 指的是 WW 依赖、WR 依赖和 RW 依赖组成的 DSG 中存在环,图 3 展示了对上篇的 Phantom 现象进行分析,在其中发现 G2 现象的例子。在这个例子中,如果 T1 或者 T2 任意一个事务失败,或者 T1 没有读取到 T2 写入的值,那么实际上就不存在 G2 现象也不会发生异常,但是根据 P3 的定义,Phantom 现象已经发生了。本文认为 G2 比 P3 用一种更加合理的方式来约束 Phantom 问题带来的异常,同时也补充了 ANSI SQL-92 的 Phantom Read 必须要两次 predicate 读才能算作异常的不合理之处。



图 3 - G2 现象


如果不会出现 G0、G1 和 G2 现象,则达到了 PL-3 的隔离级别。

PL-2.99 & G2-item


PL-3 的要求非常严格,而 PL-2 又相当于 Read Committed 的隔离级别,这就需要在 PL-2 和 PL-3 之间为 Repeatable Read 找到位置。上篇提到过 Non-repeatable Read 和 Phantom Read 的区别在于是 item 还是 predicate 类型的读取,理解了这一点之后,G2-item (Item Anti-dependency Cycles) 就呼之欲出了。


G2-item 指的是 WW 依赖、WR 依赖和 item 类型的 RW 依赖组成的 DSG 中存在环。图 4 展示了对 Non-repeatable 现象进行分析,在其中发现 G2-item 现象的例子。



图 4 - G2-item 现象


如果不会出现 G0、G1 和 G2-item 现象,则达到了 PL-2.99 的隔离级别。

小结


表 1 给出了与实现无关的隔离级别定义,图 5 将其与「A Critique of ANSI SQL Isolation Levels」所提出的隔离级别进行了对比,右侧是这篇文章所给出的定义,略低于左侧是因为这一定义要求事务被提交才能够产生异常。



表 1 - Adya 的隔离级别定义



图 5 - 隔离级别定义对比


这篇文章所做出的隔离级别的定义的优点在于:


  • 定义与实现无关;
  • 只约束了成功提交的事务,此前的定义限制了并发控制技术的空间,例如乐观事务 “first-committer-wins” 的策略能够被这一隔离级别更加好的解释;
  • 指出了读到事务中间状态的异常;
  • 对 Phantom 现象提出了更加清晰和准确的定义;
  • 事务间的依赖关系和事务发生的顺序无关,在这一定义下,更容易区分隔离性和线性一致性。


注意到前文所提到的 Snapshot Isolation 并没有出现在这篇文章中,而如果分析 A5B - Write Skew 现象的话,会发现它其实是属于 G2-item 现象的,这就导致很多 SI 的隔离级别只能被划分到 PL-2 的隔离级别上。这是因为这篇文章只对成功提交事务的状态做出了规定,而在 Adya 的博士论文「Weak Consistency: A Generalized Theory and Optimistic Implementations for Distributed Transactions」中对事务的过程状态也作出了约束,基于此提出了对事务中间状态的补充,其中也包括 PL-SI 的隔离级别,本文关于此不再深入展开。

TiDB 的隔离级别


在这一节,我们将研究 TiDB 的行为,TiDB 的悲观事务模型和 MySQL 在行为上十分相似,其分析可以类推到 MySQL 之上。

乐观锁与悲观锁


乐观锁和悲观锁是两种加锁技术,对应了乐观事务模型和悲观事务模型,乐观锁会在事务提交时检查事务能否成功提交,“first-committer-wins” 的策略会让后提交的冲突事务失败,TiDB 会返回 write conflict 错误。因为一个事务只要有一行记录产生了冲突,整个事务都需要被回滚,所以乐观锁在高冲突的情况下会大幅度降低性能。


悲观锁则是在事务中的每个操作执行时去检查是否会产生冲突,如果会产生冲突,则会重复尝试加锁行为,直到造成冲突的事务中断或提交。就算在无冲突的情况下,悲观锁也会增加事务执行过程中每个操作的延迟,这一点增加了事务执行过程中的开销,而悲观锁则确保了事务在提交时不会因为 write conflict 而失败,增加了事务提交的成功率,避免了清理失败事务的额外开销。

快照读与当前读


快照读和当前读的概念在 MySQL 和 TiDB 中都存在。快照读会遵循快照隔离级别的字面定义,从事务的快照版本读取数据,一个例外情况是在快照读下会优先读取到自身事务修改的数据(local read)。当前读能够读取到最新的数据,实现方式为获取一个最新的时间戳,将此作为当前读读取的快照版本。



Insert/update/delete/select for update 会使用当前读去读取数据,使用当前读也经常被称为“隔离级别降级为 Read Committed”。这两种读取方式的混合使用可能产生非常难以理解的现象。
例 10 给出了在混合使用情况下,当前读影响快照读的例子,按照快照读和当前读的行为定义,快照读是不能看到事务开始后新插入的数据的,而当前读可以看到,但是当当前读对这行数据进行修改之后,这行数据就变为了“自身事务修改的数据”,于是快照读优先使用了 local read。



例 10 - 混合使用快照读与当前读

读时加锁


在悲观事务下,point get 和 batch point get 的执行器在使用当前读时,TiDB 有特殊的读时加锁策略,执行流程为


  • 读取数据并加锁
  • 将数据返回给客户端


相比之下,其他执行器在当前读下的加锁流程为


  • 读取数据
  • 给读取到的数据加锁
  • 将数据返回给客户端


如例 11 所示,他们的区别在于,读时加锁能够锁上不存在的数据索引(point get 和 batch point get 一定存在唯一索引),即使没有读到数据,也不会让这个索引被其他事务所写入。回顾一下 P2 - Fuzzy Read,这一行为正好和 P2 的读锁要求一致,因此,悲观事务下的当前读配合读时加锁的策略能够防止 Fuzzy Read 异常的发生。


Bash
create table t(id int primary key);
begin pessimistic;
select * from t where id > 1 for update; -- 0 rows returns, will not lock any key
select * from t where id = 1 for update; -- 0 rows returns, lock pk(id = 1)

复制代码

例 11 - 混合使用快照读与当前读

RC 与读一致性


RC 有两种理解,一种是 ANSI SQL-92 中的 Read Committed,另一种是 Oracle 中定义的 Read Consistency。一致性读要求读取操作要读到相同的内容,图 6 是读不一致的例子,在一个读请求发生的过程中,发生了另一个事务的写入,对 x 和 y 读到了不同时刻的数据,破坏了 x + y = 100 的约束,出现了一致性问题,读一致性能够防止这种情况的发生。



图 6 - 读不一致

在 Oracle 中,读一致性有两个级别


  • 语句级别
  • 事务级别


语句级别保证了单条语句读一致性,而事务级别保证了整个事务的读一致性。如果使用快照的概念来进行理解的话,语句级别的读一致性代表每条语句会从一个快照进行读取,而事务级别的读一致性代表一个事务中的每一条语句都会从一个快照进行读取,也就是我们常说的快照隔离级别。


TiDB 的 RC 实现了语句级别的读一致性,并且保证每次读取都能够读到最新提交的数据,从而实现了 Read Committed 的隔离级别。

异常分析


快照隔离级别通过多版本的方式来防止了 P0 可能会带来的异常现象,Fuzzy Read 会因为两个情况发生


  • 乐观事务模型下不加读锁的当前读
  • 混合使用快照读和当前读


而 Phantom 异常则会因为不存在给 predicate 的加锁行为而出现。


综上所述,如果只使用快照读的话,TiDB 是不会出现 Phantom 或者 G2 异常的,但是快照读因为会出现 A5B (Write Skew),依旧会违反 G2-item,只有读时加锁能够防止 A5B,需要因场景选择事务模型才能够取得理想的结果。

总结


在下篇中,我们解读了实现无关的隔离级别定义,实现无关隔离级别定义的提出大大简化了对事务隔离性的分析,同时也会作为后续分析的基础内容。最后我们分析了 TiDB 中的一些行为,商业数据库在实现时在遵循标准的同时又有着更复杂的,不管对于数据库的开发者还是用户,理解数据库行为的原因都是十分重要且有益的。

相关推荐

超越JSON.parse:JavaScript中高效反序列化的艺术

当我们需要在网络间传输数据或将数据存储到本地存储时,我们通常会将JavaScript对象转换为字符串,然后在需要时再将其转换回对象,这就是数据序列化与反序列化。虽然JSON.parse()和JSON....

如何给别人网页上增加内容通过Chrome扩展为网页添加快速滚动功能

ContentScripts来看开发网站的介绍,ContentScripts是一类在网页上下文中运行的文件。它们可以使用标准的DOM接口,实现读取浏览器访问的网页的详细信息,比如网页的DOM结构...

JavaScript执行栈和执行上下文(js 执行上下文与执行栈)

在JavaScript中,执行栈和执行上下文是理解代码执行流程和作用域链的关键概念。它们决定了代码如何执行以及变量和函数如何被查找和访问。本文将详细介绍执行上下文的生命周期、执行栈的工作原理以及它们在...

防止浏览器缓存特定JS文件的常用方法

防止浏览器缓存特定JavaScript文件的常用方法:1.添加版本号或时间戳在引用JavaScript文件时,在URL中添加一个版本号或时间戳作为查询参数。这样每次更新文件后修改这个参数值,就能让浏...

前端面试:JavaScript 字符串的常用方法?

JavaScript字符串是一种不可变的数据类型,因此在使用字符串时需要注意以下几个方法:charAt(i):返回指定索引位置的字符。concat(str[,start[,end]]):连接...

Sequelize 在 Node.js 中的详细用法与使用笔记

1.Sequelize简介Sequelize是一个基于Promise的Node.jsORM(Object-RelationalMapping)工具,支持PostgreSQL、My...

前端js加密解密常用的六种方法(js加密解密源代码)

一、MD5加密可以使用md5插件进行加密插件地址:github.com/blueimp/JavaScript-MD5计算给定字符串值的(十六进制编码)MD5哈希值:计算给定字符串值和键的(十六进制编...

javascript深拷贝浅拷贝原理分析(js深拷贝和浅拷贝如何实现)

用js处理数据的时候经常遇到这样一个问题,需要保留原始数据不变情况下,进行一系列数据操作,这时候需要制作一份原始数据的副本数据来进行操作注意的是引用数据类型和基本数据类型在内存中存储方式是不一样的,只...

1、从零开始了解和使用WPS的js宏(JSA)

最近使用了一下wps的宏本地客户端功能进行了数据查询,与vba相比感觉还是比较好用的。(所谓本地客户端就是指单机使用运行的wps程序)VBA因为长时间的发展,胜在功能比较强大,支持一些Active...

JavaScript字符串查找方法总结(js查找子串)

在JavaScript中,查找字符串的常用方法有以下几种,根据不同的需求选择合适的方式:1.indexOf()/lastIndexOf()作用:查找子字符串首次出现的位置(indexOf)或...

JavaScript 合并数组的三种方法(js数组合并的几种方法)

数组作为一种数据结构,表示索引项的有序集合。经常会使用到数组,尤其是将多个数组进行合并,比如将数组[1,2,3]和数组[4,5,6]合并,最终得到数组[1,2,3,4,5,6]。数组的合并分不...

JS短文,如何正确理解Splice() 函数与Slice() 函数

转载说明:原创不易,未经授权,谢绝任何形式的转载Splice()函数与Slice()函数都是JavaScript数组中常用的方法之一。虽然它们的名称很相似,但它们的作用却截然不同。在这篇文章...

JavaScript字符串concat()方法教程

一、简介JavaScript中的字符串是一种基本数据类型,它可以用单引号或双引号括起来。concat()方法用于将一个或多个字符串连接起来,并返回连接后的新字符串。concat()方法不会改变原始字符...

手把手教你常用的59个JS类方法(js几种类型)

前言前端开发有时会处理一部分后台返回的数据,或者根据数据判断做一些处理;这个时候就非常有必要将一些常用的工具类封装起来;本文根据常用的一些工具类封装了59个方法,当然还有很多用的较少前期没有录...

js数组常用方法总结(js数组的使用)

首先说明,本文没技术含量,都是js的知识,只是为以后查阅方便。另外我们开了一个免费的讲解web前端课程,有兴趣的朋友可以去看,详情地址:http://fe.qietu.com/forum.php1、创...

取消回复欢迎 发表评论: