目录
第七讲:事件到底是隔离的还是不隔离的?
媒介:
我在第 3 篇文章和你讲事件隔离级别的时间提到过,假如是可重复读隔离级别,事件 T 启动的时间会创建一个视图 read-view,之后事件 T 实行期间,纵然有其他事件修改了数据,事件 T 看到的仍然跟在启动时看到的一样。也就是说,一个在可重复读隔离级别下实行的事件,似乎与世无争,不受外界影响。
但是,我在上一篇文章中,和你分享行锁的时间又提到,一个事件要更新一行,假如刚好有别的一个事件拥有这一行的行锁,它又不能这么超然了,会被锁住,进入等待状态。问题是,既然进入了等待状态,那么比及这个事件自己获取到行锁要更新数据的时间,它读到的值又是什么呢?
示例:
我给你举一个例子吧。下面是一个只有两行的表的初始化语句。- mysql> CREATE TABLE `t` (
- `id` int(11) NOT NULL,
- `k` int(11) DEFAULT NULL,
- PRIMARY KEY (`id`)
- ) ENGINE=InnoDB;
- insert into t(id, k) values(1,1),(2,2);
复制代码
这里,我们需要留意的是事件的启动时机。
begin/start transaction 命令并不是一个事件的起点,在实行到它们之后的第一个操作 InnoDB 表的语句,事件才真正启动。假如你想要马上启动一个事件,可以使用 start transaction with consistent snapshot 这个命令。
- 第一种启动方式,一致性视图是在实行第一个快照读语句时创建的;
- 第二种启动方式,一致性视图是在实行 start transaction with consistent snapshot 时创建的。
还需要留意的是,在整个专栏里面,我们的例子中假如没有特别说明,都是默认 autocommit=1.
(批注:autocommit为开启状态时,纵然没有手动start transaction开启事件,mysql默认也会将用户的操作当做事件即时提交。)
在这个例子中,事件 C 没有显式地使用 begin/commit,表示这个 update 语句自己就是一个事件,语句完成的时间会自动提交。事件 B 在更新了行之后查询 ; 事件 A 在一个只读事件中查询,并且时间顺序上是在事件 B 的查询之后。
结果:
这时,假如我告诉你事件 B 查到的 k 的值是 3,而事件 A 查到的 k 的值是 1,你是不是感觉有点晕呢?
所以,今天这篇文章,我实在就是想和你说明白这个问题,盼望借由把这个迷惑解开的过程,可以或许帮助你对 InnoDB 的事件和锁有更进一步的明白。
视图概念
在 MySQL 里,有两个“视图”的概念:
- 一个是 view。它是一个用查询语句定义的虚拟表,在调用的时间实行查询语句并生成结果。创建视图的语法是 create view … ,而它的查询方法与表一样。
- 另一个是 InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。
(批注:前者是当前读,后者是快照读。一个是在实行第一句查询语句时才会创建视图,一个是事件启动时就创建视图)
它没有物理结构,作用是事件实行期间用来定义“我能看到什么数据”。
深入相识MVCC
在第 3 篇文章《事件隔离:为什么你改了我还看不见?》中,我跟你表明过一遍 MVCC 的实现逻辑。今天为了说明查询和更新的区别,我换一个方式来说明,把 read view 拆开。你可以团结这两篇文章的说明来更深一步地明白 MVCC。
“快照”在 MVCC 里是怎么工作的?
在可重复读隔离级别下,事件在启动的时间就“拍了个快照”。留意,这个快照是基于整库的。
这时,你会说这看上去不太现实啊。假如一个库有 100G,那么我启动一个事件,MySQL 就要拷贝 100G 的数据出来,这个过程得多慢啊。可是,我平常的事件实行起来很快啊。
实际上,我们并不需要拷贝出这 100G 的数据。我们先来看看这个快照是怎么实现的。InnoDB 里面每个事件有一个唯一的事件 ID,叫作 transaction id。它是在事件开始的时间向 InnoDB 的事件体系申请的,是按申请顺序严格递增的。而每行数据也都是有多个版本的。
每次事件更新数据的时间,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事件 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,可以或许有信息可以直接拿到它。
也就是说,数据表中的一行记录,实在可能有多个版本 (row),每个版本有自己的 row trx_id。
如下图所示,就是一个记录被多个事件连续更新后的状态。
图中虚线框里是同一行数据的 4 个版本,当前最新版本是 V4,k 的值是 22,它是被 transaction id 为 25 的事件更新的,因此它的 row trx_id 也是 25。
回顾
你可能会问,前面的文章不是说,语句更新会生成 undo log(回滚日志)吗?那么,undo log 在哪呢?实际上,图 2 中的三个虚线箭头,就是 undo log;而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时间根据当前版本和 undo log 计算出来的。比如,需要 V2 的时间,就是通过 V4 依次实行 U3、U2 算出来。明白了多版本和 row trx_id 的概念后,我们再来想一下,InnoDB 是怎么定义谁人“100G”的快照的。
按照可重复读的定义,一个事件启动的时间,可以或许看到所有已经提交的事件结果。但是之后,这个事件实行期间,其他事件的更新对它不可见。
因此,一个事件只需要在启动的时间声明说,“以我启动的时刻为准,假如一个数据版本是在我启动之前生成的,就认;假如是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。
固然,假如“上一个版本”也不可见,那就得继续往前找。
还有,假如是这个事件自己更新的数据,它自己还是要认的。
实现方式
在实现上, InnoDB 为每个事件构造了一个数组,用来生存这个事件启动瞬间,当前正在“活跃”的所有事件 ID。“活跃”指的就是,启动了但还没提交。数组里面事件 ID 的最小值记为低水位,当前体系里面已经创建过的事件 ID 的最大值加 1 记为高水位。这个视图数组和高水位,就组成了当前事件的一致性视图(read-view)。
而数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的。
这个视图数组把所有的 row trx_id 分成了几种差异的情况。
这样,对于当前事件的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:
- 假如落在绿色部分,表示这个版本是已提交的事件大概是当前事件自己生成的,这个数据是可见的;
- 假如落在赤色部分,表示这个版本是由将来启动的事件生成的,是肯定不可见的;
- 假如落在黄色部分,那就包括两种情况
- a. 若 row trx_id 在数组中,表示这个版本是由还没提交的事件生成的,不可见;
- b. 若 row trx_id 不在数组中,表示这个版本是已经提交了的事件生成的,可见。
比如,对于图 2 中的数据来说,假如有一个事件,它的低水位是 18,那么当它访问这一行数据时,就会从 V4 通过 U3 计算出 V3,所以在它看来,这一行的值是 11。
你看,有了这个声明后,体系里面随后发生的更新,是不是就跟这个事件看到的内容无关了呢?因为之后的更新,生成的版本一定属于上面的 2 大概 3(a) 的情况,而对它来说,这些新的数据版本是不存在的,所以这个事件的快照,就是“静态”的了。
所以你现在知道了,InnoDB 利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的本领。
接下来,我们继续看一下图 1 中的三个事件,分析下事件 A 的语句返回的结果,为什么是 k=1。
这里,我们不妨做如下假设:
- 事件 A 开始前,体系里面只有一个活跃事件 ID 是 99;
- 事件 A、B、C 的版本号分别是 100、101、102,且当前体系里只有这四个事件;
- 三个事件开始前,(1,1)这一行数据的 row trx_id 是 90。
这样,事件 A 的视图数组就是[99,100], 事件 B 的视图数组是[99,100,101], 事件 C 的视图数组是[99,100,101,102]。
为了简化分析,我先把其他干扰语句去掉,只画出跟事件 A 查询逻辑有关的操作:
从图中可以看到,第一个有效更新是事件 C,把数据从 (1,1) 改成了 (1,2)。这时间,这个数据的最新版本的 row trx_id 是 102,而 90 这个版本已经成为了历史版本。
第二个有效更新是事件 B,把数据从 (1,2) 改成了 (1,3)。这时间,这个数据的最新版本(即 row trx_id)是 101,而 102 又成为了历史版本。
你可能留意到了,在事件 A 查询的时间,实在事件 B 还没有提交,但是它生成的 (1,3) 这个版本已经酿成当前版本了。但这个版本对事件 A 必须是不可见的,否则就酿成脏读了。
好,现在事件 A 要来读数据了,它的视图数组是[99,100]。固然了,读数据都是从当前版本读起的。所以,事件 A 查询语句的读数据流程是这样的:
- 找到 (1,3) 的时间,判断出 row trx_id=101,比高水位大,处于赤色地区,不可见;
- 接着,找到上一个历史版本,一看 row trx_id=102,比高水位大,处于赤色地区,不可见;
- 再往前找,终于找到了(1,1),它的 row trx_id=90,比低水位小,处于绿色地区,可见。
这样实行下来,虽然期间这一行数据被修改过,但是事件 A 不论在什么时间查询,看到这行数据的结果都是一致的,所以我们称之为一致性读。
这个判断规则是从代码逻辑直接转译过来的,但是正如你所见,用于人肉分析可见性很麻烦。
所以,我来给你翻译一下。一个数据版本,对于一个事件视图来说,除了自己的更新总是可见以外,有三种情况:
- 版本未提交,不可见;
- 版本已提交,但是是在视图创建后提交的,不可见;
- 版本已提交,而且是在视图创建前提交的,可见。
现在,我们用这个规则来判断图 4 中的查询结果,事件 A 的查询语句的视图数组是在事件 A 启动的时间生成的,这时间:
- (1,3) 还没提交,属于情况 1,不可见;
- (1,2) 虽然提交了,但是是在视图数组创建之后提交的,属于情况 2,不可见;
- (1,1) 是在视图数组创建之前提交的,可见。
你看,去掉数字对比后,只用时间先后顺序来判断,分析起来是不是轻松多了。所以,后面我们就都用这个规则来分析。
更新逻辑
细心的同学可能有疑问了:事件 B 的 update 语句,假如按照一致性读,似乎结果不对哦?
你看图中,事件 B 的视图数组是老师成的,之后事件 C 才提交,不是应该看不见 (1,2) 吗,怎么能算出 (1,3) 来?
是的,假如事件 B 在更新之前查询一次数据,这个查询返回的 k 的值确实是 1。但是,当它要去更新数据的时间,就不能再在历史版本上更新了,否则事件 C 的更新就丢失了。因此,事件 B 此时的 set k=k+1 是在(1,2)的基础上进行的操作。所以,这里就用到了这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。
因此,在更新的时间,当前读拿到的数据是 (1,2),更新后生成了新版本的数据 (1,3),这个新版本的 row trx_id 是 101。
所以,在实行事件 B 查询语句的时间,一看自己的版本号是 101,最新数据的版本号也是 101,是自己的更新,可以直接使用,所以查询得到的 k 的值是 3。
这里我们提到了一个概念,叫作当前读。实在,除了 update 语句外,select 语句假如加锁,也是当前读。
所以,假如把事件 A 的查询语句 select * from t where id=1 修改一下,加上 lock in share mode 或 for update,也都可以读到版本号是 101 的数据,返回的 k 的值是 3。下面这两个 select 语句,就是分别加了读锁(S 锁,共享锁)和写锁(X 锁,排他锁)。- mysql> select k from t where id=1 lock in share mode;
- mysql> select k from t where id=1 for update;
复制代码 再往前一步,假设事件 C 不是马上提交的,而是酿成了下面的事件 C’,会怎么样呢?
事件 C’的差异是,更新后并没有马上提交,在它提交前,事件 B 的更新语句先发起了。前面说过了,虽然事件 C’还没提交,但是 (1,2) 这个版本也已经生成了,并且是当前的最新版本。那么,事件 B 的更新语句会怎么处置惩罚呢?这时间,我们在上一篇文章中提到的“两阶段锁协议”就要上场了。事件 C’没提交,也就是说 (1,2) 这个版本上的写锁还没释放。而事件 B 是当前读,必须要读最新版本,而且必须加锁,因此就被锁住了,必须比及事件 C’释放这个锁,才气继续它的当前读。
到这里,我们把一致性读、当前读和行锁就串起来了。
现在,我们再回到文章开头的问题:事件的可重复读的本领是怎么实现的?可重复读的核心就是一致性读(consistent read);而事件更新数据的时间,只能用当前读。假如当前的记录的行锁被其他事件占用的话,就需要进入锁等待。而读提交的逻辑和可重复读的逻辑类似,它们最重要的区别是:
- 在可重复读隔离级别下,只需要在事件开始的时间创建一致性视图,之后事件里的其他查询都共用这个一致性视图;
- 在读提交隔离级别下,每一个语句实行前都会重新算出一个新的视图。
那么,我们再看一下,在读提交隔离级别下,事件 A 和事件 B 的查询语句查到的 k,分别应该是多少呢?
这里需要说明一下,“start transaction with consistent snapshot; ”的意思是从这个语句开始,创建一个连续整个事件的一致性快照。所以,在读提交隔离级别下,这个用法就没意义了,等效于平常的 start transaction。
下面是读提交时的状态图,可以看到这两个查询语句的创建视图数组的时机发生了变化,就是图中的 read view 框。(留意:这里,我们用的还是事件 C 的逻辑直接提交,而不是事件 C’)
这时,事件 A 的查询语句的视图数组是在实行这个语句的时间创建的,时序上 (1,2)、(1,3) 的生成时间都在创建这个视图数组的时刻之前。但是,在这个时刻:
(1,3) 还没提交,属于情况 1,不可见;
(1,2) 提交了,属于情况 3,可见。
所以,这时间事件 A 查询语句返回的是 k=2。显然地,事件 B 查询结果 k=3。
小结
InnoDB 的行数据有多个版本,每个数据版本有自己的 row trx_id,每个事件大概语句有自己的一致性视图。平常查询语句是一致性读,一致性读会根据 row trx_id 和一致性视图确定命据版本的可见性。
- 对于可重复读,查询只承认在事件启动前就已经提交完成的数据;
- 对于读提交,查询只承认在语句启动前就已经提交完成的数据;
- 而当前读,总是读取已经提交完成的最新版本。
你也可以想一下,为什么表结构不支持“可重复读”?这是因为表结构没有对应的行数据,也没有 row trx_id,因此只能遵照当前读的逻辑。
固然,MySQL 8.0 已经可以把表结构放在 InnoDB 字典里了,也许以后会支持表结构的可重复读。
深入:
NO.1
启动视图后,后面的事件变动是看不到的。 但是变动后的版本是可以或许看到的。 比如一行记录有1->2->3->4->5,5个版本。 但是我们这个事件视图A是在这个3瞬间启动的。 那视图A是怎么拿到3这个值呢?记住它不是直接在视图中生存的3这个值,而是通过视图版本末了的一条数据,通过undo log 然后一个个从后往前找,先找到5 ,然后这个row tra id 不属于这个视图中,丢弃。 继续找4,不属于。通过4 找到3 。row tra id 属于这个事件视图中,则该视图中认为3是这行的值。
思索:
又到思索题时间了。我用下面的表结构和初始化语句作为试验情况,事件隔离级别是可重复读。现在,我要把所有“字段 c 和 id 值相等的行”的 c 值清零,但是却发现了一个“诡异”的、改不掉的情况。请你构造出这种情况,并说明其原理。- mysql> CREATE TABLE `t` (
- `id` int(11) NOT NULL,
- `c` int(11) DEFAULT NULL,
- PRIMARY KEY (`id`)
- ) ENGINE=InnoDB;
- insert into t(id, c) values(1,1),(2,2),(3,3),(4,4);
复制代码
复现出来以后,请你再思索一下,在实际的业务开发中有没有可能碰到这种情况?你的应用代码会不会掉进这个“坑”里,你又是怎么解决的呢?
答案:
这样,session A 看到的就是我截图的结果了。实在,还有别的一种场景,同学们在留言区都还没有提到。
这个操作序列跑出来,session A 看的内容也是可以或许复现我截图的结果的。这个 session B’启动的事件比 A 要早,实在是上期我们描述事件版本的可见性规则时留的彩蛋,因为规则里还有一个“活跃事件的判断”,我是准备留到这里再补充的。
当我试图在这里讲述完备规则的时间,发现第 8 篇文章《事件到底是隔离的还是不隔离的?》中的表明引入了太多的概念,以致于分析起来非常复杂
用新的方式来分析 session B’的更新为什么对 session A 不可见就是:在 session A 视图数组创建的瞬间,session B’是活跃的,属于“版本未提交,不可见”这种情况。
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。 |