MySQL多版本并发控制原理(MVCC)

在数据库系统中,事务是指由一系列数据库操作组成的一个完整的逻辑过程,事务的基本特性是ACID:

A : Atomicity (原子性)

C: Consistency (一致性)

I: Isolation (隔离性)

D: 持久性(Durability)

由于大部分数据库都是高并行发的,即允许多个事务同时对数据进行读写和修改,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。不同的隔离级别代表了数据库在性能和可靠性之间的取舍。那么MySQL是如何保证事务基本特性ACID中的I(Isolation 隔离性)?答案就是InnoDB的多版本并发控制(Multi-Version Concurrency Control MVCC)。

目录

一、事务隔离级别简介

1.1 读未提交(read uncommitted)

1.2 读已提交(read committed)

1.3 可重复读(repeatable read)

1.4 串行化(Serializable)

二、MVCC的概念

三、MVCC实现原理

3.1 undo日志

3.2 版本链

3.3 Readview(读视图)

3.4 ReadView判断示例

3.5 不同隔离级别下ReadView的生成策略


一、事务隔离级别简介

MVCC主要作用就是实现事务的隔离级别,这里先解释一下事务隔离级别及各中隔离级别存在的问题。

SQL标准规定了4种事务隔离级别,分别是:

  • 读未提交(read ncommitted)
  • 读已提交(read committed)
  • 可重复读(repeatable read)
  • 串行化(serializable)

1.1 读未提交(read uncommitted)

会话发起的select语句可以读取其他会话事务还"未提交"的数据,这种现象我们称为"脏读",脏读会带来很多问题,一般不会采用这种隔离级别。

1.2 读已提交(read committed)

会话发起的select语句可以读取其他会话事务还"已提交"的数据,这种情况读取的数据都是其他会话已经提交的,通常不会有什么大问题,Oracle数据库的默认事务隔离级别就是 read committed。

Read Committed隔离级别会导致两个现象:

  • 不可重复读:在同一个事务运行过程中,前后2个相同的select查询之间,其他会话发起事务"修改了"数据并且提交,这就导致了同一个事务中,相同的select两次查询结果不一样,这种现象即"不可重复读"。
  • 幻读:在同一个事务运行过程中,前后2个相同的select查询之间,其他会话发起事务新增了数据(满足前面select条件)并且提交,这就导致了同一事务中,相同的select两次查询结果记录数不一样(第二次查到了会话新插入的记录),这种现象即"幻读"。

不可重复读与幻读的主要区别在于,不可重复读是数据本身的变化,而幻读是记录数量的变化。

1.3 可重复读(repeatable read)

这个是MySQL InnoDB存储引擎的默认隔离级别,在读已提交的隔离级别基础上解决了"不可重复读"问题,但是依然会有"幻读"的问题。

1.4 串行化(Serializable)

所有事务按照串行的方式运行,避免了并行访问数据,由于效率极低,生产环境一般不会使用这种隔离级别。

最常见到的就是"读已提交"和"可重复读"两个隔离级别,另外两个隔离级由于太极端基本不会使用。

四种隔离级别和脏读、不可重复读、幻读的关系总结如下:

脏读

不可重复读

幻读

读未提交(Read uncommitted)

读已提交(read committed)

可重复读(repeatable read)

串行化(Serializable)

注:表中标红的部分特别解释一下,MySQL的默认隔离级别repeatable read,在标准的ANSI隔离级别定义中, repeatable read解决了"不可重复读"的问题,但是是允许"幻读"的。但是MySQL的实现略有不同,在MySQL的repeatable read隔离级别中,通过间隙锁的方式"基本"解决了幻读问题(并没有完全解决,某些情况下还会出现幻读)。

二、MVCC的概念

多版本并发控制(Multi-Version Concurrency Control MVCC)的主要作用是解决读和写的冲突,实现事务隔离,每次修改记录时都会存储这条记录被修改之前的版本,修改之前的内容会记录在undo日志中。如果此时有用户要读取改记录,就可以通过undo日志重构修改前的数据,从而保证读一致性(undo日志还可以用来回滚事务)。

而多次修改的版本之间串联起来就形成了一条版本链,可以保证不同时刻启动的事务可以获得不同版本的数据(MVCC根据控制事务可以看到的数据版本实现了"读已提交"和"可重复读"两个隔离级别)。

三、MVCC实现原理

MySQL在我们插入数据时,实际为每行数据新增了3个隐藏字段:

  • db_trx_id,事务ID,用来记录插入或最后更新该行数据的事务ID。
  • db_roll_pointer,undo日志记录指针,用来指向undo日志中变更记录,通过变更记录可以重构update前的数据。
  • db_row_id,行编号,单调递增的序列,当你没有显式指定主键时,MySQL会利用此字段生成隐藏的主键。

