首先看一个例子:
CREATE TABLE `test` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
往test表插入两条数据:
INSERT INTO `test` (`id`, `c`) VALUES (1, 1);
INSERT INTO `test` (`id`, `c`) VALUES (2, 2);
然后我们对这张表做如下操作:
事务的启动机制:begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作InnoDB 表的语句,事务才真正启动。如果我们想马上启动一个事务,可以用 start transaction with consistent snapshot 命令。
第一种启动方式,一致性视图是在执行第一个快照读语句时创建的。
第二种启动方式,一致性视图是在执行 start transaction with consistent snapshot 时创建的。
在MySQL中有两个“视图”的概念:
- 一个是view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是create view...,而它的查询方法和查询表一样。
- 另一个是InnoDB在实现MVCC时用到的一致性视图,即consistent read view,用于支持读提交(Read Committed)和可重复读(Repeatable Read)隔离级别的实现。
InnoDB是怎么秒级创建快照的
在可重复读隔离级别下,事务启动时就会基于整个数据库创建一个快照。
快照的实现:
快照并不是把数据库所有的数据都拷贝一边存放。
在InnoDB里,每个事务都有一个唯一的transaction id。它是在事务开始的时候向InnoDB的事务系统申请的,是按准许严格递增的。
每行数据都会有多个版本。每次事务更新数据的时候,都会生成一个新的数据版本,并把transaction id 赋值给这个数据版本的事务ID,叫做row trx_id。同时,旧的数据版本也要保留,并在新的数据版本中可以找到旧的数据版本。
简单的说就是,数据表中的一行记录可能有多个版本(row),每个版本有自己的row trx_id。
如图所示:
图中同一行数据共有四个版本,当前版本是V4,c的值是8,它是被transaction id = 20的事务更新的,所以它的row trx_id也是20。
图中的三个红色箭头就是undo log;其实V1,V2,V3,并不是物理上真实存在的,而是每次更新的时候根据当前版本和undo log 计算出来的。比如,我们想得到V2版本时候的值,就需要从V4依次执行U3,U2得到。
可重复读定义:一个事务开启的时候,能够看到这个时候开启那一刻所有已经提交的事务结果。但开启之后,这个事务commit之前,其他新的事务的更新对它是不可见的。
实现方式:
InnoDB为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前已经启动但还未提交的事务ID。
数组里面最小的事务ID标记为低水位,当前系统中已经创建过的事务ID的最大值加1标记为高水位。
注意:低水位是针对事务数组里最小的事务ID,针对的是事务数组;高水位是当前系统创建过的事务ID最大值加1,针对的是当前系统;
这个视图数组和高水位组成了当前事务的一致性视图(read-view)。
数据的可见性规则就是基于数据的row trx_id和一致性视图的对比结果得到的。
从图中可以看出,当前事务启动瞬间,一个数据版本的row trx_id有以下几种可能:
如果落在绿色部分,表示这个版本是已经提交的事务或者是当前事务自己生成的,可见。
如果落在红色部分,表示这个版本是由将来启动的事务生成的,不可见。
-
如果落在黄色部分,包含两种情况:
a. 如果row trx_id 在数组中,表示这个版本是还没提交的事务生成的,不可见。
b. 如果row trx_id 不在数组中,表示这个版本是已经提交的事务生成的,可见。
接下来我们分析一下开篇的那个例子,分析事务A返回的结果。
我们可以假设一下:
- 事务A开始前,系统里面只有一个活跃事务(启动但为提交)ID=99;
- 事务A,B,C的版本号分别为100,101,102,而且当前系统中只有这四个事务;
- 三个事务开始前,c=1这行数据的row trx_id是90。
这样,事务 A 的视图数组就是[99,100], 事务 B 的视图数组是[99,100,101], 事务 C 的视图数组是[99,100,101,102]。
从图中可以看出,事务C把c=1改成了c=2,这时候数据版本的row trx_id = 102。
事务B把c=2改成了c=3,这时候数据版本的row trx_id = 101。
可重复读隔离级别下,事务A查询的时候,事务B还没有提交,所以c=3对事务A来说是不可见的,否则就是脏读了。
现在事务A的视图数组是[99,100],读数据都是从当前版本开始读的,所以事务A查询语句读数据流程如下:
- 找到c=3的时候,判断row trx_id=101,比高水位大,处于红色区域,不可见;
- 继续找历史版本,找到 row trx_id=102,比高水位大,处于红色区域,不可见;
- 继续找历史版本,找到row trx_id=90,比低水位小,处于绿色区域,可见。
所以事务A查询得到的结果c=1。
所以一个数据版本,对于一个事务视图来说,除了自己更新的总是可见以外,有三种情况:
- 版本未提交,可见;
- 版本已提交,但是是在视图创建后提交,不可见;
- 版本已提交,但是是在视图创建前提交,可见。
在这个例子中,事务B是在事务C之前启动的,那为什么事务B算出来的c=3呢?
因为事务B在更新之前,是要先读一次当前版本数据的,然后再在当前版本的数据基础之上做的更新,要不然事务C的更新就丢了。
所以这里有一条规则:更新数据都是先读后写,这个读,只能读当前版本的数据,称为“当前读”(current read)。
所以,当事务B更新之前,当前都得到的c=2,更新后生成了新的版本数据c=3,新版本的row trx_id=101,然后事务B查询时拿到的row trx_id=101和自己的版本相同,是自己更新的,可以直接使用,所以事务B查询得到的c=3。
除了update语句是当前读之外,如果select语句加锁,也是当前读。
select c from test where id=1 lock in share mode;
select c from test where id=1 for update;
上面两条语句中,第一条语句加读锁(S锁,共享锁),第二条语句加写锁(X锁,排他锁)。
如果我们改一下例子中的事务A语句,加上lock in share mode或者for update,返回结果c=3.
读提交的逻辑和可重复读的逻辑是类似的,主要的区别就是:
- 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
- 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。
结束!