原文《08 | 事务到底是隔离的还是不隔离的?-极客时间》讲的比较分散,一些关键知识点下面的评论也是五花八门;本文对这一节内容做一个梳理,先将简单的概念如"事务的启动时机"、"视图"、"秒级创建快照"拎出来解释,然后通过文章中的几个例子说明"一致性读"和"当前读";
08 | 事务到底是隔离的还是不隔离的?
事务的启动时机?
- 第一种启动方式:一致性视图是在执行事务过程中的第一个查询语句时创建的;
- 第二种启动方式:一致性视图是在执行start transaction with consistent snapshot时创建的;
注意,begin/start transaction命令并不是一个事务的起点,在执行到它们之后的第一个操作InnoDB表的语句,事务才真正启动;如果你想要马上启动一个事务,可以使用start transaction with consistent snapshot这个命令;
还需要注意的是,默认 autocommit=1下,单独的一条update语句本身就是一个事务,语句完成的时候会自动提交;
MySQL里“视图”的概念
在MySQL里,有两个“视图”的概念:
(1)一个是view;它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果;创建视图的语法是create view…,而它的查询方法与表一样;
(2)另一个是consistent readview,它是InnoDB在实现MVCC时用到的一致性读视图,用于支持RC(Read Committed,读提交)和RR(Repeatable Read,可重复读)隔离级别的实现;它没有物理结构,作用是事务执行期间用来定义“我能看到什么数据”;
"快照"在MVCC里是怎么实现的?
InnoDB 利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。
在可重复读隔离级别下,事务在启动的时候就“拍了个快照”,这个快照是基于整库的!
但是这里"快照"并不是说把此刻的整库数据拷贝一份,而是类似git,对于每一行数据,仅记录其在某一次事务中的增量更新,因此大部分的数据都是没有更新的,从而不会像"全量物理拷贝"那样占用巨大的内存,具体原理如下:
(1)InnoDB下,每个事务都有一个唯一的事务ID,即transaction id,它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的;
(2)每行数据也是有多个版本的;每次事务更新数据的时候,都会生成这行数据的一个新的数据版本,将这个数据版本对应的事务ID记为row trx_id;
(3)旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它;
也就是说,对于数据表中的一行记录,在数据库的不断更新下,可能存在多个数据版本 (row),而每个数据版本有自己的 row trx_id;如下图所示,就是一条记录被多个事务连续更新后的过程;
由图可知:语句所在的事务ID与语句更新结果的数据版本的row trx_id一一对应;实际上,历史版本数据的值并不会物理存在,而是在每次需要的时候根据当前版本和 undo log 计算出来的;
既然有了row trx_id,而事务ID又是有序递增的,再思考下“秒级创建快照”的能力是如何工作的?
按照可重复读的定义,一个事务启动的时候,它能够看到所有已经提交的事务结果;但是之后,在这个事务执行期间,其他事务的更新对它都不可见;
因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”;当然,如果“上一个版本”也不可见,那就得继续往前找;这里要注意,如果是这个事务自己更新的数据,它自己还是要认的;
作者在这里举了一个不容易理解且容易引起误解的例子,来说明一致性视图(read-view),原文描述如下并配了一张图(我在图里作了补充说明):
在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务ID。“活跃”指的就是,启动了但还没提交。数组里面事务ID的最小值记为低水位,当前系统里面已经创建过的事务ID的最大值加 1 记为高水位。这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。
这个图里面,不同颜色的区域的描述不准确,这里先说明下几个重要的点:
1. 先使用begin命令的事务,其trx_id不一定比后使用begin命令的事务的trx_id小;
这里用的是"先使用begin命令的事务"这样的描述,而不是"先开启的事务",因为上面讲过"begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动";
2. 除非使用start transaction with consistent snapshot命令,一致性视图是在执行第一个快照读语句时创建的;
3. 当前事务真正生成trx_id的时机在于"当前读",也就是非select的一般更新DML语句,如update、insert、delete、select...for update;
实际上,如果事务内先执行的第一条语句是读语句,会生成一条临时的trx_id,这个"临时的trx_id"的值并不是有当前数据库最大的trx_id递增生成,而是按照一定的计算规则;但是当后面遇到第一条更新语句时,这个trx_id的值会被更新;可参考这篇文章《mysql中事务id,有啥用?》;
4. 视图数组生成的时机在于事务启动后的第一条读语句,而非事务启动时就立即创建,也不是分配trx_id时创建;
基于以上原因,所以视图数组中可能存在这2种事务:
(1)trx_id小于当前事务A,但未提交的事务A_pre:如事务A_pre先执行了一条非select的DML语句,生成trx_id=10,然后执行多条SQL语句,事务A_pre提交之前,当前事务A也开启执行了一条非select的DML语句,生成trx_id=11,此时A又执行了一条查询语句,生成视图数组;
(2)trx_id大于当前事务A,但未提交的事务A_behind:如事务A先执行了一条非select的DML语句,生成trx_id=11,在A执行到第一条读语句之前,事务A_behind也开启执行了一条非select的DML语句,生成trx_id=12,此时A又执行了一条查询语句,生成视图数组;
注意:当使用start transaction with consistent snapshot这个命令开启事务时,会立即生成一致性视图,这种情况下,当前事务ID若已生成,则视图数组的最后一个元素就是当前事务ID,高水位的值就是当前事务ID+1;文中举的例子就是用此方式开启事务;
个人理解这个视图数组是用来判断当前事务对哪些数据版本是可见的,对哪些是不可见的,作者也在文中指出"判断规则是从代码逻辑直接转译过来的",因此步骤应该如下:
首先,确定视图数组;在当前事务执行第一条读语句时,生成一致性视图,创建视图数组,数组中的trx_id元素为当前正在"活跃"的所有事务ID,即启动且生成trx_id但还没提交的事务;
其次,确定文中的"低水位"和"高水位";"低水位"的值为系统中未提交事务trx_id的最小值,也就是视图数组的最小值;"高水位"的值是当前系统内的已经创建过的事务trx_id的最大值加1;
最后,根据当前事务的trx_id,找到其可见的数据版本:
- 如果当前数据版本的trx_id大于或等于"高水位",则说明这个事务是当前事务创建一致性视图之后才启动的,因此不可见;
- 如果当前数据版本的trx_id小于"低水位",则说明这个事务是当前事务创建一致性视图之前就完成提交的,因此可见;
- 如果当前数据版本的trx_id大于"高水位"而小于"高水位",则判断其是否在视图数组的元素中(数组不一定连续,因为可能有部分trx_id更大但执行更快的事务已经完成提交),如果在数组内则说明未提交,因此不可见;如果不在数组内则说明已经提交,因此可见;
一致性读
举个例子:
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);
先说结论:事务B查到的k的值是3,而事务A查到的k的值是1;
接下来,根据上面的视图数组和高低水位,来分析下事务A的语句返回的结果为什么是k=1;
这里,我们不妨做如下假设:
(1)事务A开始前,系统里面只有一个活跃事务ID是99;
(2)事务A、B、C的版本号分别是100、101、102,且当前系统里只有这四个事务;
(3)三个事务开始前,(1,1) 这一行数据的row trx_id是90;
注意一点:事务B虽然比C先开启一致性视图,但是B比C更晚执行"当前读",是否生成的trx_id应该比C更大呢?感觉这里作者应该是为了简化说明,尽量不引入后面的知识;
这样,事务A的视图数组就是[99,100],事务B的视图数组是[99,100,101],事务C的视图数组是[99,100,101,102];为了简化分析,先把其他干扰语句去掉,只画出跟事务A查询逻辑有关的操作:
从图中可以看到:
(1)第一个有效更新是事务C,把数据从(1,1)改成了(1,2);这时候,这个数据的最新版本的row trx_id是102,而90这个版本已经成为了历史版本;
(2)第二个有效更新是事务B,把数据从(1,2)改成了(1,3);这时候,这个数据的最新版本(即row trx_id)是101,而102又成为了历史版本;
(3)在事务A查询的时候,其实事务B还没有提交,但是它生成的(1,3)这个版本已经变成当前版本了;但这个版本对事务A必须是不可见的,否则就变成脏读了;
现在事务A要来读数据了,它的视图数组是[99,100];事务A查询语句的读数据流程是这样的:
1. 找到(1,3)的时候,判断出row trx_id=101,比高水位大,不可见;
2. 接着,找到上一个历史版本,一看row trx_id=102,比高水位大,不可见;
3. 再往前找,终于找到了(1,1),它的row trx_id=90,比低水位小,可见;
这样执行下来,虽然期间这一行数据被修改过,但是事务A不论在什么时候查询,看到这行数据的结果都是一致的,所以我们称之为一致性读;
总结一下,对于一致性读中判断数据的可见性,规则可以简化为:一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:若版本未提交,不可见;若版本已提交,但是是在视图创建后提交的,不可见;版本已提交,而且是在视图创建前提交的,可见;
当前读
接下来看看示例中的更新逻辑,可能有疑问:事务B的update语句,如果按照一致性读,好像结果不对?因为图中事务B的视图数组是先生成的,之后事务C才提交,不是应该看不见(1,2)吗,怎么能算出(1,3)来?
先说结论:如果事务B在更新之前查询一次数据,这个查询返回的k的值确实是1;但是,当它要去更新数据的时候,就不能再在历史版本上更新了,否则事务C的更新就丢失了;因此,事务B此时的set k=k+1是在(1,2)的基础上进行的操作;
所以,这里就用到了这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(currentread);因此,在更新的时候,当前读拿到的数据是(1,2),更新后生成了新版本的数据(1,3),这个新版本的row trx_id是101;所以,在执行事务B查询语句的时候,一看自己的版本号是101,最新数据的版本号也是101,是自己的更新,可以直接使用,所以更新后再查询得到的k的值是3;
这里提到了一个概念,叫作当前读;其实,除了update 之类的写操作语句外,select语句如果加锁,也是当前读;例如,如果在事务A的查询语句后加上 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’执行更新语句时加上的,要等到事务C’结束时才释放;而事务B是当前读,必须要读最新版本,而且必须加写锁,因此就被阻塞了,必须等到事务C’释放这个锁,事务B才能继续它的当前读;
事务的可重复读的能力是怎么实现的?
- 可重复读的核心就是一致性读(consistent read);
- 而事务更新数据的时候,只能用当前读;
- 如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待;
隔离级别为"读提交"时的逻辑
而读提交的逻辑和可重复读的逻辑类似:
1. 在可重复读隔离级别下,只需要在事务开始执行第一条读语句的时候创建一致性视图(或使用start transaction with consistent snapshot直接创建一致性视图),之后事务里的其他查询都共用这个一致性视图;有一个特例,当前事务能读到自己最新的更新,哪怕没提交;
2. 在读提交隔离级别下,每一个slelct语句执行前都会重新算出一个新的视图(无需使用start transaction with consistent snapshot命令显示的创建一致性视图);因此可能出现同一个事物中连续的两个select读到不一样的情况,因为可能在此之间有其他事务完成了提交;
3. 不管哪个事务隔离级别,写操作都是当前读,不会去read view;
那么,我们再看一下,在读提交隔离级别下,事务A和事务B的查询语句查到的k,分别应该是多少呢?下面是读提交隔离级别下的状态图,可以看到这两个查询语句的创建视图数组的时机发生了变化,就是图中的read view框;
这时,事务A的查询语句的视图数组是在执行这个语句的时候创建的,时序上(1,2)、(1,3)的生成时间都在创建这个视图数组的时刻之前;但是,在这个时刻:(1,3)还没提交,不可见;(1,2)提交了,可见;
所以,这时候事务A查询语句返回的是k=2;显然地,事务B查询结果k=3;
小结
- InnoDB 的行数据有多个版本,每个数据版本有自己的 row trx_id,每个事务或者语句有自己的一致性视图;
- 普通查询语句是一致性读,一致性读会根据 row trx_id 和一致性视图确定数据版本的可见性;有一个特例,当前事务能读到自己最新的更新,哪怕没提交;
- 对于可重复读,查询只承认在一致性视图创建前就已经提交完成的数据;对于读提交,查询只承认在语句执行前就已经提交完成的数据;
- 不管哪个事务隔离级别,写操作都是当前读;当前读,总是读取已经提交完成的最新版本;
思考题
条件:用下面的表结构和初始化语句作为试验环境,事务隔离级别是可重复读;
问题:现在,我要把所有“字段 c 和 id 值相等的行”的 c 值清零,但是却发现了一个“诡异”的、改不掉的情况;请你构造出这种情况,并说明其原理。
解答:
因为图中更新语句后面的影响行数为0,说明更新没有成功,没有任何数据被当前事务修改;当前事务A在第一条select后生成了一致性视图read view,而当前事务又没有更新数据版本,因此更新语句后面的select是一致性读,读的是read view;
那么,为什么更新语句未影响任何一行数据呢?因为更新语句是"当前读",说明此时表t中最新的数据版本中没有满足 id =c 条件的,因此一定是在当前事务A创建一致性视图read view后,在执行更新之前,有其他的事务B更新了表t中的数据,如 update t set c=c+1,导致最新的数据版本都不满足 "id = c"的条件;
需要注意的是,事务B对数据的更新只需要在事务A的更新语句执行之前完成,等事务B完成commot后,事务A才能拿到数据的写锁,然后执行更新;并且事务B的begin命令可能在事务A之前,也可能在事务A之后;以下是几种可能的情况示例:
或
下篇文章:《MySQL实战45讲》——学习笔记09 “普通索引和唯一索引、change buffer 和 redo log“