MySQL: 对底层各类锁机制和MVCC中ReadView的基本学习

概述

在传统的计算资源里,如CPU、RAM、I/O的争用需要一系列的并发控制操作外,数据库中为了保证数据的一致性也不可避免的需要对并发操作进行控制,也就产生了锁。

之所以要去了解MySQL中的锁,我想有两点,其一是了解锁机制是如何对各个隔离级别提供的保证,以此更深入的了解隔离性,其二是锁的冲突是影响数据库并发性能的一大重要因素。

面临的问题

通过以往的学习可以得知,事务是MySQL中的基本执行单位。那么显然,对于MySQL中的并发冲突也均是围绕着事务之间产生的。一般有三种常见问题,数据库系统对其应对策略进而抽象为等级,即事务隔离级别,而每个级别代表着它能否解决某个问题

三种事务冲突问题如下:

  1. 脏读,一个事务读取到另一个未提交事务的记录。主要对那些更改的记录加锁就可以解决。
  2. 不可重复读,当前事务读取的数据被另外一个事务改动并提交,当该事务再次读取的时候发现两次的数据不一致。同上,对数据加锁就可以解决。
  3. 幻读,事务读取了一个范围的记录,而另外的事务在范围中插入了新记录并提交,当该事务再次读取这个范围的记录时就会发现新插入的记录。将第一次执行select获取的记录进行存储,其后相同语句均采用这个记录。或者直接对访问的范围进行加锁,不允许其他事务写入。

数据库系统进而抽象出的隔离级别等级如下:

  1. 读未提交 READ UNCOMITTED,三种问题都不能解决
  2. 读已提交 READ COMMITTED,能够解决脏读问题
  3. 可重复读 REPEATABLE READ, 能够解决脏读和不可重复读问题
  4. 串行化 SERIALIZATION,能够解决脏读和不可重复读以及幻读问题

其中,MySQL默认使用可重复读隔离级别,但是MySQL在此隔离级别下也能够解决幻读问题,主要使用的是多版本并发控制MVCC技术。

关于锁

对于MySQL中的锁大概可以以其作用范围来进行分类,也就是表锁、页锁、行锁。而各种类型数据库以及MySQL中的各个引擎所侧重的锁类别大多不同,并且每种类别的锁对并发性能产生的影响也是很大的,显然锁的粒度越小,对性能的影响就越少。

例如MyISAM引擎主要使用表锁设计,因此它的并发插入性能显然会很慢,因为每次插入都要加锁。而SQL Server起初是侧重于页锁,不过后续开始支持行级锁,但因为设计的缘故导致锁的资源很稀有,锁越多开销越大,最后促成锁升级变为表锁。

而InnoDB主要采用行级锁,且行级锁没有相关额外的开销,能够获得很好的并发性。

各种锁

InnoDB实现了两种标准的行级锁,分别是:

  1. 共享锁(简称为S锁),可以理解为Java中读写锁的读锁
  2. 排他锁(简称为X锁),可以理解为Java中读写锁的写锁

显然,依照Java中读写锁的理解,不同的事务在读-读之中是不会受到锁的影响,而读-写写-读写-写则会被阻塞。

此外当行被设置锁的时候,其对应的表也会被自动加上一个表锁,其名为意向锁。相同的,不同的行级锁产生的意向锁亦不同,分为意向共享锁(IS锁)意向排它锁(IX锁)

那么它解决了什么问题呢?其实主要是作为标记当前表正在被使用呢,如果想要以表为基础(例如ALTER语句)去操作的时候就会被阻塞住。 而如果没有它的话,由于一般记录都采用行级锁的缘故,那么只能慢慢的以为基础去扫描有没有行被锁定了。值得注意的是,如果进行全表扫描的话,行锁将会被升级成表锁,因此会被意向锁所阻塞

自增锁

自增锁本质来说是为了保证表主键被设定为自增时的唯一性。从需求入手,对于自增的设计很显然是设计一个计数器,每当有INSERT操作时先为计数器加锁,然后进行自增产生ID,随后进行解锁。这种方式称之为AUTO-INC Locking,采用一种特殊的表锁机制。但当面临并发插入的时候,性能并非是差,而是有一些优化空间。

MySQL一共提供了三种自增策略,可以通过参数innodb_autoinc_lock_mode进行调整。而各类策略会以插入方式的不同而作出区分。

  • simple insert 简单插入,主要特点是插入语句在处理时便可以确定需要分配几个ID,即 INSERT……VALUES()REPLACE。
  • bulk inserts 批量插入,主要特点是插入语句在处理时并不能得知要插入的行数,即INSERT……SELECT这种。
  • mixed-mode inserts 混合模式插入,主要是基于简单插入但有部分行的ID是在插入时就已经确定的。

了解完前述插入方式的不同,便可以往下了解对应的策略了。

参数为0,“传统”锁定模式。 在这个锁定模式下,所有类型的INSERT语句在执行的时候都会获得一个名为AUTO-INC的表锁,每次插入时互斥。本质来说还是交替执行,当并发执行时必定会限制并发能力。

参数为1,“连续”锁定模式,在MySQL8.0前默认选项。 在这个语句下批量插入依然使用前述的AUTO-INC表锁,而简单插入可以直接通过mutex(轻量锁)的控制下批量的获取对应的自增ID值。

