当前位置 博文首页 > JavaEdge全是干货的技术号:MySQL是怎么实现事务隔离的?

    JavaEdge全是干货的技术号:MySQL是怎么实现事务隔离的?

    作者:[db:作者] 时间:2021-07-21 09:38

    一个事务要更新一行,如果刚好有另外一个事务拥有这一行的行锁,它会被锁住。既然进入等待状态,那么等到这个事务自己获取到行锁要更新数据时,它读到的值又是什么呢?

    • 初始化
    • 事务A、B、C的执行流程

    何时启动事务?

    • begin/start transaction
      在执行到它们之后的第一个操作InnoDB表的语句,事务才真正启动。一致性视图是在执行第一个快照读语句时创建的。
    • start transaction with consistent snapshot
      若你想要马上启动一个事务,可以使用。一致性视图是在执行start transaction with consistent snapshot时创建的。

    若无特殊说明,默认autocommit=1

    该案例中:

    • 事务C没有显式使用begin/commit,表示该update语句本身就是个事务,语句完成时会自动提交
    • 事务B在更新了行之后,查询
    • 事务A在一个只读事务中查询,并且时间上是在事务B的查询后

    视图

    MySQL有两个“视图”概念:

    • view
      一个用查询语句定义的虚拟表,在调用时,执行查询语句并生成结果。创建视图的语法是create view … ,而它的查询方法与表一样
    • InnoDB在实现MVCC时用到的一致性读视图,即consistent read view
      用于支持读提交可重复读。它没有物理结构,事务执行期间用来定义“我能看到什么数据”。

    “快照”在MVCC里是怎么工作的?

    在可重复读下,事务启动时就“拍了个快照”。

    • 该快照是基于整库的。
      若一个库有100G,则启动一个事务,MySQL就要拷贝100G的数据出来,这得多慢啊。实际上,并不需要拷贝出这100G数据。

    先看看快照的实现。
    InnoDB的每个事务有个唯一事务ID:transaction id,在事务开始时向InnoDB事务系统申请的,按申请顺序严格递增。

    每行数据也都有多个版本

    每次事务更新数据时,都会生成一个新的数据版本,并把transaction id赋给该数据版本事务ID,记为row trx_id。同时,旧数据版本要保留,并且在新数据版本中,能够有办法可以直接拿到它。

    也就是说,数据表中的一行记录,其实可能有多个版本(row),每个版本有自己的row trx_id。

    如图所示,就是一个记录被多个事务连续更新后的状态。

    • 行状态变更图
      TODO

    虚线框里是同一行数据的4个版本,当前最新版本是V4,k=22,它是被transaction id=25的事务更新,因此它的row trx_id=25。

    • 语句更新会生成undo log(回滚日志),在哪呢?
      三个虚线箭头,就是undo log。V1、V2、V3并非物理上真实存在,而是每次需要时,根据当前版本和undo log计算而得。比如,需要V2时,就通过V4依次执行U3、U2计得。

    那InnoDB如何定义那个“100G”快照?
    按可重复读定义,一个事务启动时,能够看到所有已提交的事务结果。但之后,该事务执行期间,其他事务的更新对它不可见。

    因此,一个事务只需在启动时说,以我启动时刻为准:

    • 若一个数据版本是在我启动前生成,就认
    • 启动后才生成,我不认,我必须要找到它的上一个版本。若上个版本也不可见,就继续往前找。若是该事务自己更新的数据,它自己还是要认的。

    视图数组

    InnoDB为每个事务构造了一个数组,以保存该事务启动瞬间,当前正“活跃”(启动了,但尚未提交)的所有事务ID。

    在该数组里:

    • 事务ID的最小值,记为低水位
    • 当前系统里已创建过的事务ID的最大值加1,记为高水位

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

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

    该视图数组把所有row trx_id 分成:

    • 数据版本可见性规则

      对于当前事务的启动瞬间,一个数据版本的row trx_id,有如下可能:
    1. 若落在绿色,表示该版本是已提交的事务或当前事务自己生成的,这个数据是可见的
    2. 若落在土色,表示该版本是由将来启动的事务生成的,肯定不可见
    3. 若落在黄色,包括两种情况:
      a. 若 row trx_id在数组中,表示该版本是由尚未提交的事务生成的,不可见
      b. 若 row trx_id不在数组中,表示该版本是已提交的事务生成的,可见

    比如,对于【行状态变更图】的数据,若有一个事务,它的低水位是18,则当它访问这一行数据时,就会从V4通过U3计算出V3,所以在它看来,这一行值是11。

    有了该声明后,系统里随后发生的更新,就跟该事务看到的内容无关了。因为之后的更新,生成的版本一定属于上面的2或者3(a),而对它来说,这些新的数据版本是不存在的,所以这个事务的快照,就是“静态”的了。

    所以InnoDB利用了“所有数据都有多版本”的特性,实现了“秒级创建快照”能力。

    接下来,我们开始分析一开始的三个事务

    事务案例分析

    假设:

    • 事务A开始前,系统里只有一个活跃事务ID=99
    • 事务A、B、C版本号分别是100、101、102,且当前系统里只有这四个事务
    • 三个事务开始前,(1,1)这一行数据的row trx_id是90

    于是:

    • 事务A的视图数组[99,100]
    • 事务B的视图数组是[99,100,101]
    • 事务C的视图数组是[99,100,101,102]

    为简化分析,先把其他干扰语句去掉,只画出跟事务A查询逻辑有关的操作:

    • 事务A查询数据逻辑图
      TODO

    • 第一个有效更新是事务C,(1,1)=》(1,2)。这时,该数据的最新版本的row trx_id=102,版本90已成为历史版本

    • 第二个有效更新是事务B,(1,2)=》(1,3)。这时,该数据的最新版本(即row trx_id)=101,版本102成为历史版本

    在事务A查询时,事务B还没有提交,但它生成的(1,3)这个版本已经变成当前版本。但这个版本对事务A必须是不可见的,否则就变成脏读了。

    现在事务A要来读数据了,它的视图数组是[99,100]。读数据都是从当前版本读起的。所以,事务A查询语句的读数据流程是这样的:

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

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

    一个数据版本,对于一个事务视图来说,除了自己的更新总是可见之外,还有如下情况:

    1. 版本未提交,不可见
    2. 版本已提交,但是是在视图创建后提交的,不可见
    3. 版本已提交,而且是在视图创建前提交的,可见。

    现在,让我们用这些规则判断查询结果,事务A的查询语句的视图数组是在事务A启动时生成的,这时:

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

    现在只需通过时间先后分析即可。

    更新逻辑

    事务B的update语句,若按一致性读,好像结果不对呢?

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

    • 事务B更新逻辑图
      TODO

    若事务B在更新前查询一次数据,该查询返回的k的值确实是1。
    但当它要去更新数据时,就不能再在历史版本上更新了,否则事务C的更新就丢失了。因此,事务B此时的set k=k+1是在(1,2)的基础上进行的操作。

    所以,这里用到规则:更新数据都是先读后写。这个读,只能读当前的值,称为“当前读”(current read)。

    因此,在更新时,当前读拿到的数据是(1,2),更新后生成了新版本数据(1,3),这个新版本的row trx_id是101。

    所以,在执行事务B查询语句时,一看自己的版本号是101,最新数据的版本号也是101,是自己的更新,可以直接使用,所以查询得到的k的值是3。

    当前读(current read)

    除了update语句外,select语句若加锁,也是当前读。

    所以,若修改事务A的查询语句

    select * from t where id=1
    

    加上lock in share mode 或 for update,都可读到版本号是101的数据,返回的k的值是3。

    // 加了读锁(S锁,共享锁)
    mysql> select k from t where id=1 lock in share mode;
    // 写锁(X锁,排他锁)
    mysql> select k from t where id=1 for update;
    

    假设事务C不是马上提交的,而是变成了下面的事务C’,会怎么样呢?

    • 事务A、B、C’的执行流程

    事务C’不同在于更新后并没有马上提交,在它提交前,事务B的更新语句先发起了。前面说过了,虽然事务C’还没提交,但(1,2)这个版本也已经生成了,并且是当前的最新版本。
    那事务B的更新语句会怎么处理呢?
    “两阶段锁协议”。事务C’没提交,即(1,2)这个版本上的写锁还没释放。而事务B是当前读,必须要读最新版本,而且必须加锁,因此就被锁住了,必须等到事务C’释放这个锁,才能继续它的当前读。

    • 事务B更新逻辑图(配合事务C’)
      TODO

    至此,一致性读、当前读和行锁就串起来了。

    事务如何实现可重复读?

    • 可重复读的核心就是一致性读(consistent read)
    • 而事务更新数据时,只能用当前读

    若当前的记录的行锁被其他事务占用,就需要进入锁等待。

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

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

    那在读提交隔离级别下,事务A和事务B的查询语句查到的k,分别应该是多少呢?

    start transaction with consistent snapshot; 
    

    的意思是从这个语句开始,创建一个持续整个事务的一致性快照。所以,在读提交隔离级别下,这个用法就没意义了,等效于普通的start transaction。

    下面是读提交时的状态图,可以看到这两个查询语句的创建视图数组的时机发生了变化,就是图中的read view框。(注意:这里,我们用的还是事务C的逻辑直接提交,而不是事务C’)

    图8 读提交隔离级别下的事务状态图
    这时,事务A的查询语句的视图数组是在执行这个语句的时候创建的,时序上(1,2)、(1,3)的生成时间都在创建这个视图数组的时刻之前。但是,在这个时刻:

    (1,3)还没提交,属于情况1,不可见;
    (1,2)提交了,属于情况3,可见。
    所以,这时候事务A查询语句返回的是k=2。

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

    总结

    InnoDB的行数据有多个版本,每个数据版本有自己的row trx_id,每个事务或者语句有自己的一致性视图。普通查询语句是一致性读,一致性读会根据row trx_id一致性视图确定数据版本的可见性。

    • 对于可重复读
      查询只承认在事务启动前就已经提交完成的数据
    • 对于读提交
      查询只承认在语句启动前就已经提交完成的数据
    • 当前读
      总是读取已经提交完成的最新版本

    为什么表结构不支持“可重复读”?

    因为表结构没有对应的行数据,也没有row trx_id,因此只能遵循当前读的逻辑。

    当然,MySQL 8.0已经可以把表结构放在InnoDB字典里了,也许以后会支持表结构的可重复读。

    cs