在MVCC的实现中,主要用到db_trx_id和db_roll_pointer两个隐藏字段。

3.1 undo日志

undo log,即回滚日志,它记录了数据更新前的信息,可以用来重构更新前的数据,也是MVCC版本链实现的基础。除了通过MVCC实现一致性读,undo log还会用来回滚事务。

undo log分为两类:

  • insert undo log,只有当事务回滚时才会用到,只要事务提交,立刻就可以丢弃了。
  • update undo log(这里的update包含delete),除了事务回滚,还会被用来保障一致性读,因此只有当没有一致性读需求时才会丢弃。

对于update undo log,如果事务长时间不提交。为了维持版本链,那么undo日志就无法释放,可能导致逐渐累积过大。这就是为什么事务仅仅查询也会影响性能的原因。因此推荐定期的提交事务,释放相关资源。

3.2 版本链

MySQL在更新记录时,每次都在undo日志中记录下更新前的原记录,并通过db_roll_pointer连接起来,当多次更新后,便形成了一个版本链,假设下列场景:

对于一张表MVCC_Chain,有id和value两个字段:

  • 1:00 时执行 insert into mvcc_chain values(1, 'AAA'); commit; 事务ID为1
  • 2:00 时执行 update mvcc_chain set value='BBB' where id=1; commit; 事务ID为5
  • 3:00 时执行 update mvcc_chain set value='CCC' where id=1; commit; 事务ID为10
  • 4:00 时执行 update mvcc_chain set value='DDD' where id=1;  事务ID为15 -- 当前事务,还未提交

事务 ID 是递增分配的,越晚申请的事务ID越大,中间的事务ID假设被其他事务占用了。

那么形成的版本链应该如下:

MySQL多版本并发控制原理(MVCC)_第1张图片

版本链中共存在4个版本的数据(通过db_roll_pointer串联起来,每次更新都指向上一个版本的数据):

  • 绿色行代表当前存在缓冲区(buffer_pool)中并已经被修改且还未提交的数据。
  • 灰色行代表版本链中通过undo日志重构的历史数据版本。

每行前面的时间代表SQL执行时间,只是为了方便理解。

3.3 Readview(读视图)

有了版本链,MySQL执行select语句时如何判断应该读取链中的哪条数据呢?答案是ReadView,ReadView包含了select语句发起时事务的统计信息,通过这些信息结合版本链中的trx_id,就可以判断该select可以读取哪个版本的数据。

ReadView只有select语句才会生成,其主要包含以下4类信息:

  • creator_trx_id,发起select语句所属事务ID。
  • m_ids,生成ReadView时数据库中活跃的事务ID集合,也就是当前活动中且还未提交的事务ID列表。
  • min_trx_id,生成ReadView时活跃事务ID之中的最小值,即min(m_ids),小于该值的都是已提交事务,而大于则可能提交,也可能未提交,需要结合m_ids判断。
  • max_trx_id,即将分配给下一个事务的 ID 的值,最大事务ID+1

生成ReadView后,会从最新的数据版本开始,通过trx_id,判断数据是否可见,判断步骤如下:

  1. 如果前数据版本的 trx_id = creator_trx_id 说明修改这条数据的事务就是当前事务,数据可见,否则继续判断。
  2. 如果当前数据版本的 trx_id < min_trx_id,说明修改这条数据的事务在当前事务生成 readView 的时候已提交,数据可见,否则继续判断。
  3. 如果当前数据版本的 trx_id >= max_trx_id,说明修改这条数据的事务在当前事务生成 readView 的时候还未启动,数据不可见,继续判断。
  4. 如果当前数据版本的 min_trx_id<= trx_id < max_trx_id,此时 trx_id 若在 m_ids 中(注意这个m_ids的值是离散的,事务只要提交了就不在里面),说明修改这条数据的事务是活跃事务,数据不可见,如果不在m_ids中,则说明事务已提交,数据可见。

如果经过上面4步判断,数据依然不可见,则通过版本链向上追溯一个版本,继续上面4步判断,直到找到可见的数据为止。

可以总结出,ReadView实际上找两类数据:

  • trx_id = creator_trx_id,自己事务更新的数据。
  • trx_d 不在m_ids中的数据(也要小于max_trx_id),即ReadView生成前已提交的数据。

3.4 ReadView判断示例