参数为2,“交错”锁定模式,在MySQL8.0开始的默认选项。 在这个锁定模式下可以为并发执行的INSERT语句都能够并发得到一个唯一且单调递增的ID,但生成的值可能不是连续的。

元数据锁(MDL锁)

简而言之,当需要对表结构进行修改的时候,需要获得元数据锁。而对表进行增删改查时会加上元数据所。这样使这两者之间互斥,不可以同时进行,以确保数据读写正确性。

间隙锁与临界锁

提出间隙锁和临界锁的主要目的主要是为了在REPEATABLE READ级别下去解决幻读问题。而解决的方式便是在查询的时候将对应条件中的整个范围都去锁住,不允许其他事务在此区间进行操作。

其中间隙锁主要指对一个记录的前后记录之间产生的缝隙进行加锁,而临键锁本质是间隙锁的升级,它主要作用于不仅是对一个范围的记录,还对其前后缝隙进行加锁。

其中以上所讲的间隙概念是指前后数据之间的范围。而间隙锁的加锁范围是前开后开区间,而间隙锁为前开后闭区间。

如数据行仅有一列,各行为:1、3、5。那么通过间隙锁能够产生以下的间隙区间:

(1,3)、(3,5)

而通过临键锁产生以下间隙区间:

(1,3]、(3,5]

对读操作的延申

一致性非锁定读

由前述可知S锁和S锁之间并不会互斥,但如果记录被X锁占有,那么便会与后续的S锁互斥了。但究其本质,大部分场景下对于读操作的正确性其实并没有过高的要求,能够容忍一定的延迟。所以就出现了一致性非锁定读

一致性非锁定读是指InnoDB存储引擎通过多版本控制的方式去在对应行被写锁持有时读取对应行的历史版本。 使用这种方法可以极大的提高数据库的并发性,所以在InnoDB下这时默认的读取方式。

不过,根据隔离级别设定的不同,读取的方式不同。在READ COMMITTED级别下对于快照数据总是读取被锁定行的最新一份快照数据。而在REPEATABLE READ级别下则是读取事务开始时的快照数据。

展开来讲REPEATABLE READ级别,其实从表面的意思上来看与READ COMMITTED级别并没有什么区别,都是读取当时最新一份快照数据。而主要的区别是,在一个READ COMMITED级别的事务中,每次读数据都会去获取最新的一份快照数据,很显然这必然会存在幻读问题。而在REPEATABLE READ级别的事务里,会把第一次读取到的数据存储起来,其后相同的查询语句均以第一次读取的数据为准,这样就可以避免了幻读问题。

而这两者读取数据的方法主要依靠MVCC中的READVIEW实现,在后面详细介绍。

一致性锁定读

前面讲述了一致性非锁定读下的读取,那么在某些情况下需要显式的对读取操作加锁以保证数据逻辑的一致性该如何进行呢?

同开始讲述的两种标准的行级锁一样,对数据的一致性锁定读也是共享和排它两种,亦称为S锁和X锁。主要的操作方式如下:

  • X锁:SLELECT……FOR UPDATE
  • S锁:SELECT……LOCK IN SHARE MODE

ReadView

通过前述可知,ReadView的主要职责在于为事务获取到历史版本的快照数据。其实很容易可以联想到,ReadView本质来讲是基于undo日志实现的,也就是说前面所说的快照数据,其实就是undo日志中的回滚链!

既然知道了其本质,其实后面的东西就很好理解了。当事务检索数据时会获得一个REDOVIEW,其中最主要的几个参数分别如下:

  • creator_trx_id,创建这个ReadView的事务ID
  • trx_ids,生成这个ReadView时系统中活跃(未提交)的事务ID列表
  • up_limit_id,活跃的事务中最小的事务ID
  • low_limit_id,生成这个ReadView时,系统会分配给下一个事务的id

其中值得注意的是low_limit_id,它并不是与最小事务ID对应的最大事务ID,而是未来将要发生的第一个事务的ID。比如当前最大事务ID为5,那么low_limit_id将会存储6。(如果ID是自增规则设计的话)

过程与规则

当明白ReadView的本体后,就可以了解MySQL是如何使用的ReadView了。其实最主要的部分在于,是如何得知要提供给用户哪一个版本。也就是一个记录的不同版本中那个是当前事务可见的。

具体流程如下:

  1. 当获取到ReadView后,根据对应行记录的trx_id判断是否与自己的相等,如果相等代表这个行记录是自己修改的,是可见的。如果不可见,那么就进入下一步,通过回滚指针去进行寻找历史版本
  2. 根据up_limit_id和low_limit_id去判断对应事务是否存在,在最小事务ID前代表该事务已不在活动,可见。大于等于low_limit_id则认为其在活跃,不可见。
  3. 如果在上面两个参数之间,则判断对应事务id是否存在于trx_ids中。如果存在,则不可见,反之可见。

最主要的一件事是,这些参数对于ReadView而言自被创建时就已被确立,后续并不会更新,因此不具备时效性。其主要目的就是为了让事务从前到后的所有操作都固定在一个“时间场景”之下。

自此便是ReadView的一个读取过程。另外需要注意的就是READ COMMITTED和REPEATABLE READ两个隔离级别下的不同。

# MySQL