Scott's world.

MySQL-详解事务隔离笔记

Word count: 3.1kReading time: 11 min
2019/11/09 Share

MySQL-详解事务隔离笔记

在前面我们也提到过事务隔离

事务隔离中有一个可重复读隔离级别,事务启动的时候会创建一个视图read-view,之后执行期间如果有其他事务修改了数据,那么该事务看到的还是和启动时看到的一样,但是这其中仍然有很多细节值得我们去推敲

比如在上一篇中提到了行锁的概念,那么在另一个事务拥有行锁的时候,其他想要操作该行的事务就会被锁住,进入等待状态,问题是进入等待状态,那么等到这个事务自己获取到行锁要更新数据的时候,它读到的值是先前拥有行锁更新后的值还是更新前的值呢

现在我们通过一个例子来了解这一过程

1
2
3
4
5
6
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);

MmKxER.png

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

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

其中事务C没有显示启动事务,但是Update语句本身就是一个事务,语句完成的时候自动提交。

上面的例子的结果为

事务A查到的k的值为1,事务B查到的k为3

这时候你可能就会发现事务中行锁的并没有我们想象的那么简单

下面我们就会来详细解释一下这一个问题

视图

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

  • 一个是view。一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是create view ….。
  • 另一个是InnoDB在实现MVCC时用到的一致性读视图,即consistent read view,用于支持RC和RR隔离级别的实现。

MVCC里面的“快照”

  • InnoDB里面每一个事物都有一个唯一的事务ID,叫作transaction id。

    它是在事务开始的时候向InnoDB的事务系统申请的,是按申请顺序严格递增的。

其中每行数据也都是有多个版本的,每次事务更新数据的时候,都会生成一个新的数据版本,并且把transaction id赋值给这个数据版本的事务ID,记为row trx_id。同时旧的数据版本要保留并且在新的数据版本中可通过undo log去拿到它。

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

可根据当前版本和undo log(回滚日志)计算出旧的数据版本的值,即逆序往回推

以事务的启动时刻为准,当前事务创建的视图中的数据版本必须是在事务启动之前生成的,若是启动之后生成的就必须找到它的上一个版本,而这上一个版本就是在事务启动之前的最后一个版本

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

“活跃”指的就是,启动了但还没有提交

在这个数组里面事务ID的最小值记为低水位,当前系统里面已经创建过的事务ID的最大值加1记为高水位。

而这个视图数组和高低水位,就组成了当前事务的一致性视图(read-view)

而根据数据版本的可见性规则,就是基于数据的row trx_id和这个一致性视图的对比结构得到的

这个视图数组把所有的row trx_id分成了几种不同的情况

Mm1ORx.png

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

  • 若落在绿色部分,可见
  • 若落在红色部分,不可见
  • 若落在黄色部分
    • 若row trx_id在数组中表示这个版本是由还没提交的事务生成的,不可见
    • 若row trx_id不在数组中表示这个版本是由已经提交了的事务生成的,可见

通过数据版本的可见性,在保证事务可见的最后一个数据版本之后,那么之后的新的数据版本对于它则是不存在的,所以这个事务的快照就是“静态”的了。

所以,InnoDB利用了“数据版本的可见性规则“,实现了”秒级创建快照“的能力

分析问题

让我们再回到开头的问题,为什么事务A的查询结果是1,而事务B的查询结果是3

那么我们知道了数据版本和事务版本,就来分析一下这一问题

假设:

1.事务A开始前,系统只有一个活跃事务ID=99

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

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

Mm8ujK.png

根据这一图片,我们就能很清晰的看到该行的数据版本即row trx_id的过程

第一个有效更新是事务C,将数据改成了(1,2),row trx_id=102

第二个有效更新是事务B,将数据从(1,2)改成了(1,3),row trx_id=101,且在事务B还没有提交的情况下,它就生成的数据版本就已经是当前版本。但这个版本对于事务A就是不可见的,否则就会出现脏读