场景1:假设现在为4:01,我执行了查询select value from mvcc_chain where id=1; 查询事务ID为15,当前数据库中活跃事务为14,15,17(16号事务已提交,所以不在m_ids中),此时生成的ReadView如下,

  • 第1步,当前版本数据的trx_id为15,ReadView的creator_trx_id也是15,两者相等,说明4:00这条update由我自己更新的(同一事务),因此可以直接返回的value值"DDD",判断终止。

场景2:假设现在为4:01,我执行select value from mvcc_chain where id=1; 事务ID为17,生成的ReadView如下:

  • 第1步,当前版本数据的trx_id为15,ReadView的creator_trx_id是17,不是同一个事务,需要继续判断。
  • 第2步,当前版本数据的trx_id为15,trx_id(15)
  • 第3步,当前版本数据的trx_id为15,trx_id(15)>=max_trx_id(18)不满足,那么事务ID肯定在区间[min_trx_id, max_trx_id)中,继续第4步判断。
  • 第4步,当前版本数据的trx_id为15,m_ids为(14,15,17),trx_id in m_ids满足,说明是活跃事务,数据不可见(这里如果trx_id是16就可见了)。

经过上面4步判断,发现trx_id为15的数据对当前会话不可见,那么只能向上追溯一个版本(3:00更新的版本,value值为"CCC",trx_Id为10),继续4步判断:

  • 第5步,当前版本数据的trx_id为10,ReadView的creator_trx_id也是17,数据不可见。
  • 第6步,当前版本数据的trx_id为10,trx_id(10)

3.5 不同隔离级别下ReadView的生成策略

场景3:假设现在为1:30(数据在1:00插入,且还没有任何更新),我执行了查询select value from mvcc_chain where id=1,查询事务ID为3,生成的ReadView如下:

  • 第1步,当前版本数据的trx_id为1(insert的事务ID),ReadView的creator_trx_id是3,说明不是一个事务,数据不可见。
  • 第2步,当前版本数据的trx_id为1,trx_id(1)

假设这个事务3一直没提交,到了4:01又执行了查询select value from mvcc_chain where id=1,此时如果重新生成ReadView(ReadView和场景2基本相同,只是这次加入了1:30开始的一个事务3):

  • 第1步,当前版本数据的trx_id为15,ReadView的creator_trx_id是3,说明不是同一个事务,继续判断。
  • 第2步,当前版本数据的trx_id为15,trx_id(15)
  • 第3步,当前版本数据的trx_id为15,trx_id(15)>=max_trx_id(18)不满足,那么事务ID肯定在区间[min_trx_id, max_trx_id)中,继续第4步判断。
  • 第4步,当前版本数据的trx_id为15,m_ids为(3,14,15,17),trx_id in m_ids满足,说明是活跃事务,数据不可见。

上面4步判断和场景2相同,trx_id为15的数据对当前会话不可见,那么只能向上追溯一个版本(3:00更新的版本,value值为"CCC",trx_Id为10),继续4步判断:

  • 第5步,当前版本数据的trx_id为10,ReadView的creator_trx_id也是3,数据不可见。
  • 第6步,当前版本数据的trx_id为10,trx_id(10)
  • 第7步,当前版本数据的trx_id为10,trx_id(10)>=max_trx_id(18)不满足,继续判断。
  • 第8步,当前版本数据的trx_id为10,min_trx_id(3)<=trx_id(10)

同一个事务3,在1:30和4:01发出相同的查询,一次返回了"AAA",一次返回了"CCC"(不可重复读现象),其原因就是在4:01的查询时重新生成了ReadView

如果4:01的查询依然拿1:30生成的ReadView来判断,那么它能看到的数据依然是"AAA"。虽然此时数据"BBB"(trx_id=5)和"CCC"(trx_id=10)已提交,但是对于第3步条件trx_id(5/10)>=max_trx_id(5),说明它们是在ReadView生成(1:30)后发起的事务(分别是2:00和3:00发起),数据不可见。

在这个场景中,虽然"BBB"和"CCC" 没有查询能查到,但是为了重构"AAA"的数据,它们也是要一直保存在版本链中的,如果事务3一直挂着不提交(MySQL对事务执行时长没有限制),那么会导致版本链越来越长,undo log占用越来越大。这就是为什么需要定期提交事务的原因。

这两种ReadView生成策略即代表了MySQL的两种隔离级别:

  • 读已提交(Read Committed),每次查询都会生成新的ReadView,因此事务3可以读到后面已提交的数据。
  • 可重复读(Repeatable Read),只有第一次查询生成ReadView,后续查询依然使用此ReadView,因此事务3读不到后面提交的数据。

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