MySQL是怎么实现事务隔离的?
InnoDB的每个事务有个唯一事务ID:transaction id,在事务开始时向InnoDB事务系统申请的,按申请顺序严格递增。
每行数据也都有多个版本
每次事务更新数据时,都会生成一个新的数据版本,并把transaction id
赋给该数据版本的事务ID,记为row trx_id。同时,旧数据版本要保留,并且在新数据版本中,能够有办法可以直接拿到它。
也就是说,数据表中的一行记录,其实可能有多个版本(row),每个版本有自己的row trx_id。
如图所示,就是一个记录被多个事务连续更新后的状态。
- 行状态变更图
TODO
虚线框里是同一行数据的4个版本,当前最新版本是V4,k=22,它是被transaction id=25的事务更新,因此它的row trx_id=25。
- 语句更新会生成undo log(回滚日志),在哪呢?
三个虚线箭头,就是undo log。V1、V2、V3并非物理上真实存在,而是每次需要时,根据当前版本和undo log计算而得。比如,需要V2时,就通过V4依次执行U3、U2计得。
那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不在数组中,表示该版本是已提交的事务生成的,可见
比如,对于【行状态变更图】的数据,若有一个事务,它的低水位是18,则当它访问这一行数据时,就会从V4通过U3计算出V3,所以在它看来,这一行值是11。
有了该声明后,系统里随后发生的更新,就跟该事务看到的内容无关了。因为之后的更新,生成的版本一定属于上面的2或者3(a),而对它来说,这些新的数据版本是不存在的,所以这个事务的快照,就是“静态”的了。
所以InnoDB利用了“所有数据都有多版本”的特性,实现了“秒级创建快照”能力。
接下来,我们开始分析一开始的三个事务
=====================================================================
假设:
-
事务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查询逻辑有关的操作:
- 事务A查询数据逻辑图
TODO
-
第一个有效更新是事务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不论在什么时候查询,看到这行数据的结果都是一致的,所以称之为一致性读。
一个数据版本,对于一个事务视图来说,除了自己的更新总是可见之外,还有如下情况:
-
版本未提交,不可见
-
版本已提交,但是是在视图创建后提交的,不可见
-
版本已提交,而且是在视图创建前提交的,可见。
现在,让我们用这些规则判断查询结果,事务A的查询语句的视图数组是在事务A启动时生成的,这时:
-
(1,3)还没提交,属于case1,不可见
-
(1,2)虽然提交了,但却在视图数组创建之后提交,属于case2,不可见
-
(1,1)是在视图数组创建之前提交的,可见
现在只需通过时间先后分析即可。
===================================================================
事务B的update语句,若按一致性读,好像结果不对呢?
你看下图,事务B的视图数组是先生成的,之后事务C才提交,不是应该看不见(1,2)吗,怎么能算出(1,3)?
- 事务B更新逻辑图
TODO
若事务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。
当前读(current read)
除了update语句外,select语句若加锁,也是当前读。
所以,若修改事务A的查询语句
select * from t where id=1
加上lock in share mode 或 for update,都可读到版本号是101的数据,返回的k的值是3。
// 加了读锁(S锁,共享锁)
mysql> select k from t where id=1 lock in share mode;
// 写锁(X锁,排他锁)
mysql> select k from t where id=1 for update;
假设事务C不是马上提交的,而是变成了下面的事务C’,会怎么样呢?
- 事务A、B、C’的执行流程
事务C’不同在于更新后并没有马上提交,在它提交前,事务B的更新语句先发起了。前面说过了,虽然事务C’还没提交,但(1,2)这个版本也已经生成了,并且是当前的最新版本。
那事务B的更新语句会怎么处理呢?
“两阶段锁协议”。事务C’没提交,即(1,2)这个版本上的写锁还没释放。而事务B是当前读,必须要读最新版本,而且必须加锁,因此就被锁住了,必须等到事务C’释放这个锁,才能继续它的当前读。
- 事务B更新逻辑图(配合事务C’)
TODO
至此,一致性读、当前读和行锁就串起来了。
==========================================================================
-
可重复读的核心就是一致性读(consistent read)
-
而事务更新数据时,只能用当前读
若当前的记录的行锁被其他事务占用,就需要进入锁等待。
读提交和可重复读的逻辑类似,最主要区别是:
-
可重复读,只需要在事务开始时创建一致性视图,之后事务里的其他查询都共用该一致性视图
-
读提交,每个语句执行前都会重新算出一个新视图
那在读提交隔离级别下,事务A和事务B的查询语句查到的k,分别应该是多少呢?
start transaction with consistent snapshot;
的意思是从这个语句开始,创建一个持续整个事务的一致性快照。所以,在读提交隔离级别下,这个用法就没意义了,等效于普通的start transaction。
下面是读提交时的状态图,可以看到这两个查询语句的创建视图数组的时机发生了变化,就是图中的read view框。(注意:这里,我们用的还是事务C的逻辑直接提交,而不是事务C’)
图8 读提交隔离级别下的事务状态图
这时,事务A的查询语句的视图数组是在执行这个语句的时候创建的,时序上(1,2)、(1,3)的生成时间都在创建这个视图数组的时刻之前。但是,在这个时刻:
(1,3)还没提交,属于情况1,不可见;
(1,2)提交了,属于情况3,可见。
所以,这时候事务A查询语句返回的是k=2。
显然地,事务B查询结果k=3。
=================================================================
InnoDB的行数据有多个版本,每个数据版本有自己的row trx_id,每个事务或者语句有自己的一致性视图。普通查询语句是一致性读,一致性读会根据row trx_id
和一致性视图
确定数据版本的可见性。
- 对于可重复读
查询只承认在事务启动前就已经提交完成的数据