场景导入
我们晓得,在可重复读的隔离等级下,当事务A启动之时会生成一个read view,在该事务A后续执行期间,就算其他事务对数据进行了修改,事务A所看到的内容依旧和启动时保持一致。
思考这样一个问题:要是该事务A想要对某一行进行更新,而此时该行的行锁被别的事务B持有,那么事务A就会被锁住从而等待行锁。当事务A获取到行锁并打算查询或更新时,它读取到的到底是启动时看到的旧值还是被事务B更新后的新值呢?
我们以一张有着两行数据(id,k)=(1,1),(2,2)
的表作为例子。假定当下有三个事务A、B、C,它们的语句时间顺序如下:

首先,得留意事务的启动时机:begin/start transaction
并不会直接让事务启动,而是在执行完它们之后的第一个涉及InnoDB表的语句时,才会真正启动事务。要是想要立刻启动一个事务,可以运用start transaction with consistent snapshot
,就像事务A和事务B那样。而对于事务C,没有显式使用相关语句,这意味着更新语句自身就是一个事务,会在语句执行完毕后自动提交。
上面这个例子就是我们要探讨的场景,事务B率先启动了事务,而想要更新的行先被事务C修改,之后事务B自身进行更新并查询;事务A在事务B之后查询同一行。那么事务A和事务B的查询结果分别是多少呢?
答案是:事务A得到的结果是k=1
,事务B得到的结果是k=3
。
要是这个答案和你预想的不一样,那就接着往下读,相信最后能解开疑惑。
快照在MVCC里的运作方式
在可重复读的隔离等级下,事务启动时就会有一个快照,此快照是基于整个数据库的。
接下来先看看快照是怎样实现的:
InnoDB里每一个事务都有一个独一无二的事务ID,称为transaction id,这个ID在事务开始的时候会向InnoDB的事务系统申请,按照申请的顺序严格递增。
并且每行数据存在多个版本,每当有事务对数据进行更新,都会生成一个新的数据版本,同时把事务的transaction id赋给这个数据版本,记为row trx_id。与此同时,旧的数据版本依然会被保留,而且能够通过一定办法从新数据版本中获取到旧数据版本。下图展现了一个记录被多个事务更新的过程:

图中,下方的矩形代表不同的数据版本。而\(U_i\)实际上就代表了undo log。只要获取到最新的数据版本和undo log,就能够回滚出历史数据版本。
为了达成可重复读的定义,实际上在一个事务启动的时候,允许它看到自己创建的以及在它启动之前已经生成的数据版本,不允许看到在它启动时还没有生成的数据版本。
在实现方面,InnoDB会为每个事务构造一个数组,用来保存该事务启动瞬间当前处于活跃状态的事务ID。这里所说的活跃,指的是启动了但还没有提交的事务。同时,还会记录数组里面事务ID的最小值,以及当前系统中已经创建过的事务ID的最大值加1。
数组、最小值、(最大值+1)以及当前事务ID,实际上共同构成了当前事务的一致性视图read view。
而数据版本是否可见,是基于read view和数据版本的row trx_id来判断的。read view的数组和字段会把row trx_id分成几种情况:

对于当前启动的事务,一个数据版本的row trx_id,存在以下几种可能:
-
落在绿色部分,表明该版本在当前事务启动之前已经提交或者是自己创建的,是可见的;
-
落在红色部分,表明该版本不是由所有已创建出来的事务启动的,是不可见的;
-
落在黄色部分
-
要是row trx_id在数组中,表明是活跃事务生成的,还没有提交,是不可见的;
-
要是row trx_id不在数组中,表明是已经提交的事务生成的,是可见的。
-
所以,由于所有数据都有多个版本,每个创建的事务都有对应的快照。
接下来分析“场景导入”中事务A的查询结果为何是k=1
:
这里先做几个假设。假定事务A开始之前,系统里只有一个活跃事务ID为99,事务A、B、C的ID分别是100、101、102,并且当前系统只有四个事务;在三个事务启动之前,(1,1)
这一行数据的row trx_id为90。
根据这个假设,事务A的read view中的数组是[99,100],事务B的read view中的数组是[99,100,101],事务C的read view中的数组是[99,100,101,102]。
我们来分析事务A相关的操作:

能够发现,尽管在事务A进行查询时,数据已经变成了(1,3)
,但由于该版本的row trx_id=101
不在事务A的read view数组中,所以该版本对事务A是不可见的。事务A查询语句的流程应该是:
-
找到
(1,3)
,发现不可见; -
找到上一个版本
(1,2)
,发现不可见; -
继续往前找,找到
(1,1)
,这是一个可见的数据版本。
通过以上分析,相信你已经明白为什么事务A的查询结果是k=1
了。不过要是每次都这样分析,未免有些繁琐,所以我们总结出:一个数据版本,对于一个事务视图来说,除了自己的更新总是可见外,有三种情况:
-
版本没有提交,不可见;
-
版本已经提交,但是是在read view创建之后提交的,不可见;
-
版本已经提交,而且是在read view创建之前提交的,可见。
以上总结和前面基于row trx_id比较分析的方法对比,其实就是去掉了数字对比,仅仅用时间先后顺序来判断。
更新逻辑
剖析事务B相关的操作:

能够发现,如果像分析事务A那样去分析事务B,会觉得事务B看不到(1,2)
这个数据版本。
这个问题出在混淆了“快照读”和“当前读”。当前读指的是读取最新版本的数据。由于更新数据都是先读再写,所以用到的是当前读而不是快照读。
明白了这个规则后,就比较好理解答案了,事务B在更新时能够获取到数据(1,2)
,从而更新后生成一个新的数据版本(1,3)
,并且该版本的row trx_id是事务B的ID 101。那么之后事务B在查询时,能够查到由自己更新的数据版本,得到的结果是k=3
。
当前读除了在update语句上会生效,如果使用select … lock in share mode / for update
,也是当前读。所以,如果对事务A的查询语句加锁,它也能够查询出k=3
。
假设事务C不是马上提交,而是变成了下面这样:

此时,就需要考虑上一篇文章介绍的“两阶段锁协议”。因为事务C’没有提交,它在id=1
这一行上加的写锁不会释放。而事务B是当前读,必须加锁读取最新版本,所以会被锁住,直到事务C’释放这个行锁。
从可重复读到读已提交
到这里,我们可以归纳事务实现可重复读能力的方式:核心是read view,而且事务更新数据的时候,只能使用当前读,如果读取行的行锁被其他事务占用,就需要进入锁等待。
可重复读和读已提交的区别:
-
在读已提交隔离级别下,每个语句执行之前都会生成一个read view。
-
在可重复读隔离级别下,只在事务开始时创建read view。
最后我们再分析一下在读已提交的隔离级别下,初始场景中事务A和事务B的读取结果。画出状态图:

对于事务B,答案依然是k=3
。
对于事务A,它创建read view时已经能够看到(1,2)
的版本,但由于事务B还没有提交,事务A不能看到(1,3)
的版本,所以事务A的查询结果是k=2
。