MySQL实战45讲——08|事务到底是隔离的还是不隔离的?

文章目录

  • 08|事务到底是隔离的还是不隔离的?
    • 快照在MVCC里是如何工作
    • 更新逻辑

08|事务到底是隔离的还是不隔离的?

在文章03| 事务隔离:为什么你改了我还看不见里,我们提到了如果是可重复读级别的隔离事务,那么事务启动的时候,就会创建一个事务,并且事务执行期间,即使有其他事务修改了数据,事务看到的任然和启动的时候看到的是一样的

在文章07|行锁功过:怎么减少行锁对性能的影响里,一个事务在可重复读隔离级别下要更新一行,如果恰好有另一个事务拥有这一行的行锁,那么它会被锁住,进入等待状态。问题是,既然进入了等待状态,那么等到这个事务获得行锁,要更新数据的时候,它读到的值是什么?

这是一个很有意思的问题,对吧

假设有:

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
start transaction with consistent snapshot;
start transaction with consistent snapshot;
update t set k = k + 1 where id = 1;
update t set k = k + 1 where id = 1;
select k from t where id = 1;
select k from t where id = 1 ;
commit
commit;

这里,我们需要注意的是事务的启动时机

begin/start transaction命令并不是一个事务的起点,在执行到它们之后的第一个操作InnoDB表的语句,事务才真正的启动,如果你想要马上启动一个事务,可以使用start transaction with consistent snapshot命令

还有一个要注意的就是,默认autocommit是1

那么回到这个例子,事务C没有显式启动事务,表示这个update本身就是一个事务,语句完成的时候会自动提交。事务B在更新了行之后查询,事务A在只读事务中查询,而且时间顺序是在事务B的查询之后

这时候,如果我告诉你事务B查到的k = 3,事务A查到的k = 1,你是不是会有些莫名其妙?

在MySQL里,有两个视图的概念

  • 一个是view,它是查询语句定义出的虚拟表,在调用的时候执行查询语句并生成结果,创建视图的语法是:create vie …,此后视图可以视为和表一样
  • 另一个是InnoDB在实现MVCC时,用到的一致性视图,即consistent read view,用于支持RC(Read Commited 读提交)和RR(Repeatable Read,可重复读)隔离级别的实现

它没有物理结构,作用是事务执行期间用来定义"我能看到什么数据"

快照在MVCC里是如何工作

在可重复读下,事务在启动的时候就拍了个快照,注意,这个快照是基于整个库的

你可能会反驳:假如一个库有100个g,那么我启动一个事务,MySQL就要拷贝100g的数据出来,这个过程得有多慢,平时的事务执行起来都很快的

事实上,我们并不需要拷贝出这100g的数据,我们先来看看快照的实现原理

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


而每行数据也都是有多个版本,每次事务要更新数据的时候,都会生成一个新的数据版本,而且把transaction id赋值给这个数据版本的事务id,记作row trx_id。同时,旧的数据版本要保留,而且在新的数据版本中,能够有信息可以直接拿到它,这一段可以看看可重复读的原理,见文章03| 事务隔离:为什么你改了我还看不见


也就是说,数据表中的一行记录,有多个版本,每个版本都有自己的row trx_id

U3
U2
U1
V4 k=22 row trx_id=25
V3 k = 11 row trx_id = 17
V2 k = 10 row trx_id = 15
V1 k = 1 row trx_id = 10
set k = k * 2 transaction id = 25
set k = k + 1 transaction id = 17
set k = 10 tracsaction id = 15

如图,每一句都会把transaction id赋值给每一行的各个版本,最新的版本是V4,k = 22,它是被transaction id为25的事务更新的,因此它的row trx_id也是25

如果你看过02|日志系统:一条SQL更新语句是如何执行的,你可能会问,语句更新会生成undo log(回滚日志),那么这个回滚日志在哪呢

事实上,图中的三个箭头,U3, U2, U1就是undo log;而V1、V2、V3并不是物理上真实存在的,而是每次需要的时候根据当前版本和undo log计算出来的。比如说需要V2的时候,就会通过V4依次执行U3, U2得来

明白了这点后,我们再来看看InnoDB是如何定义那个"100G"快照的

