【MySQL】 锁

Metadata

title: 【MySQL】 锁
date: 2023-06-25 22:16
tags:
  - 行动阶段/完成
  - 主题场景/数据存储
  - 笔记空间/KnowladgeSpace/ProgramSpace/BasicsSpace
  - 细化主题/数据存储
categories:
  - 数据存储
keywords:
  - 数据存储
description: 【MySQL】 锁

概述

并发事务的情况

  • 写-写情况:即并发事务相继对相同的记录做出改动。 – 脏写
  • 读-写或写-读情况:即一个事务进行读取操作,另一个进行改动操作。 – 脏读、不可重复读、幻读

锁结构

  • trx信息:代表这个锁结构是哪个事务生成的。
  • is_waiting :代表当前事务是否在等待。

解决并发事务带来问题的两种基本方式

  • 读操作利用多版本并发控制( MVCC ),写操作进行加锁。
  • 读、写操作都采用加锁的方式。

读操作

  • 一致性读(Consistent Reads) 事务利用MVCC 进行的读取操作
  • 锁定读(Locking Reads) 共享锁和独占锁

写操作

  • DELETE
    1. 先在B+ 树中定位到这条记录的位置,
    2. 然后获取一下这条记录的X锁,
    3. 然后再执行delete mark 操作。
  • UPDATE
    • 未修改键值且未改变存储空间:
      1. 在B+树中定位到待修改记录的位置。
      2. 获取记录的X锁(锁定读)。
      3. 在原记录位置进行修改操作。
    • 未修改键值但改变了存储空间:
      1. 在B+树中定位到待修改记录的位置。
      2. 获取记录的X锁(锁定读)。
      3. 彻底删除该记录(移入垃圾链表)。
      4. 插入一条新记录。
      5. 新插入的记录由INSERT操作提供的隐式锁进行保护。
    • 修改了键值:
      1. 相当于先执行DELETE操作再执行INSERT操作。
      2. 加锁操作遵循DELETE和INSERT的规则。
  • INSERT
    • 隐式锁 保护

多粒度锁

  • 共享锁,英文名: Shared Locks,简称S锁。 在事务要读取一条记录时,需要先获取该记录的S锁。

  • 独占锁,也常称排他锁,英文名: Exclusive Locks ,简称X锁。在事务要改动一条记录时,需要先获取该记录的X锁。

  • 行锁 DML 语句 DQL 语句

    • 行S锁
    • 行X锁
  • 表锁 DDL 语句

    • 表S锁
    • 表X锁
  • 意向锁 为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁

    • 意向共享锁,英文名: Intention Shared Lock ,简称IS锁。当事务准备在某条记录上加S锁时,需要先在表级别加一个IS锁。
    • 意向独占锁,英文名: Intention Exclusive Lock ,简称IX锁。当事务准备在某条记录上加X锁时,需要先在表级别加一个IX锁。

InnoDB 存储引擎相关锁

表级锁

  • AUTO-INC锁
    • id 字段声明了AUTO_INCREMENT
    • 轻量级的锁

行级锁

  • Record Locks 记录锁
    • 仅仅把一条记录锁上
    • 分 X锁 S锁
  • Gap Locks 间隔锁
    • 记录前边的间隙插入新记录
    • 解决幻读
  • Next-Key Locks 邻键锁
    • 既锁住某条记录,又阻止其他事务在该记录前边的间隙插入新记录
  • Insert Intention Locks 插入意向锁
    • 有事务想在某个间隙中插入新记录,但是现在在等待。
  • 隐式锁
    • 一个事务对新插入的记录可以不显式的加锁(生成一个锁结构),但是由于事务id 的存在,相当于加了一个隐式锁。

InnoDB锁的内存结构

前言

并发事务的情况

  • 读-读情况:即并发事务相继读取相同的记录。
    读取操作本身不会对记录有一毛钱影响,并不会引起什么问题,所以允许这种情况的发生。
  • 写-写情况:即并发事务相继对相同的记录做出改动。
    在这种情况下会发生脏写的问题,任何一种隔离级别都不允许这种问题的发生。
    读-写或写-读情况:也就是一个事务进行读取操作,另一个进行改动操作。
    这种情况下可能发生脏读、不可重复读、幻读的问题。

