在文章03| 事务隔离:为什么你改了我还看不见里,我们提到了如果是可重复读级别的隔离事务,那么事务启动的时候,就会创建一个事务,并且事务执行期间,即使有其他事务修改了数据,事务看到的任然和启动的时候看到的是一样的
在文章07|行锁功过:怎么减少行锁对性能的影响里,一个事务在可重复读隔离级别下要更新一行,如果恰好有另一个事务拥有这一行的行锁,那么它会被锁住,进入等待状态。问题是,既然进入了等待状态,那么等到这个事务获得行锁,要更新数据的时候,它读到的值是什么?
这是一个很有意思的问题,对吧
假设有:
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);
事务A | 事务B | 事务C |
---|---|---|
start transaction with consistent snapshot; | ||
start transaction with consistent snapshot; | ||
update t set k = k + 1 where id = 1; | ||
update t set k = k + 1 where id = 1; select k from t where id = 1; |
||
select k from t where id = 1 ; commit |
||
commit; |
这里,我们需要注意的是事务的启动时机
begin/start transaction命令并不是一个事务的起点,在执行到它们之后的第一个操作InnoDB表的语句,事务才真正的启动,如果你想要马上启动一个事务,可以使用start transaction with consistent snapshot命令
还有一个要注意的就是,默认autocommit是1
那么回到这个例子,事务C没有显式启动事务,表示这个update本身就是一个事务,语句完成的时候会自动提交。事务B在更新了行之后查询,事务A在只读事务中查询,而且时间顺序是在事务B的查询之后
这时候,如果我告诉你事务B查到的k = 3,事务A查到的k = 1,你是不是会有些莫名其妙?
在MySQL里,有两个视图
的概念
它没有物理结构,作用是事务执行期间用来定义"我能看到什么数据"
在可重复读下,事务在启动的时候就拍了个快照,注意,这个快照是基于整个库的
你可能会反驳:假如一个库有100个g,那么我启动一个事务,MySQL就要拷贝100g的数据出来,这个过程得有多慢,平时的事务执行起来都很快的
事实上,我们并不需要拷贝出这100g的数据,我们先来看看快照的实现原理
在InnoDB里,每个事务都有唯一的事务id,叫做transaction id。它是事务开始的时候向InnoDB事务系统申请的,是按照申请顺序严格递增的
而每行数据也都是有多个版本,每次事务要更新数据的时候,都会生成一个新的数据版本,而且把transaction id赋值给这个数据版本的事务id,记作row trx_id。同时,旧的数据版本要保留,而且在新的数据版本中,能够有信息可以直接拿到它,这一段可以看看可重复读的原理,见文章03| 事务隔离:为什么你改了我还看不见
也就是说,数据表中的一行记录,有多个版本,每个版本都有自己的row trx_id
如图,每一句都会把transaction id赋值给每一行的各个版本,最新的版本是V4,k = 22,它是被transaction id为25的事务更新的,因此它的row trx_id也是25
如果你看过02|日志系统:一条SQL更新语句是如何执行的,你可能会问,语句更新会生成undo log(回滚日志),那么这个回滚日志在哪呢
事实上,图中的三个箭头,U3, U2, U1就是undo log;而V1、V2、V3并不是物理上真实存在的,而是每次需要的时候根据当前版本和undo log计算出来的。比如说需要V2的时候,就会通过V4依次执行U3, U2得来
明白了这点后,我们再来看看InnoDB是如何定义那个"100G"快照的
根据可重复读的含义:一个事务启动的时候,能够看到所有已经提交的所有事务的结果,但是这之后,这个事务执行期间,其他事务的更新对它是不可见的
在实现上,InnoDB为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在活跃的所有事务ID
。活跃是指启动了还未提交
数组里事务ID的最小值
记作低水位
当前系统里已经创建过事务ID的最大值+ 1
记作高水位
这个视图数组和高水位旧组成了当前事务一致性视图,而数据版本可见性规则,就是基于数据的row trx_id和这个一致性视图的对比得到的。
这样,对于当前事务的启动瞬间来说,一个数据版本的row trx_id 有以下几种可能:
也就是说,所有正在执行并且未提交的事务的事务id构成一个数组,数组的最小值称之为低水位
数组的最大值 + 1称之为高水位
只要row trx_id <= 低水位,那么数据就是可见的
只要row trx_id >= 高水位,那么数据就是不可见的
如果数据位于高低水位之间,那么要看row trx_id是否在数组里,如果在,不可见,反之可见
这里MySQL实战45讲并没有讲清楚,为什么会出现这种高低水位之间,但是row trx_id却又不在数组里的情况
至少对于我造成了一点点困惑,这里我单独讲一下,如果不对,请指正
前文已经提到了,row trx_id是由transaction id赋值的,并且,transaction是按申请顺序严格递增的
那么这种在高低水位区间内,但是又不在数组里的情况
的情况,举个例子:有事务A, B, C依次启动。其中A、C启动了但未提交,B已经提交,在这种情况下,又启动了事务D,系统为D生成了数组(其他事务也拥有自己的数组),那么这时候对于事务B,它生成的row trx_id就会出现:在高低水位区间内,但是又不在数组里
那么,还是那个图
假设有一个事务,它的低水位是18,当它访问这一行数据的时候,会通过U3计算出V3,所以在它看来,这一行的值是11
所以,有了这么一个数组,系统随后发生的更新,就和这个事务看到的内容无关了,因为之后的更新,生成的版本一定属于2或者3.1的情况。而对于它来说,这些新的数据是不可见的,所以这个事务的快照就是“静态”的了
所以现在你知道了,InnoDB利用了"所有数据都有多个版本"的这个特性,实现了"秒级创建快照"的能力
我们回过头来看之前那个表格
事务A | 事务B | 事务C |
---|---|---|
start transaction with consistent snapshot; | ||
start transaction with consistent snapshot; | ||
update t set k = k + 1 where id = 1; | ||
update t set k = k + 1 where id = 1; select k from t where id = 1; | ||
select k from t where id = 1 ; commit |
||
commit; |
分析一下,事务A的返回结果,为什么k = 1
我们先做如下假设,在事务A开始之前,系统里面只有一个活跃事务id是99
事务A、B、C的版本号分别是100、101、102,且当前系统里只有这四个事务
三个事务开始前,(1, 1)这一行数据的row_id是90
那么现在
对于事务A的视数组是:[99, 100]
对于事务D的视数组是:[99, 100, 101]
对于事务C的视数组是:[99, 100, 101, 102]
那么
分析开始:首先,第一个有效更新语句是事务C,把数据从(1, 1)改成了(1, 2),这个时候新版本的row trx_id是102
第二个有效更新是事务B,把数据从(1, 2)改成了(1, 3),这个时候最新版本的row trx_id是101
现在,事务A要查数据了,它的视图数组是[99, 100]。因此,只能看到row trx_id<=99或者row trx_id在区间[99, 100]之间,且row trx_id是99或者100
事务A查询语句的流程是这样的:
这样一整套下来,虽然事务A执行期间,这一行数据被修改过,事务A不论什么时候查询,看到这一行数据的结果都是一致的,所以我们称之为一致性读
那么为了方便记忆,可以有如下情况:
版本未提交,不可见
版本已提交,但是是在视图创建后提交,不可见
版本已提交,而且是在视图创建之前提交的,可见
现在我们用这三个结论来判断一下:
事务A的查询语句的视图数组是在事务A启动的时候生成的,这个时候:
那么细心的同学会有疑问,按照这个逻辑,事务B的update语句,结果好像不对吧?
事务B的视图数组是先生成的,之后事务C才提交,不是应该看不见(1, 2)吗,怎么算出来的(1, 3)
答案很浅显,如果事务B在更新之前查询一次数据,那么这个查询结果就是1,但是当它要更新数据的时候,就不能在历史版本上更新了,否则事务C的更新就丢失了。因此事务B的更新是在(1, 2)上进行的操作
这里有一条规则:更新数据都是先读后写,而这个读,只能读当前的值/实时的值,称之为"当前读"
所以,事务B查询语句的时候,一看自己的版本号是101,最新数组也是101,是自己更新的,可见,因此返回的k=3
这里,我们提到了当前读,其实不只是update,如果select加锁,那么也是当前读
所以把事务A的查询语句select * from t where id = 1
修改一下,加上lock in share mode 或for update,也都可以读到版本号是101的数据,返回值是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不是马上提交,而是变成如下的事务D,会怎么样呢?
事务A | 事务B | 事务D |
---|---|---|
start transaction with consistent snapshot; | ||
start transaction with consistent snapshot; | ||
start transaction with consistent snapshot; update t set k = k + 1 where id = 1 |
||
update t set k = k + 1 where id = 1; select k from t where id = 1; |
||
select k from t where id = 1 ; commit |
commit; | |
commit; |
在事务D提交之前,事务B的更新语句先发起,这个时候虽然事务D还未提交,但是(1, 2)这个版本已经生成了,而且是当前的最新版本,那么事务B的更新语句会怎么处理呢?
这个时候,要留意,事务D更新id = 1的行,并且还没提交,事务B也要更新id = 1的行,并且也还没提交,这里要说的不是死锁,而是两阶段锁
,也就是事务B被锁住了,只有事务D释放了这个锁,事务B才能继续当前读
现在,回到开头的问题,事务的可重复读是如何实现的呢?
其实,可重复读实现的核心就是一致性读;而事务更新数据的时候,只能用当前读,如果当前的记录被行锁锁住的话,就只能进入锁等待
读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:
那么,我们再看一下,在读提交的隔离级别下,事务A和事务B的查询语句的k分别是多少
这里需要说明一下,start transaction with consistent snapshot
的意思是从这个语句开始,创建一个持续整个事务的一致性快照,所以,在读提交隔离级别下,这个用法就没有意义了
这里,我们使用前面的事务A、B、C来说明,而不是D
事务A的查询语句的视图数组是在执行select语句的时候创建的,时序上(1, 2), (1, 3)的生成时间都在创建这个视图数组的时刻之前,但是,在这个时刻:
因此,此时事务A查询的k = 2
事务B查询的结果是3