从源码看MySQL的加锁规则

1,365 阅读16分钟

MySQL加锁规则

InnoDB锁的内存结构

我们来看一下InnoDB锁的内存结构,InnoDB中用lock_t这个结构来定义:

image-20220815093948210

对一条记录加锁的本质就是在内存中创建一个锁结构与之关联,那么是不是一个事务对多条记录加锁,就要创建多个锁结构呢?如果一个事务要获取10000条记录的锁,就要生成10000个这样的结构,开销就太大了。

InnoDB在对不同记录加锁时,如果符合下边这些条件,那么这些记录的锁就可以被放到一个锁结构中:

  • 在同一个事务中进行加锁操作
  • 被加锁的记录在同一个页面中
  • 加锁的类型是一样的
  • 等待状态是一样的

锁的内存结构示意图如下:

image-20220815094134187

接下来具体分析一下每个结构所代表的是什么信息

  • 锁所在的事务信息:哪个事务生成了这个锁结构,就记载这个事务的信息。

  • 索引信息:对于行锁来说,需要记录一下加锁的记录是属于哪个索引的。

  • 表锁/行锁信息 :表锁结构和行锁结构在这个位置的内容是不同的:

    • 表锁:记载着这是对哪个表加的锁,还有其他的一些信息。
    • 行锁,记载了三个重要的信息:
      • Space ID:记录所在表空间。
      • Page Number:记录所在页号。
      • n_bits:对于行锁来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。为此在行锁结构的末尾放置了一堆比特位,这个n_bits属性代表使用了多少比特位。
  • type_mode:这是一个32位的数,被分成了lock_mode、lock_type和rec_lock_type三个部分。

    image-20220815094858990
    • 锁的模式(lock_mode),占用低4位,可选的值如下:
      • LOCK_IS(十进制的0):表示共享意向锁,也就是IS锁
      • LOCK_IX(十进制的1):表示独占意向锁,也就是IX锁
      • LOCK_S(十进制的2):表示共享锁,也就是S锁
      • LOCK_X(十进制的3):表示独占锁,也就是X锁
      • LOCK_AUTO_INC(十进制的4):表示AUTO-INC锁
    • 锁的类型(lock_type),占用第5~8位,不过现阶段只有第5位和第6位被使用:
      • LOCK_TABLE(十进制的16),也就是当第5个比特位置为1时,表示表级锁。
      • LOCK_REC(十进制的32),也就是当第6个比特位置为1时,表示行级锁。
    • 行锁的具体类型(rec_lock_type),使用其余的位来表示。只有在lock_type的值为LOCK_REC时,也就是只有在该锁为行级锁时,才会被细分为更多的类型:
      • LOCK_ORDINARY(十进制的0):表示next-key锁
      • LOCK_GAP(十进制的512):也就是当第10个比特位置为1时,表示gap锁
      • LOCK_REC_NOT_GAP(十进制的1024):也就是当第11个比特位置为1时,表示行锁
      • LOCK_INSERT_INTENTION(十进制的2048):也就是当第12个比特位置为1时,表示插入意向锁。
      • 其他的类型:还有一些不常用的类型。
  • 其他信息 :为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表。

  • 比特位:如果是行锁结构的话,在该结构末尾还放置了一堆比特位。页面中的每条记录在记录头信息中都包含一个heap_no属性,伪记录Infimum的heap_no值为0,Supremum的heap_no值为1,之后每插入一条记录,heap_no值就增1。锁结构最后的一堆比特位就对应着一个页面中的记录,一个比特位映射一个heap_no。

    image-20220815095351648

在InnoDB的实现中,InnoDB的行锁是与记录一一对应的,对于间隙锁(gap)来说也是如此,虽说间隙锁锁住的是一个间隙,但是是通过在指定记录上加锁生成锁结构,然后指定锁的类型为间隙锁,并不是在一个专门的区间生成一个间隙锁。他的工作方式就是在插入记录式检查下一条记录上的锁结构是否存在间隙锁,如果存在的话就无法插入。