在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过锁来实现的。

锁结构

这个所谓的锁其实是一个内存中的结构,在事务执行前本来是没有锁的,也就是说一开始是没有锁结构和记录进行关联的

当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的锁结构,当没有的时候就会在内存中生成一个锁结构与之关联。

比方说事务T1 要对这条记录做改动,就需要生成一个锁结构与之关联:

其实在锁结构里有很多信息,不过为了简化理解,我们现在只把两个比较重要的属性拿了出来:

  • trx信息:代表这个锁结构是哪个事务生成的。
  • is_waiting :代表当前事务是否在等待。

如图所示,当事务T1 改动了这条记录后,就生成了一个锁结构与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting 属性就是false ,我们把这个场景就称之为获取锁成功,或者加锁成功,然后就可以继续执行操作了。

在事务T1 提交之前,另一个事务T2 也想对该记录做改动,那么先去看看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,然后也生成了一个锁结构与这条记录关联,不过锁结构的is_waiting 属性值为true ,表示当前事务需要等待,我们把这个场景就称之为获取锁失败,或者加锁失败,或者没有成功的获取到锁,画个图表示就是这样:

在事务T1 提交之后,就会把该事务生成的锁结构释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务T2 还在等待获取锁,所以把事务T2 对应的锁结构的is_waiting 属性设置为false ,然后把该事务对应的线程唤醒,让它继续执行,此时事务T2 就算获取到锁了。效果图就是这样:

我们总结一下后续内容中可能用到的几种说法,以免大家混淆:

  • 不加锁
    意思就是不需要在内存中生成对应的锁结构,可以直接执行操作。
  • 获取锁成功,或者加锁成功
    意思就是在内存中生成了对应的锁结构,而且锁结构的is_waiting 属性为false ,也就是事务可以继续执行操作。
  • 获取锁失败,或者加锁失败,或者没有获取到锁
    意思就是在内存中生成了对应的锁结构,不过锁结构的is_waiting 属性为true ,也就是事务需要等待,不可以继续执行操作。

解决并发事务带来问题的两种基本方式

方案一:读操作利用多版本并发控制( MVCC ),写操作进行加锁。

读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC 时, 读-写操作并不冲突。

方案二:读、写操作都采用加锁的方式。

这样在读取记录的时候也就需要对其进行加锁操作,这样也就意味着读操作和写操作也像写-写操作那样排队执行。

读操作

一致性读(Consistent Reads)

事务利用MVCC 进行的读取操作称之为一致性读,或者一致性无锁读,有的地方也称之为快照读

锁定读(Locking Reads)

共享锁和独占锁

由于既要允许读-读情况不受影响,又要使写-写、读-写或写-读情况中的操作相互阻塞,所以设计MySQL 的大叔给锁分了个类:

  • 共享锁,英文名: Shared Locks ,简称S锁。在事务要读取一条记录时,需要先获取该记录的S锁。
  • 独占锁,也常称排他锁,英文名: Exclusive Locks ,简称X锁。在事务要改动一条记录时,需要先获取该记录的X锁。

锁定读的语句

对读取的记录加S锁:

SELECT ... LOCK IN SHARE MODE;

对读取的记录加X锁:

SELECT ... FOR UPDATE;

写操作

写操作无非是DELETE 、UPDATE 、INSERT 这三种

  • DELETE :

对一条记录做DELETE 操作的过程其实是

  1. 先在B+ 树中定位到这条记录的位置,
  2. 然后获取一下这条记录的X锁,
  3. 然后再执行delete mark 操作。

