MySQL 08 详解read view:事务到底是隔离的还是不隔离的?

场景引入

我们知道,在可重复读的隔离级别下,一个事务A启动的时候会创建一个read view,之后在这个事务A执行期间,即使其他事务修改数据,事务A看到的仍然和启动时相同。

考虑一个问题,假如该事务A想要对一行做更新,而此时这行的行锁被其他事务B持有,那么事务A会被锁住而等待行锁。当事务A获取到行锁想要查询更新时,它读到的值是启动时看到的旧值还是被事务B更新后的新值呢?

我们以一个有两行数据(id,k)=(1,1),(2,2)的表为例。假设现在有三个事务A,B,C,其语句时间顺序如下:

首先,需要注意事务启动时机:begin/start transaction并不会直接启动事务,而是执行到它们之后的第一个操作InnoDB表的语句,才会真正启动事务。如果想要马上启动一个事务,可以使用start transaction with consistent snapshot,就像事务A和事务B那样。而对于事务C没有显式使用语句,表示更新语句本身就是一个事务,会在语句完成时自动提交。

上面这个例子就是我们要考虑的问题的一个场景,事务B先启动了事务,而想要更新的行先被事务C更新,之后事务B自己更新并查询;事务A在事务B之后查询同一行。那么事务A和事务B查询结果是多少呢?

答案是:事务A得到的结果是k=1,事务B得到的结果是k=3

如果答案和你想的不一样,那么可以继续往下读,相信最后能解答疑惑。

快照在MVCC里如何工作

在可重复读的隔离级别下,事务在启动时就会有一个快照,这个快照是基于整个库的。

接下来首先来看快照如何实现:

InnoDB里每个事务都有一个唯一的事务ID称为transaction id,该ID在事务开始时向InnoDB的事务系统申请,按照申请顺序严格递增

而每行数据有多个版本,每次有事务更新数据,都会生成一个新的数据版本,并且把事务的transaction id赋值给这个数据版本,记为row trx_id。同时,旧的数据版本依然会被保留,且可以在新数据版本中通过一定方法获取到旧数据版本。下图表示了一个记录被多个事务更新的过程:

图中,下方的矩形就代表了不同的数据版本。而\(U_i\)实际就代表了undo log。只要获取了最新的数据版本和undo log,就能回滚出历史数据版本。

为了达到可重复读的定义,实际上是在一个事务启动的时候,允许其看见它自己创建的以及在它启动前已经生成的数据版本,而不允许看见在它启动时还未生成的数据版本

在实现上,InnoDB为每个事务构造一个数组,用来保存在这个事务启动瞬间,当前正在活跃的事务ID。这里,活跃指的是启动但未提交的事务。同时,还会记录数组里面事务ID的最小值,以及当前系统里已经创建过的事务ID的最大值+1。

数组+最小值+(最大值+1)+当前事务ID,实际上就组成了当前事务的一致性视图read view。

而数据版本是否可见,就是基于read view和数据版本的row trx_id。read view的数组和字段会把row trx_id分为几种情况:

对于当前启动的事务,一个数据版本的row trx_id,有如下可能:

  • 落在绿色部分,表示该版本在当前事务启动前已提交或是自己创建的,可见;

  • 落在红色部分,表示该版本不是由所有已创建出来的事务启动的,不可见;

  • 落在黄色部分

    • 若row trx_id在数组中,表示是活跃事务生成的,还未提交,不可见;

    • 若row trx_id不在数组中,表示是已经提交的事务生成的,可见。

所以,由于所有数据都有多个版本,每个创建的事务都有对应的快照。

接下来分析"场景引入"里事务A的查询结果为什么是k=1

这里先做几个假设。假设事务A开始前,系统里只有一个活跃事务ID为99,事务A,B,C的ID为100,101,102且当前系统只有四个事务;在三个事务启动前,(1,1)这一行的数据的row trx_id为90。

根据该假设,事务A的read view中的数组为[99,100],事务B的read view中的数组为[99,100,101],事务C的read view中的数组为[99,100,101,102]。

我们分析事务A相关的操作:

可以发现,尽管在事务A做查询时,数据已经改为了(1,3),但由于该版本的row trx_id=101,不存在于事务A的read view数组中,因此该版本对事务A不可见。事务A查询语句的流程应该是:

  • 找到(1,3),发现不可见;

  • 找到上一个版本(1,2),发现不可见;

  • 继续向前,找到(1,1),是一个可见的数据版本。

通过以上分析,相信你已经理解为什么事务A的查询结果是k=1了。但若每次分析都像这样,未免有些麻烦,因此我们给出总结:一个数据版本,对于一个事务视图来说,除了自己的更新总是可见,有三种情况:

  • 版本未提交,不可见;

  • 版本已提交,但是是在read view创建后提交的,不可见;

  • 版本已提交,而且是在read view创建前提交的,可见。

以上总结对比前面与row trx_id比较分析的方法,其实就是去掉了数字的对比,只用时间先后顺序判断。

更新逻辑

分析事务B相关的操作:

可以发现,如果我们像分析事务A那样去分析事务B,会认为事务B看不到(1,2)这个数据版本。

这个问题出在混淆了**"快照读"** 和**"当前读"**。当前读指的是读最新版本的数据。由于更新数据都是先读后写,它用到的是当前读而不再是快照读。

知道了这个规则后,就比较好理解答案了,事务B在更新时能拿到数据(1,2),从而更新后生成了一个新的数据版本(1,3),且该版本的row trx_id为事务B的ID 101。那么之后事务B在查询时,能查到由自己更新的数据版本,得到结果为k=3

当前读除了在update语句上会生效,如果使用select ... lock in share mode / for update,也是当前读。因此,如果对事务A的查询语句加锁,它也能查询出k=3

假设事务C不是马上提交的,而是变成了下面这样:

此时,就需要考虑上一篇文章介绍的"两阶段锁协议"。由于事务C' 没有提交,其在id=1这一行上加的写锁并不会释放。而事务B是当前读,必须加锁读最新版本,因此会被锁住,直到事务C' 释放这个行锁。

从可重复读到读已提交

到这里,我们可以归纳事务的可重复读的能力是如何实现的:核心是read view,而事务更新数据的时候,只能用当前读,如果读取行的行锁被其他事务占用,就需要进入锁等待。

可重复读和读已提交的区别:

  • 读已提交隔离级别下,每个语句执行前都会生成一个read view。

  • 可重复读隔离级别下,只在事务开始时创建read view。

最后我们再来分析一下在读已提交的隔离级别下,一开始的场景中事务A和事务B的读取结果。画出状态图:

对于事务B,答案依然是k=3

对于事务A,其创建read view时已经能看到(1,2)的版本,但由于事务B还未提交,事务A并不能看到(1,3)的版本,因此事务A查询结果为k=2

相关推荐
星辰离彬3 小时前
Java 与 MySQL 性能优化:Java应用中MySQL慢SQL诊断与优化实战
java·后端·sql·mysql·性能优化
程序猿小D5 小时前
[附源码+数据库+毕业论文]基于Spring+MyBatis+MySQL+Maven+jsp实现的个人财务管理系统,推荐!
java·数据库·mysql·spring·毕业论文·ssm框架·个人财务管理系统
发仔12310 小时前
Oracle与MySQL核心差异对比
mysql·oracle
wkj00113 小时前
navicate如何设置数据库引擎
数据库·mysql
ladymorgana13 小时前
【Spring Boot】HikariCP 连接池 YAML 配置详解
spring boot·后端·mysql·连接池·hikaricp
kk在加油16 小时前
Mysql锁机制与优化实践以及MVCC底层原理剖析
数据库·sql·mysql
合作小小程序员小小店16 小时前
web网页开发,在线%ctf管理%系统,基于html,css,webform,asp.net mvc, sqlserver, mysql
mysql·sqlserver·性能优化·asp.net·mvc
JosieBook16 小时前
【Java编程动手学】Java常用工具类
java·python·mysql
hello 早上好16 小时前
MsSql 其他(2)
数据库·mysql