根据可重复读的含义:一个事务启动的时候,能够看到所有已经提交的所有事务的结果,但是这之后,这个事务执行期间,其他事务的更新对它是不可见的

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

数组里事务ID的最小值记作低水位

当前系统里已经创建过事务ID的最大值+ 1记作高水位

这个视图数组和高水位旧组成了当前事务一致性视图,而数据版本可见性规则,就是基于数据的row trx_id和这个一致性视图的对比得到的。

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

  1. 如果数据版本的row trx_id小于低水位,那么表示这个版本是已提交的事务或者当前事务自己生成的,这个数据是可见的
  2. 如果是row trx_id高于高水位,那么表示这个版本是将来启动事务生成的,是不可见的
  3. 如果在低水位和高水位之间的,也就是数据版本的row trx_id在区间:[低水位, 高水位]之间的,有两种情况
    1. 若这个row trx_id在数组中,表示这个版本是由还未提交事务生成的,不可见
    2. 若row trx_id不在数组中,表示这个版本是已经提交的事务生成的,是可见的
也就是说,所有正在执行并且未提交的事务的事务id构成一个数组,数组的最小值称之为低水位
数组的最大值 + 1称之为高水位

只要row trx_id <= 低水位,那么数据就是可见的
只要row trx_id >= 高水位,那么数据就是不可见的
如果数据位于高低水位之间,那么要看row trx_id是否在数组里,如果在,不可见,反之可见

这里MySQL实战45讲并没有讲清楚,为什么会出现这种高低水位之间,但是row trx_id却又不在数组里的情况至少对于我造成了一点点困惑,这里我单独讲一下,如果不对,请指正

前文已经提到了,row trx_id是由transaction id赋值的,并且,transaction是按申请顺序严格递增的

那么这种在高低水位区间内,但是又不在数组里的情况的情况,举个例子:有事务A, B, C依次启动。其中A、C启动了但未提交,B已经提交,在这种情况下,又启动了事务D,系统为D生成了数组(其他事务也拥有自己的数组),那么这时候对于事务B,它生成的row trx_id就会出现:在高低水位区间内,但是又不在数组里


那么,还是那个图

U3
U2
U1
V4 k=22 row trx_id=25
V3 k = 11 row trx_id = 17
V2 k = 10 row trx_id = 15
V1 k = 1 row trx_id = 10
set k = k * 2 transaction id = 25
set k = k + 1 transaction id = 17
set k = 10 tracsaction id = 15

假设有一个事务,它的低水位是18,当它访问这一行数据的时候,会通过U3计算出V3,所以在它看来,这一行的值是11

所以,有了这么一个数组,系统随后发生的更新,就和这个事务看到的内容无关了,因为之后的更新,生成的版本一定属于2或者3.1的情况。而对于它来说,这些新的数据是不可见的,所以这个事务的快照就是“静态”的了

所以现在你知道了,InnoDB利用了"所有数据都有多个版本"的这个特性,实现了"秒级创建快照"的能力

我们回过头来看之前那个表格

事务A 事务B 事务C
start transaction with consistent snapshot;
start transaction with consistent snapshot;
update t set k = k + 1 where id = 1;
update t set k = k + 1 where id = 1; select k from t where id = 1;
select k from t where id = 1 ;
commit
commit;

分析一下,事务A的返回结果,为什么k = 1

  1. 我们先做如下假设,在事务A开始之前,系统里面只有一个活跃事务id是99

  2. 事务A、B、C的版本号分别是100、101、102,且当前系统里只有这四个事务

  3. 三个事务开始前,(1, 1)这一行数据的row_id是90


那么现在

对于事务A的视数组是:[99, 100]

对于事务D的视数组是:[99, 100, 101]

对于事务C的视数组是:[99, 100, 101, 102]

那么
分析开始:首先,第一个有效更新语句是事务C,把数据从(1, 1)改成了(1, 2),这个时候新版本的row trx_id是102

第二个有效更新是事务B,把数据从(1, 2)改成了(1, 3),这个时候最新版本的row trx_id是101