我们也可以把这个定位待删除记录在B+ 树中位置的过程看成是一个获取X锁的锁定读。

  • UPDATE :
  1. 如果未修改该记录的键值并且被更新的列占用的存储空间在修改前后未发生变化,则先在B+ 树中定位到这条记录的位置,然后再获取一下记录的X锁,最后在原记录的位置进行修改操作。其实我们也可以把这个定位待修改记录在B+ 树中位置的过程看成是一个获取X锁的锁定读。
  2. 如果未修改该记录的键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在B+ 树中定位到这条记录的位置,然后获取一下记录的X锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+ 树中位置的过程看成是一个获取X锁的锁定读,新插入的记录由INSERT 操作提供的隐式锁进行保护。
  3. 如果修改了该记录的键值,则相当于在原记录上做DELETE 操作之后再来一次INSERT 操作,加锁操作就需要按照DELETE 和INSERT 的规则进行了。
  • INSERT :

新插入一条记录的操作并不加锁,设计InnoDB 的大叔通过一种称之为隐式锁的东东来保护这条新插入的记录在本事务提交前不被别的事务访问

多粒度锁

之前提到的锁都是针对记录的,也可以被称之为行级锁或者行锁,对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细

其实一个事务也可以在表级别进行加锁,自然就被称之为表级锁或者表锁,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。

给表加的锁也可以分为共享锁( S锁)和独占锁( X锁):

给表加S锁:

如果一个事务给表加了S锁,那么:

  • 别的事务可以继续获得该表的S锁
  • 别的事务可以继续获得该表中的某些记录的S锁
  • 别的事务不可以继续获得该表的X锁
  • 别的事务不可以继续获得该表中的某些记录的X锁

给表加X锁:

如果一个事务给表加了X锁(意味着该事务要独占这个表),那么:

  • 别的事务不可以继续获得该表的S锁
  • 别的事务不可以继续获得该表中的某些记录的S锁
  • 别的事务不可以继续获得该表的X锁
  • 别的事务不可以继续获得该表中的某些记录的X锁

意向锁

我们在对教学楼整体上锁( 表锁)时,怎么知道教学楼中有没有教室已经被上锁( 行锁)了呢?

总结

IS、IX锁是表级锁,它们的提出仅仅为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录,也就是说其实IS锁和IX锁是兼容的,IX锁和IX锁是兼容的。

MySQL中的行锁和表锁

InnoDB 存储引擎既支持表锁,也支持行锁。表锁实现简单,占用资源较少,不过粒度很粗,有时候你仅仅需要锁住几条记录,但使用表锁的话相当于为表中的所有记录都加锁,所以性能比较差。行锁粒度更细,可以实现更精准的并发控制。下边我们详细看一下。

InnoDB中的表级锁

表级别的S锁、X锁

在对某个表执行SELECT 、INSERT 、DELETE 、UPDATE 语句时, InnoDB 存储引擎是不会为这个表添加表级别的S锁或者X锁的。

在对某个表执行一些诸如ALTER TABLE 、DROP TABLE 这类的DDL 语句时,其他事务对这个表并发执行诸如SELECT 、INSERT 、DELETE 、UPDATE 的语句会发生阻塞,同理,某个事务中对某个表执行SELECT 、INSERT 、DELETE 、UPDATE 语句时,在其他会话中对这个表执行DDL 语句也会发生阻塞

这个过程其实是通过在server层使用一种称之为元数据锁(英文名: Metadata Locks ,简称MDL )东东来实现的,一般情况下也不会使用InnoDB 存储引擎自己提供的表级别的S锁和X锁。

其实这个InnoDB 存储引擎提供的表级S锁或者X锁是相当鸡肋,只会在一些特殊情况下,比方说崩溃恢复过程中用到。

不过我们还是可以手动获取一下的,比方说在系统变量autocommit=0,innodb_table_locks =1 时,手动获取InnoDB 存储引擎提供的表t 的S锁或者X锁可以这么写:

  • LOCK TABLES t READ : InnoDB 存储引擎会对表t 加表级别的S锁。
  • LOCK TABLES t WRITE : InnoDB 存储引擎会对表t 加表级别的X锁。

表级别的IS锁、IX锁

当我们在对使用InnoDB 存储引擎的表的某些记录加S锁之前,那就需要先在表级别加一个IS锁,当我们在对使用InnoDB 存储引擎的表的某些记录加X锁之前,那就需要先在表级别加一个IX锁。