那么事务A读取数据的时候,都是从当前版本读起的,所以事务A的流程是

  • 找到(1,3)的时候,判断出row trx_id=101,比高水位大,处于红色区域,不可见;
  • 接着,找到上一个历史版本,一看row trx_id=102,比高水位大,处于红色区域,不可见;
  • 再往前找,终于找到了(1,1),它的row trx_id=90,比低水位小,处于绿色区域,可见。

这样就保证了事务A中的一致性读,因为不论在什么时候查询,事务A看到这行的数据结果都是一致的。

所以根据上面事务A的流程我们就可以发现

而对于一个事务视图来说,除了自己的更新总是可见以外就有三种情况:

  • 版本未提交,不可见

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

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

然后我们根据这一描述来简化一下这时候事务A的查询语句的视图数组

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

更新逻辑

我们又回到那一个问题上,大家可能发现,如果按照一致性读,那么为什么事务B的update语句执行的时候,已经看到了版本row trx_id=102,且事务B启动时生成的视图数组,不是应该不会出现(1,2)吗?

是的,如果事务B在更新之前查询一次数据,数据返回结果确实是(1,1)

但是如果它要去更新数据的话,就不能再在历史版本上更新了,否则就会把事务C的更新丢失。

那么这里就运用到了一个新的规则叫做

更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。

因此,在更新的时候,当前读拿到的数据是(1,2),更新之后就是生成了(1,3)为新版本且row trx_id=101为当前版本

除了update语句外,如果select语句加锁也是当前读

你可以将食物A的查询语句加上读锁(S锁,共享锁)和写锁(X锁,排它锁)

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

则事务A最后的查询结果就会变为(1,3)

可重复读实现

那么我们再来假设一种新的情况

MmJXAe.png

即修改为事务C‘更新后并没有马上提交,而是等到事务B更新语句发起后再提交

这时候事务B和事务C就会发生行锁的冲突,那么就会出现我们前一篇所讲的“两阶段所协议”

MmYPnf.png

即此时事务C‘未释放该行的写锁,而事务B又是当前读,而又要保证数据版本为最新版本所以,必须加锁直至事务C’提交后释放这个锁,才能继续它的当前读。

这样我们就把一致性读,行锁和当前读连起来了。

那么事务的可重复读的实现就是通过我们这里所提到的三个关键:

可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。

可重复读与读提交的区别

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

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

下面我们来看一下,如果是读提交的话,上面的例子会变成什么样

MmYb2n.png

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

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

所以,这时候事务A查询语句返回的是k=2。

显然地,事务B查询结果k=3。

问题

用下面的表结构和初始化语句作为试验环境,事务隔离级别是可重复读。现在,我要把所有“字段c和id值相等的行”的c值清零,但是却发现了一个“诡异”的、改不掉的情况。请你构造出这种情况,并说明其原理。

1
2
3
4
5
6
mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, c) values(1,1),(2,2),(3,3),(4,4);

img

答案

分析: 假设有两个事务A和B, 且A事务是更新c=0的事务; 给定条件: 1, 事务A update 语句已经执行成功, 说明没有另外一个活动中的事务在执行修改条件为id in 1,2,3,4或c in 1,2,3,4, 否则update会被锁阻塞; 2,事务A再次执行查询结果却是一样, 说明什么?说明事务B把id或者c给修改了, 而且已经提交了, 导致事务A“当前读”没有匹配到对应的条件; 事务A的查询语句说明了事务B执行更新后,提交事务B一定是在事务A第一条查询语句之后执行的;

所以执行顺序应该是:
1, 事务A select from t;
2, 事务B update t set c = c + 4; // 只要c或者id大于等于5就行; 当然这行也可以和1调换, 不影响
3, 事务B commit;
4, 事务A update t set c = 0 where id = c; // 当前读; 此时已经没有匹配的行
5, 事务A select
from t;

CATALOG
  1. 1. MySQL-详解事务隔离笔记
    1. 1.1. 视图
      1. 1.1.1. MVCC里面的“快照”
      2. 1.1.2. 分析问题
    2. 1.2. 更新逻辑
    3. 1.3. 可重复读实现
      1. 1.3.1. 可重复读与读提交的区别
    4. 1.4. 问题
      1. 1.4.1. 答案