现在,事务A要查数据了,它的视图数组是[99, 100]。因此,只能看到row trx_id<=99或者row trx_id在区间[99, 100]之间,且row trx_id是99或者100

事务A查询语句的流程是这样的:

  • 找到(1, 3),判断出row trx_id=101,比高水位大,不可见
  • 找到row trx_id=102,比高水位大,不可见
  • 找到(1, 1),row trx_id为90 ,比低水位小,可见

这样一整套下来,虽然事务A执行期间,这一行数据被修改过,事务A不论什么时候查询,看到这一行数据的结果都是一致的,所以我们称之为一致性读

那么为了方便记忆,可以有如下情况:

  1. 版本未提交,不可见

  2. 版本已提交,但是是在视图创建后提交,不可见

  3. 版本已提交,而且是在视图创建之前提交的,可见

现在我们用这三个结论来判断一下:

事务A的查询语句的视图数组是在事务A启动的时候生成的,这个时候:

  • (1, 3)还未提交,属于情况1,不可见
  • (1, 2)虽然已经提交,但是是在视图创建后提交的,属于情况2, 不可见
  • (1, 1)是在视图创建之前提交的,可见

更新逻辑

那么细心的同学会有疑问,按照这个逻辑,事务B的update语句,结果好像不对吧?

事务B的视图数组是先生成的,之后事务C才提交,不是应该看不见(1, 2)吗,怎么算出来的(1, 3)


答案很浅显,如果事务B在更新之前查询一次数据,那么这个查询结果就是1,但是当它要更新数据的时候,就不能在历史版本上更新了,否则事务C的更新就丢失了。因此事务B的更新是在(1, 2)上进行的操作

这里有一条规则:更新数据都是先读后写,而这个读,只能读当前的值/实时的值,称之为"当前读"

所以,事务B查询语句的时候,一看自己的版本号是101,最新数组也是101,是自己更新的,可见,因此返回的k=3

这里,我们提到了当前读,其实不只是update,如果select加锁,那么也是当前读

所以把事务A的查询语句select * from t where id = 1修改一下,加上lock in share mode 或for update,也都可以读到版本号是101的数据,返回值是3

下面两个select语句,就是分别加了读锁,(S锁,共享锁)和写锁(X锁,排他锁)

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

现在,挑战一下,如果事务C不是马上提交,而是变成如下的事务D,会怎么样呢?

事务A 事务B 事务D
start transaction with consistent snapshot;
start transaction with consistent snapshot;
start transaction with consistent snapshot;
update t set k = k + 1 where id = 1
update t set k = k + 1 where id = 1;
select k from t where id = 1;
select k from t where id = 1 ;
commit
commit;
commit;

在事务D提交之前,事务B的更新语句先发起,这个时候虽然事务D还未提交,但是(1, 2)这个版本已经生成了,而且是当前的最新版本,那么事务B的更新语句会怎么处理呢?

这个时候,要留意,事务D更新id = 1的行,并且还没提交,事务B也要更新id = 1的行,并且也还没提交,这里要说的不是死锁,而是两阶段锁,也就是事务B被锁住了,只有事务D释放了这个锁,事务B才能继续当前读

现在,回到开头的问题,事务的可重复读是如何实现的呢?

其实,可重复读实现的核心就是一致性读;而事务更新数据的时候,只能用当前读,如果当前的记录被行锁锁住的话,就只能进入锁等待

读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:

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

那么,我们再看一下,在读提交的隔离级别下,事务A和事务B的查询语句的k分别是多少

这里需要说明一下,start transaction with consistent snapshot的意思是从这个语句开始,创建一个持续整个事务的一致性快照,所以,在读提交隔离级别下,这个用法就没有意义了

这里,我们使用前面的事务A、B、C来说明,而不是D

事务A的查询语句的视图数组是在执行select语句的时候创建的,时序上(1, 2), (1, 3)的生成时间都在创建这个视图数组的时刻之前,但是,在这个时刻:

  • (1, 3)还没提交,属于情况1, 不可见
  • (1, 2)提交了,属于情况3,可见

因此,此时事务A查询的k = 2

事务B查询的结果是3

你可能感兴趣的:(MySQL实战45讲,mysql,数据库)