准备工作

以下案例来自于:Innodb到底是怎么加锁的

为了故事的顺利发展,我们先创建一个表hero

CREATE TABLE hero (
    number INT,
    name VARCHAR(100),
    country varchar(100),
    PRIMARY KEY (number),
    KEY idx_name (name)
) Engine=InnoDB CHARSET=utf8;

然后向这个表里插入几条记录:

INSERT INTO hero VALUES
    (1, 'l刘备', '蜀'),
    (3, 'z诸葛亮', '蜀'),
    (8, 'c曹操', '魏'),
    (15, 'x荀彧', '魏'),
    (20, 's孙权', '吴');

然后现在hero表就有了两个索引(一个二级索引,一个聚簇索引),示意图如下:

图片

加锁受哪些因素影响

  • 事务的隔离级别

  • 语句执行时使用的索引类型(比如聚簇索引、唯一二级索引、普通二级索引)

  • 是否是精确匹配

  • 是否是唯一性搜索

  • 具体执行的语句类型(SELECT、INSERT、DELETE、UPDATE)

  • 记录是否被标记删除

  • 是否开启innodb_locks_unsafe_for_binlog系统变量

    innodb_locks_unsafe_for_binlog最主要的作用就是控制innodb是否对gap加锁。注意该参数如果是enable的,则是unsafe的,此时gap不会加锁;反之,如果disable掉该参数,则gap会加锁

首先来看一下几个概念:

扫描区间

SELECT * FROM hero WHERE name <=  'l刘备' ;

以这条sql为例,MySQL可以使用下边两种方式来执行上述查询:

  • 使用二级索引idx_name执行上述查询,那么就需要扫描name值在(-∞, 'l刘备']这个区间中的所有二级索引记录,针对获取到的每一条二级索引记录,都需要执行回表操作来获取相应的聚簇索引记录。
  • 直接扫描所有的聚簇索引记录,即进行全表扫描。此时相当于扫描number值在(-∞, +∞)这个区间中的所有聚簇索引记录。

可能有人会有疑惑,name不是二级索引吗?为啥还有可能不走索引去全表扫描呢?

SQL 的执行成本(cost)是 MySQL 优化器选择 SQL 执行计划时一个重要考量因素。当优化器认为使用索引的成本高于全表扫描的时候,优化器将会选择全表扫描,而不是使用索引。那为什么使用索引的成本比全表扫描还高呢?因为当普通索引并不包括查询的所有列(没有使用覆盖索引的情况),因此需要通过 name 的索引树找到对应的主键 id ,然后再到 id 的索引树进行数据查询,即回表(通过索引查出主键,再去查数据行),这样成本必然上升。尤其是当回表的数据量比较大的时候,经常会出现 MySQL 优化器认为回表查询代价过高而不选择索引的情况。

当使用二级索引执行查询时,我们把(-∞, 'l刘备']称作扫描区间,意味着需要扫描name列值在这个区间中的所有二级索引记录,我们也可以把形成这个扫描区间的条件name <= 'l刘备'称作是形成这个扫描区间的边界条件;当使用全表扫描执行查询时,我们把(-∞, +∞)称作扫描区间,意味着需要扫描number值在这个区间中的所有聚簇索引记录。

精确匹配

对于形成扫描区间的边界条件来说,如果是等值匹配的条件,我们就把对这个扫描区间的匹配模式称作精确匹配

SELECT * FROM hero WHERE name = 'l刘备' AND country = '魏';

如果使用二级索引idx_name执行上述查询时,扫描区间就是['l刘备', 'l刘备'],形成这个扫描区间的边界条件就是name = 'l刘备'。我们就把在使用二级索引idx_name执行上述查询时的匹配模式称作精确匹配

唯一性搜索

