mysql指引(十二):innodb的MVCC实现与锁构成

首先看官方对于事务模型是怎么个说法,一句有哲理的话:

you can adjust MySQL settings to trade some of the ACID reliability for greater performance or throughput

事务ACID的可靠性和性能之间是需要权衡的,当然mysql支持调整往哪一边倾斜。下面是四个特性和 mysql(innodb)相关功能的联系。

对于原子性,根据官方描述,特性相关的主要为:autocommit、commit、rollback等语句实现。

对于一致性,主要是:innodb 的 doublewrite buffer 和 宕机恢复处理涉及到的东西。这些我们已经在mysql指引(十一):innodb基本结构和执行逻辑拆解 中做了说明。

对于隔离性,主要是:autocommit、隔离等级语句以及 锁 的底层实现。

对于持久性,主要是:Mysql 软件和硬件的共同作用。对于硬件来说,比如UPS电源等。


那么,MVCC呢?再看官方经典:

InnoDB is a multi-versioned storage engine: it keeps information about old versions of changed rows, to support transactional features such as concurrency and rollback.

就是保留了行的旧版本信息,来支持事务的并发和回滚。

MVCC 多版本并发控制,拆解为:

  • MV多版本
  • CC并发控制

其中,多版本是利用 undo log 实现的,指的是某行记录的多个版本快照,既然有了快照也表示支持了回滚。

而并发控制则顾名思义,是需要依靠锁处理的。

在事务隔离等级中,默认都是可重复读(Repeatable Read)隔离等级(文中也针对RR隔离级别讲述),在该等级下,对于增删改查四种操作,如下:

普通查询操作

select xxx from table xxx,最常见的查询。

利用MV多版本解决,即基于 undo 的记录历史快照实现,所以又叫做 快照读

增删改操作

增删改操作,肯定是对当前最新版本的记录执行操作。不可能对历史版本记录做操作,否则就乱套了。比如 update xxx,是对最新的记录执行修改,既然是修改,那么必须 “读” 到最新的记录,所以这种读取最新版本的操作,叫做 当前读

特殊查询语句

即两种:

  • select xxx lock in share mode
  • select xxx for update

这两种同样是对当前最新版本的记录执行操作,也即 当前读


不同于快照读,当前读的实现是基于 实现的。本篇暂时只对锁简述下,下篇着重谈及。

锁这种机制,从使用者心态来说,分为两种思想:乐观和悲观。

悲观锁总是对资源加锁,以阻塞其他的并发访问。而乐观锁不对资源加锁,只是在更新时检查资源是否被修改,如果被修改只能回滚操作。

而 MVCC 则是基于 乐观锁 思想基础来实现的,乐观锁的常见实现方式就是版本号。

在这种思想下,我们再回顾下记录的行格式,如下:

mysql指引(十二):innodb的MVCC实现与锁构成

每行记录中,transactionId表示事务ID,也可以理解为记录的版本号。roll pointer 就是行指针,指向上一个版本的行记录信息,即指向对应的undo log。

再继续往下之前,必须知道一点,即:

任何操作都是一个事务或者是被包含在一个事务中,每个事务都有自己的ID,即事务ID

所以,简单的一句 select xxx 也同样是一个事务,同样有一个事务ID。当一个行记录被创建时,它的事务ID就是创建这行的事务的事务ID。当一个行记录被修改时,修改后的行记录,它的事务ID就是这个修改事务的事务ID。

而事务ID是全局递递增的。

tips:事务ID递增,那会不会有一天出现溢出呢?

会的,溢出后自然变为0,又从0开始递增,所以会出现脏读的bug。但是这么大的数,n年才会出现。


现在,假设事务A启动,那么当他执行快照读操作的时候,到底能看到哪些版本的记录呢?

这里涉及到一个 Read View 概念,事务执行过程中会生成 Read View,以数组的形式将生成瞬间系统中活跃的事务ID存储起来,比如为 [11,22,33,44]。

当快照读执行时,就会根据记录的版本链,依次寻找符合的版本,规则为:

  • 小于数组中的最小事务ID的快照版本,是可见版本
  • 大于数组中的最大事务ID的快照版本,是不可见版本
  • 介于二者之间的,若某版本的事务ID在数组中,则不可见。属于活跃事务未提交的情况。若不在数组中,则可见。因为不在数组中,意味着该 Read View 创建时,那个事务已经提交了。

第三点中,红字部分再解释一下,肯定会有疑惑。即事务A执行的过程中,为什么快照读可以看到比A的事务ID还要大的版本记录呢?

这就关系到 Read View 的生成时机,目前只需要知道一个常见的时机即可:

  • 在RR隔离级别下,Read View 是在第一个快照读操作时生成
  • 在RC隔离级别下,Read View 是在每个快照读操作时生成

正因为不是在事务启动的时候就生成(当然,用 start transaciton with consistent snapshot 这条语句启动事务除外,该语句表示事务启动的时候就生成 Read View),故可能存在的情况是:

事务A的事务ID为10,事务B在事务A之后创建,事务ID为15。事务A启动后先执行了其他操作,此时事务B修改了事务A要查询的记录后完成提交。则事务A第一次执行到快照读语句时,此时生成的 Read View 就符合第三种情况的红字描述。

下图表示了上面的一系列过程:

mysql指引(十二):innodb的MVCC实现与锁构成

事务97、101、106中的Read View生成时机都是第一次执行快照读操作时生成。

  • 事务97快照读的结果为事务97的修改结果,即读取的是前一步update后的数据;

  • 事务101的Read View中包含了97,是Read View生成时,事务97还没提交。所以快照读看到的是事务95的版本

  • 事务106的Read View 中未包含事务110,这是因为事务106的Read View生成时,虽然事务110晚于事务106启动,但是在106的Read View生成之前就已经提交了,所以事务106的快照读看到的是事务110的版本。


快照读说完了,再来看下当前读

当前读主要和锁有关,简单引出锁相关的概念。下一篇文章中我们再详细聊聊innodb的锁。

当前读是操作最新版本的记录 ,即该记录为共享资源,针对共享资源,自然是对该资源执行修改的时候可能会发生冲突。此时,innodb对该记录加的锁为 行锁

即事务A执行当前读操作时,会对记录a加行锁;此时事务B同样对记录a执行当前读操作,则会被阻塞。

上面说过,当前读中除了增删改操作,还存在两种特殊的select操作,他们使用的也是 当前读 。假如事务A中执行两次 select xxx for update,在两次执行过程中,事务B插入了一行记录,而该记录符合事务A的查询条件。则对于事务A来说,两次查询的结果不一致,读到了原本不存的记录行。这就引出了幻读 这个问题。

幻读后续在分布式mysql中还会再次提到,我们先看下innodb在RR隔离级别下是如何解决幻读问题的。

幻读问题的解决在于引入了 gap锁,即 间隙锁。锁住的是记录与记录之间的间隙。比如对id=5的记录加间隙锁,此时id5之前的行记录为id2,则相当于锁住了id区间 (2,5]。即此时其他事务新增id4时,就会被阻塞。

那么对最后一条记录(假如为id11)加间隙锁,锁住的区间是什么呢?是(11, ?],即问号这里应该是什么呢?

答案就是mysql指引(八):innodb页结构 中提到的 Supremum。Supremum 就是该数据页中默认的最后一个数据行。


最后,间隙锁 和 行锁 合并起来就是 Next-Key锁,不再赘述。

更多关于锁的系统知识和细节,我们在下篇文章中进行讲述。