场景引入
我们知道,在可重复读的隔离级别下,一个事务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
。