菜鸟笔记
提升您的技术认知

事务到底是隔离还是不隔离?

1. 引例

之前我们探讨过可重复读隔离级别下,事务T启动的时候会创建一个视图read-view。在事务T执行期间,即使有其他事务修改了数据,事务T看到的也是跟启动时一样的。

但是上次讲到行锁的时候,当事务T要更新当前行的时候,其他事务占据了该行的行锁。那等
其他事务更新完,事务T要更新当前行的时候,看到的值又是多少呢?

首先,我们创建一个表,然后插入两行数据

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

这里我们需要注意事务的启动时机,启动实际是存在两种方式的:

  1. begin/start transaction:这种命令的启动方式并不是立即启动事务,而是到执行第一个InnoDB的操作语句时,事务才真正启动。也就是说一致性视图是在第一个快照读语句时创建的
  2. start transaction with consistent snapshot:这种命令的启动方式能够立即启动事务。也就是说一致性视图是在执行该语句的时候就创建了

对于上图中,事务C没有显式地启动事务,但是执行完update语句就会立马提交。事务B在更新了行之后查询。事务A是一个只读地事务,且在事务B和事务C更新之后进行查询。

最终的答案是,事务A看到的值是1,事务B看到的值是3。

在MySQL中视图有两个含义:

  1. view:一个用查询语句定义的虚拟表,创建视图的语法是create view…
  2. consistent read view:InnoDB在实现mvCC时用到的一致性读视图,用于支持Read Committed(读提交)和Repeatable Read(可重复读)隔离级别的实现。

2. 快照在MVCC中是如何工作的?

在可重复读隔离级别下,事务在启动的时候就建立了一张快照,并且这个快照是基于整库的

InnoDB里面每个事务有一个唯一的事务ID,叫做transaction id。它是在事务开始时向InnoDB的事务系统申请的,是按照申请顺序严格递增的。

而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新版本的数据,并且把transaction id赋值给这个数据版本的事务ID,记为row trx_id。也就是说,数据表中的一行数据,其实可能有多个版本,每个版本有自己的row trx_id。

如下图所示,就展现了一个记录被多个事务更新的状态。

V1、V2、V3并不是物理上实际存在的,而是每次需要的时候根据当前版本和redo log(回滚日志)计算出来的。比如,需要V2的时候,通过V4依次执行U3、U2算出来的。

接下来,我们来看快照是如何生成的?

根据可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。
也就是说,如果一个事务是在我启动之前生成的,就认。如果是我启动之后生成的,我就不认,必须要找到上一个可见的版本。此外,如果这个事务自己更新的数据,也是要认的

在具体实现的时候,InnoDB为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务ID。“活跃”是指启动了但还没提交。

数组里面事务ID的最小值记为低水位,当前系统里面已经创建过的事务ID的最大值加1记为高水位。这个视图数组和和高水位,就组成了当前事务的一致性视图

数据版本的可见性规则,就是基于数据的row trx_id和这个一致性视图对比结果得到的。如下图,分为以下几种情况:

对于当前事务的启动瞬间,一个数据版本的row trx_id,有以下几种可能:

  1. 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的。
  2. 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的。
  3. 如果落在黄色部分,那就包含两种情况
    • 若row trx_id在数组中,表示这个版本是由还没提交的事务生成的,不可见;
    • 若row trx_id不在数组中,表示这个版本是已经提交了的事务生成的,可见。

接着上面的说,对于第一个图中,如果有一个事务,它的低水位是18,那么当访问到这行数据的时候,就会从V4回滚到V3,在他看来,这一行的值是11。InnoDB利用了“所有数据都有多个版本”实现了秒级快照的能力。

然后,我们再回到最开始的引例,分析下为什么事务A看到的值是k=1。
我们先做如下的假设:

  1. 事务A开始前,系统分配的最大transaction id为99;
  2. 则事务A、B、C的版本号分别为100、101、102,且当前系统只有这四个事务;
  3. 三个事务开始前,(1,1)这行数据的row trx_id是90;

基于此,事务A的视图数组为[99,100],事务B的视图数组为[99,100,101],事务C的视图数组为[99,100,101,102]。数据版本的展示如下:

事务A查询语句读取数据的流程是这样的

  1. 找到(1,3)的时候,发现row trx_id=101,比高水位大,处于红色区域,不可见。
  2. 找到上一个历史版本(1,2),发现row trx_id=102,比高水位大,处于红色区域,不可见。
  3. 再找到上一个历史版本(1,1),发现row trx_id=90,比低水位小,处于绿色区域,可见。

但是上面这种判断方法太麻烦,我们可以简化判断方法

  • 前提:除了自己的更新总是可见之外
  • 版本未提交,不可见
  • 版本已提交,但是在视图创建后提交,不可见
  • 版本已提交,但是在视图创建前提交,可见。

我们再基于这种方法来判断事务A的查询流程:

  1. (1,3)还没提交,属于情况1,不可见
  2. (1,2)提交了,但是在视图数组创建之后提交的,属于情况2,不可见
  3. (1,1)是在视图数组创建之前提交的,可见。

3. 更新逻辑

可能你会发现,如果按照上面的逻辑,事务B不应该也看不见(1,2)吗?下面是事务B的事务逻辑图:

如果事务B只更新数据的话,那看到的确实是(1,1)。但是事务B先更新了数据,那么就不能在历史版本上更新了,否则就会出现丢失更新的情况。因此事务B的set k=k+1是在(1,2的基础上)完成的。

所以这里用到了一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。 因此,在执行事务B查询语句的时候,自己的版本号和最新的数据版本号都是101,所以查询得到k的值是3。

其实除了update语句外,select语句如果加锁,也是当前读。
因此,如果把事务A的查询语句,加上lock in share mode或for update,也都是返回k等于3。如下面这两个select语句,就是分别加了读锁和写锁。

mysql> select k from t where id=1 lock in share mode;
mysql> select k from t where id=1 for update;

再分析另外一种情况,如果事务C不是马上提交会怎么样?如下图:

因为事务C先将k修改为2,根据之前的两阶段协议,由于还没有提交,所以事务C会持有该行的行锁,直到事务提交才释放。又由于事务B是当前读,必须要读到最新版本,所以就需要等待事务C释放了锁才能修改值。如下图所示:

接下来,我们总结下事务的可重复读的能力是如何实现的
可重复读的核心就是一致性读;而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进行等待。

而对于读提交的逻辑其实也很像,主要区别在于?

  • 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的查询都共用这个一致性试图。
  • 在读提交的隔离级别下,每一个语句执行前都会重新计算出一个新的视图。

那么在读提交的隔离级别下,事务A和事务B查询到的值是多少呢?

事务B的查询结果为k=3.但是由于事务B还没提交,事务C提交了,所以事务A的查询结果为k=2。