S锁和IX锁的使命只是为了后续在加表级别的S锁和X锁时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。

表级别的AUTO-INC锁

在使用MySQL 过程中,我们可以为表的某个列添加AUTO_INCREMENT 属性,之后在插入记录时,可以不指定该列的值,系统会自动为它赋上递增的值

比方说我们有一个表:

CREATE TABLE t (
id INT NOT NULL AUTO_INCREMENT,
c VARCHAR(100),
PRIMARY KEY (id)
) Engine=InnoDB CHARSET=utf8;

由于这个表的id 字段声明了AUTO_INCREMENT ,也就意味着在书写插入语句时不需要为其赋值.

系统实现这种自动给AUTO_INCREMENT 修饰的列递增赋值的原理主要是两个:

  • 采用AUTO-INC 锁,也就是在执行插入语句时就在表级别加一个AUTO-INC 锁,然后为每条待插入记录的AUTO_INCREMENT 修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INC 锁释放掉。这样一个事务在持有AUTO-INC 锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。
  • 采用一个轻量级的锁,在为插入语句生成AUTO_INCREMENT 修饰的列的值时获取一下这个轻量级锁,然后生成本次插入语句需要用到的AUTO_INCREMENT 列的值之后,就把该轻量级锁释放掉,并不需要等到整个插入语句执行完才释放锁。

InnoDB中的行级锁

行锁,也称为记录锁,顾名思义就是在记录上加的锁。

使对同一条记录加行锁,如果类型不同,起到的功效也是不同的。

示例

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孙权', '吴');

现在表里的数据就是这样的:

采用utf8字符集,该字符集并没有对应的按照汉语拼音进行排序的比较规则

我们把hero 表中的聚簇索引的示意图画一下:

常用的行锁类型

Record Locks :

LOCK_REC_NOT_GAP (正经记录锁) 仅仅把一条记录锁上

比方说我们把number 值为8 的那条记录加一个正经记录锁的示意图如下:

正经记录锁是有S锁和X锁之分的,让我们分别称之为S型正经记录锁和X型正经记录锁吧

Gap Locks :

使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上正经记录锁。

提出了一种称之为Gap Locks 的锁,官方的类型名称为: LOCK_GAP 简称为gap锁。

不允许别的事务在number 值为8 的记录前边的间隙插入新记录

这个gap锁的提出仅仅是为了防止插入幻影记录而提出的

给哪条记录加gap锁才能阻止其他事务插入number 值在(20, +∞) 这个区间的新记录呢?

参考数据页时介绍的两条伪记录了:

  • Infimum 记录,表示该页面中最小的记录。
  • Supremum 记录,表示该页面中最大的记录。

为了实现阻止其他事务插入number 值在(20, +∞) 这个区间的新记录,我们可以给索引中的最后一条记录,也就是number 值为20 的那条记录所在页面的Supremum 记录加上一个gap锁

这样就可以阻止其他事务插入number 值在(20, +∞) 这个区间的新记录。为了大家理解方便,之后的索引示意图中都会把这个Supremum 记录画出来。

Next-Key Locks :

既想锁住某条记录,又想阻止其他事务在该记录前边的间隙插入新记录

Next-Key Locks 的锁,官方的类型名称为: LOCK_ORDINARY 简称为next-key锁。

next-key锁的本质就是一个正经记录锁和一个gap锁的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前边的间隙。

Insert Intention Locks :

一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了所谓的gap锁
如果有的话,插入操作需要等待,直到拥有gap锁的那个事务提交。
设计InnoDB 的大叔规定事务在等待的时候也需要在内存中生成一个锁结构, 表明有事务想在某个间隙中插入新记录,但是现在在等待。

把这种类型的锁命名为Insert Intention Locks ,官方的类型名称为: LOCK_INSERT_INTENTION ,我们也可以称为插入意向锁。