如果在扫描某个扫描区间的记录前,就能事先确定该扫描区间最多只包含1条记录的话,那么就把这种情况称作唯一性搜索。那满足什么样的情况可以确定是唯一性搜索呢?

  1. 匹配模式是精确匹配

  2. 使用的索引是聚簇索引或唯一索引(非聚簇索引)

  3. 如果索引中包含多个列,则每个列在生成扫描区间时都应该被用到

    举个例子:比方说我们为某个表的a、b两列建立了一个唯一索引uniquek_a_b(a, b),那么对于搜索条件a=1形成的扫描区间来说,不能保证该扫描区间最多只包含一条记录;对于搜索条件a=1 AND b= 1形成的扫描区间来说,才可以保证该扫描区间中仅包含1条记录

  4. 如果使用的索引是唯一索引(非聚簇索引),那么在搜索时不能搜索某个索引列为NULL的记录(因为对于唯一索引来说,是可以存储多个值为NULL的记录的)。

row_search_mvcc

我们知道MySQL其实是分成server层和存储引擎层两部分,每当执行一个查询时,server层负责生成执行计划,即选取即将使用的索引以及对应的扫描区间。我们这里以InnoDB为例,针对每一个扫描区间,都会:

  1. server层向InnoDB要扫描区间的第1条记录

  2. InnoDB通过B+树定位到扫描区间的第1条记录(如果定位的是二级索引记录并有回表需求则回表获取完整的聚簇索引记录),然后返回给server层

  3. server层判断记录是否符合搜索条件,如果符合则发送给客户端,不符合则跳过。继续向InnoDB要下一条记录

    此处将记录发送给客户端其实是发送到本地的网络缓冲区,缓冲区大小由net_buffer_length控制,默认是16KB大小。等缓冲区满了才真正发送网络包到客户端。

  4. InnoDB根据记录的单向链表以及页面之间的双向链表找到下一条记录(如果定位的是二级索引记录并有回表需求则回表获取完整的聚簇索引记录),返回给server层

  5. server层处理该记录,并向InnoDB要下一条记录

  6. 不停执行上述过程,直到InnoDB读到一条不符合边界条件的记录为止

可见一般情况下,server层和存储引擎层是以记录为单位进行通信的,而InnoDB读取一条记录最重要的函数就是row_search_mvcc,在这个函数里,对一条记录进行诸如多版本的可见性判断,要不要对记录进行加锁的判断,要是加锁的话加什么锁的选择,完成记录从InnoDB的存储格式到server层存储格式的转换等等等等十分繁杂的工作。在看具体加锁流程之前大家可以先看一下深入理解MySQL底层事务隔离级别的实现原理,大致了解一下MVCC机制。

语句到底是怎么加锁的

对普通的SELECT的处理和意向锁的添加

image-20220816140103886

  • 标号1的箭头是对普通的SELECT的处理,在查询开启前需要生成ReadView。(快照读)

    对于Repeatable Read隔离级别来说,只在首次执行SELECT语句时生成Readview,之后的SELECT语句都复用这个ReadView;对于Read Committed隔离级别来说,每次执行SELECT语句时都会生成一个ReadView。这一点并不是在上边截图中的代码里实现的。

  • 标号2的箭头是对加锁读的语句的处理,在首次读取记录前,需要添加表级别的意向锁(IS或IX锁)。(当前度)

上面说到的当前读,快照度以及ReadView等概念在MySQL底层事务隔离级别的实现原理那篇文章中都有说道,不了解的同学可以先看看那篇文章。

1. 定位扫描区间的第一条记录

开始通过B+树定位某个扫描区间中的第一条记录了(对于一个扫描区间来说,只执行一次下述函数,因为只要定位到扫描区间的第一条记录之后,就可以沿着记录所在的单向链表进行查询了):

image-20220816141541724

2. 对于ORDER BY ... DESC条件形成的扫描区间的第一条记录的处理

在B+树的每层节点中,记录是按照键值从小到大的方式进行排序的。对于某个扫描区间来说,InnoDB通常是定位到扫描区间中最左边(最小)的那条记录,但是对于下边这个查询来说:

SELECT count(*) FROM hero WHERE name < 's孙权' AND country = '魏' ORDER BY name DESC FOR UPDATE ;