比方说现在T1 为number 值为8 的记录加了一个gap锁,然后T2 和T3 分别想向hero 表中插入number 值分别为4 、5 的两条记录,所以现在为number 值为8 的记录加的锁的示意图就如下所示:

从图中可以看到,由于T1 持有gap锁,所以T2 和T3 需要生成一个插入意向锁的锁结构并且处于等待状态。当T1 提交后会把它获取到的锁都释放掉,这样T2 和T3 就能获取到对应的插入意向锁了(本质上就是把插入意向锁对应锁结构的is_waiting 属性改为false ), T2 和T3 之间也并不会相互阻塞,它们可以同时获取到number 值为8的插入意向锁,然后执行插入操作。

隐式锁

一个事务在执行INSERT 操作时,如果即将插入的间隙已经被其他事务加了gap锁,那么本次INSERT 操作会阻塞,并且当前事务会在该间隙上加一个插入意向锁,否则一般情况下INSERT 操作是不加锁的。

那如果一个事务首先插入了一条记录(此时并没有与该记录关联的锁结构),没提交,然后另一个事务:

  • 立即使用SELECT … LOCK IN SHARE MODE 语句读取这条事务,也就是在要获取这条记录的S锁,或者使用SELECT … FOR UPDATE 语句读取这条事务或者直接修改这条记录,也就是要获取这条记录的X锁
    如果允许这种情况的发生,那么可能产生脏读问题。
  • 立即修改这条记录,也就是要获取这条记录的X锁,该咋办?
    如果允许这种情况的发生,那么可能产生脏写问题。

我们把聚簇索引和二级索引中的记录分开看一下:

  • 情景一:

对于聚簇索引记录来说,有一个trx_id 隐藏列,该隐藏列记录着最后改动该记录的事务id 。
那么如果在当前事务中新插入一条聚簇索引记录后,该记录的trx_id 隐藏列代表的就是当前事务的事务id ,
如果其他事务此时想对该记录添加S锁或者X锁时,首先会看一下该记录的trx_id 隐藏列代表的事务是否是当前的活跃事务,
如果是的话,那么就帮助当前事务创建一个X锁(也就是为当前事务创建一个锁结构, is_waiting 属性是false ),
然后自己进入等待状态(也就是为自己也创建一个锁结构, is_waiting 属性是true )。

  • 情景二:

对于二级索引记录来说,本身并没有trx_id 隐藏列,但是在二级索引页面的Page Header 部分有一个PAGE_MAX_TRX_ID 属性,该属性代表对该页面做改动的最大的事务id ,
如果PAGE_MAX_TRX_ID 属性值小于当前最小的活跃事务id ,那么说明对该页面做修改的事务都已经提交了,
否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记录,然后再重复情景一的做法。

一个事务对新插入的记录可以不显式的加锁(生成一个锁结构),但是由于事务id 这个牛逼的东东的存在,相当于加了一个隐式锁。

InnoDB锁的内存结构

决定在对不同记录加锁时,如果符合下边这些条件:

如果符合下边这些条件:

  • 在同一个事务中进行加锁操作
  • 被加锁的记录在同一个页面中
  • 加锁的类型是一样的
  • 等待状态是一样的
    那么这些记录的锁就可以被放到一个锁结构中

InnoDB 存储引擎中的锁结构:

锁所在的事务信息:

不论是表锁还是行锁,都是在事务执行过程中生成的,哪个事务生成了这个锁结构,这里就记载着这个事务的信息。

索引信息:

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

表锁/行锁信息:

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

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

type_mode :

这是一个32位的数,被分成了lock_mode 、lock_type 和rec_lock_type 三个部分,如图所示:

  • 锁的模式( 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时,表示插入意向锁。
      is_waiting 属性也放到了type_mode 这个32位的数字中
    • LOCK_WAIT (十进制的256 ) :也就是当第9个比特位置为1 时,表示is_waiting 为true ,也就是当前事务尚未获取到锁,处在等待状态;当这个比特位为0 时,表示is_waiting 为false ,也就是当前事务获取锁成功。

一堆比特位

对应着一个页面中的记录,一个比特位映射一个heap_no