如果使用二级索引idx_name执行上述查询的话,那么对应的扫描区间就是(-∞, 's孙权')。由于上述查询要求记录是按照从大到小的顺序返回给用户,所以InnoDB定位到扫描区间中的第一条记录应该是该扫描区间中最右边的那条记录,也就是键值最大的那条记录(在执行btr_pcur_open_with_no_init时就定位到最右边的那条记录),我们看一下idx_name二级索引示意图:

image-20220816142425047

对于从右向左扫描扫描区间中记录的情况,针对从扫描区间中定位到的最右边的那条记录,需要做如下处理:

image-20220816142439967

其中sel_set_rec_lock就是对一条记录进行加锁的函数。对于加锁读来说,隔离级别在REPEATABLE READ及以上并且也没有开启innodb_locks_unsafe_for_binlog系统变量的情况下,会对扫描区间中最右边的那条记录的下一条记录加一个类型为LOCK_GAP的锁,这个类型为LOCK_GAP的锁其实就是gap锁。相当于就是在孙权的那条记录上加了一个间隙锁。

加锁的原因主要是为了防止幻读,这个间隙锁锁住的是l刘备s孙权之间的间隙,如果没有这个锁的话在假设上述sql进行了多次查询,期间我用insert语句插入一条n哪吒那么这条记录就会插入到刘备和孙权之间,再执行这个sql语句结果就不一样了,幻读就产生了。

3. 对Infimum和Supremum记录的处理

步骤1是用来定位扫描区间中的第一条记录,针对一个扫描区间只执行1次

步骤2是针对从右向左扫描的扫描区间中最右边的那条记录的下一条记录进行加锁,针对一个扫描区间也执行1次

从第3步骤开始以及往后的步骤,扫描区间中的每一条记录都要经历。

先看一下如果当前记录是Infimum记录(当前数据库最小的记录,可以把它当做-∞)或者Supremum记录(同理,最大的记录)时的处理:

image-20220816143202018

  • 如果当前读取的记录是Infimum记录,则啥也不做,直接去读下一条记录。
  • 如果当前读取的记录是Supremum记录,则在下边这些条件成立的时候就会为记录添加一个类型为LOCK_ORDINARY的锁,其实也就是next-key锁
    • set_also_gap_locks是TRUE(这个变量只在前边设置过,当隔离级别不大于READ COMMITTEDSELECT语句的加锁读会设置为FALSE,否则为TRUE)
    • 未开启innodb_locks_unsafe_for_binlog系统变量并且事务的隔离级别在REPEATABLE READ及以上
    • 本次读取属于加锁读
    • 所使用的不是空间索引

由于Supremum记录本身是一条伪记录,别的事务并不会更新或删除它,所以给它添加next-key锁起到的效果和给它添加gap锁是一样的,也是为了防止幻读。

4. 对精确匹配的特殊处理

如果当前记录不是Infimum记录或者Supremum记录,下边进入对匹配模式是精确匹配的一个特殊处理,如果不是的话直接走下一步:

image-20220816143904107

这一步骤是对精确匹配的扫描区间的一个特殊处理,即当server层收到InnoDB返回的扫描区间的最后一条记录,server层仍会向InnoDB索要下一条记录。InnoDB仍会沿着记录所在的链表向后读取,此次读取到的记录就不在扫描区间中了,如果这是一个精确匹配的扫描区间,那么就进行对记录加锁的特殊处理,如果不是的话,就继续执行第5步,也就是走正常的加锁流程。

我们举一个例子,比方说当前事务的隔离级别为Repeatable Read,执行如下语句:

SELECT * FROM hero WHERE name = 's孙权' FOR UPDATE;

如果使用二级索引idx_name执行上述查询,那么对应的扫描区间就是['s孙权', 's孙权']。当读取完's孙权'的记录后,InnoDB会根据记录的next_record属性找到下一条二级索引记录,即name值为'x荀彧'的二级索引记录,该记录不在扫描区间['s孙权', 's孙权']中,即符合 0 != cmp_dtuple_rec(search_tuple, rec, offsets)条件,那么就执行上述代码的加锁流程 —— 对name值为'x荀彧'的二级索引记录加一个gap锁。另外,err被赋值为DB_RECORD_NOT_FOUND,这意味着向server层报告当前扫描区间的记录都已经扫描完了,server层在收到这个信息后就会停止向Innodb索要下一条记录的请求,即结束本扫描区间的查询。

5. 真正的加锁流程才开始

image-20220816145652600

这两个红框是对记录是不对记录加gap锁的场景。来具体看一下,对于1号红框来说,满足以下任意条件则该记录就不应该被加gap锁,而应该添加行锁(record lock)

  • set_also_gap_locks是FALSE(这个变量只在前边设置过,当隔离级别不大于READ COMMITTED的SELECT语句的加锁读会设置为FALSE,否则为TRUE)
  • 开启innodb_locks_unsafe_for_binlog系统变量
  • 事务的隔离级别在READ COMMITTED及以下
  • 唯一性搜索并且该记录的delete_flag不为1
  • 该索引是空间索引

其余情况就应该加next-key锁(临键锁,行锁和间隙锁的组合)了;2号红框就又叙述了一个不加gap锁的场景:

对于>= 主键的这种边界条件来说,如果当前记录恰好是开始边界,就仅需对该记录加行锁,而不需添加gap锁。

举个例子,比方说下边这个查询(隔离级别为REPEATABLE READ):

SELCT * FROM hero WHERE number >= 8 FOR UPDATE;

很显然,优化器会扫描[8, +∞)的聚簇索引记录。首先要通过B+树定位到扫描区间[8, +∞)的第一条记录,也就是number值为8的聚簇索引记录,这条记录就是扫描区间[8, +∞)的开始边界记录。按理说在REPEATABLE READ隔离级别下应该添加next-key锁,但由于2号红框中代码的存在,仅会给number值为8的聚簇索引记录添加行锁。这个优化主要是基于“主键值是唯一的”这条约束,在一个事务执行了上述查询之后,其他事务是不能插入number值为8的记录的,这也用不着gap锁了。

除了1号方框2号方框的场景,其余场景都给记录加next-key锁即可。

6. 判断索引条件下推的条件是否成立

如果是使用二级索引执行查询,并且有索引条件下推(Index Condition Pushdown,简称ICP)的条件的话,判断下推的条件是否成立:

image-20220816153125422

关于什么事索引下推可以看看这篇文章:索引设计(索引下推)

7. 回表对记录加锁

如果row_search_mvcc读取的是二级索引记录,则还需进行回表,找到相应的聚簇索引记录后需对该聚簇索引记录加一个行锁

image-20220816155019871

需要注意的是,即使是对于覆盖索引的场景下,如果我们想对记录加X型锁(也就是使用SELECT ... FOR UPDATE、DELETE、UPDATE语句时)时,也需要对二级索引记录执行回表操作,并给相应的聚簇索引记录添加行锁

8. row_search_mvcc返回,判断是否已经到达边界

每当处理完一条记录后,还需要判断一下这条记录还在不在扫描区间中,如果当前记录还在扫描区间中,就给server层正常返回,如果不在了,就给server层返回一个HA_ERR_END_OF_FILE信息,表示当前扫描区间的记录都已经扫描完了,server层在收到这个信息后就会停止向Innodb索要下一条记录的请求,即结束本扫描区间的查询。

9. 然后,再处理下一条记录

server层收到InnoDB的一条记录后,如果收到InnoDB通知的本扫描区间已经扫描完毕的信息,则结束本扫描区间的查询;否则继续向InnoDB要下一条记录,也就是需要继续执行一遍row_search_mvcc函数了。循环往复,直到server层收到本扫描区间所有记录都扫描完了的信息为止。

对于具体的语句加锁的详细分析,可以看看这几篇文章:MySQL语句加锁分析