MySQL的锁机制

2022/1/21

# MySQL的锁

本篇文章提到的锁,如果没有特别说明,默认指InnoDB的锁。

# MySQL加锁的目的是什么?

数据库的锁是为了解决事务的隔离性问题,为了让事务之间相互不影响,每个事务进行操作的时候都会对数据加上一把特有的锁,防止其他事务同时操作数据。

# MySQL的锁是基于什么实现的?

数据库里面的锁是基于索引实现的,在Innodb中我们的锁都是作用在索引上面的,当我们的SQL命中索引时,那么锁住的就是命中条件内的索引节点(行锁),如果没有命中索引的话,那我们锁的就是整个索引树(表锁)。

# 解决并发事务问题的方式

# 写写情况

任何一种隔离级别都不允许出现脏写现象,是通过加锁来实现的。锁结构两个比较重要的属性:

  • trx信息:表示锁结构与哪个事务有关。
  • is_waiting:表示当前事务是否在等待。

锁获取的状态:

  • 获取锁成功:内存中生成了对应的锁结构,而且is_waiting的值为false。(除了隐式锁)
  • 获取锁失败:内存中生成了对应的锁结构,而且is_waiting的值为true,事务需要等待。
  • 不加锁:内存中没有生成锁结构,可以直接操作。(不包括隐式锁)

释放锁:释放锁,发现还有事务等待锁,修改对应锁结构is_waiting的值为true。

# 读写或写读情况

方案1:读操作使用MVCC,写操作加锁

MVCC通过ReadView找到符合条件的记录版本,查询语句只能读到在生成ReadView之前已经提交的事务所做的更改,在生成ReadView之前未提交或者之后才开启的事务所做的修改操作是看不到的。

写操作针对最新版本的记录,读记录的历史版本和改动记录的最新版本是不冲突的。

方案2:读写操作都加锁

# 一致性读

事务利用MVCC方式,读写操作不冲突称之为一致性读。

一致性读并不会对表中的任何记录进行加锁,其他事务可以自由对表中的记录进行改动。

# 锁定读

对记录加S锁:SELECT …… LOCK IN SHARE MODE

对记录加X锁:SELECT …… FOR UPDATE

# 写操作

# DELETE

  1. 先在B+树中定位到记录,然后获取X锁,最后执行 delete mark 操作(添加删除标记)

# UPDATE

  • 未修改主键值,并且被更新的列所占用的存储空间未改变:定位到B+树的记录位置,获取X锁,修改值。
  • 未修改主键值,并且被更新的列至少有一个所占用的存储空间改变:定位到B+树的记录位置,获取X锁,彻底删除记录(而不是delete mark),然后再新增记录。
  • 修改记录的键值:定位到B+树的记录位置,获取X锁,执行DELETE操作,然后再执行INSERT操作。

# INSERT

一般情况下,新插入记录受隐式锁保护,不生成对应的锁结构。

# 锁的内存结构

# 基本介绍

“锁”本质上是内存中的结构,在事务执行之前是没有锁的(也就是说一开始是没有锁结构与记录进行关联的)。

当一个事务相对这条记录进行改动时,首先会看内存中有没有与这条记录相关联的锁结构;如果没有,就在内存中生成一个锁结构与记录相关联。

锁结构有很多信息,比较重要的属性如下

  • trx信息:表示这个锁结构与哪个事务相关联。
  • is_waiting:表示当前事务是否在等待。
  • type:表示锁的类型。

# 锁结构的变化:

  1. 事务T1修改记录前,生成锁结构(因为之前没有别的事务为这条记录加锁):trx:T1 is_waiting:false。称这个操作为加锁成功。
  2. 事务T1提交前,事务T2也想修改这条记录。在内存中发现有一个锁记录,T2也生成一个所记录trx:T2 is_waiting:true,表示需要等待,称为加锁失败。
  3. 事务T1提交后,就会把它生成的锁机构释放掉,然后检测一次是否还有与这条记录相关联的锁结构。发现事务T2在等待获取锁,所以把事务T2的 is_waiting:true 改为 false ,然后把该事物对应的线程唤醒,让T2继续执行。此时T2就获取到锁了。

# 哪些记录可以放在一个锁结构中?

上文提到:对一条记录加锁的本质是在内存中创建一个锁结构与之关联(隐式锁除外)。

但是一个事务对多条记录加锁,是不是要创建多个锁结构?如果加锁记录太多,岂不是造成了内存占用太大。

所以,如果符合以下条件,这些记录的锁可以放到一个锁结构中:

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

# 锁结构详解

271a340123d72197fd99faa32c1190b
  • 锁所在事务信息和索引信息 在内存结构中是一个指针,不会占用太大空间
  • 表锁/行锁信息:
    • 表锁记载着这是对哪个表加的锁
    • 行锁记载下面3个重要信息:
      • Space ID:记录所在的表空间
      • Page Number:记录所在的页号
      • n_bits:对于行锁来说,一条记录对用一个比特。用以区分哪些记录被加了行锁,n_bits为了让页面插入新记录时不至于重新分配锁结构,一般来说会比页面记录多一些。
  • type_mode:是一个32比特的数,分为 lock_mode、lock_type、rec_lock_type 三部分。91bb6399110c93e44260472028a69fc
    • lock_mode(锁模式)占用4比特,具体如下:
      • LOCK_IS(十进制0)
      • LOCK_IX(十进制1)
      • LOCK_S(十进制2)
      • LOCK_X(十进制3)
      • LOCK_AUTO_INC(十进制4)
    • 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,表示插入意向锁
      • 其他类型
      • LOCK_WAIT(十进制256):第9比特位为1,is_waiting表示为true。
    • 一堆比特位:每个比特位,表示锁结构对应一条记录。

# 锁的分类

基于锁的属性分类:共享锁、排他锁。

基于锁的粒度分类:表锁、行锁、记录锁、间隙锁、临键锁、自增锁。

基于锁的状态分类:意向共享锁、意向排它锁。

基于加锁的态度分类:悲观锁、乐观锁。

# 共享锁和排它锁(读写锁)

# 共享锁

共享锁又称读锁,简称S锁;当一个事务为数据加上读锁之后,其他事务只能对该数据加读锁,而不能对数据加写锁,直到所有的读锁释放之后其他事务才能对其进行加持写锁。

共享锁的特性主要是为了支持并发的读取数据,读取数据的时候不支持修改,避免出现重复读的问题。

共享锁

# 排它锁

排他锁又称写锁,简称X锁;当一个事务为数据加上写锁时,其他请求将不能再为数据加任何锁,直到该锁释放之后,其他事务才能对数据进行加锁。

排他锁的目的是在数据修改时候,不允许其他人同时修改,也不允许其他人读取。避免了出现脏数据和脏读的问题。

排它锁

# 读写意向锁

意向锁也是表级锁,也可分为读意向锁(IS 锁)和写意向锁(IX 锁)。分为如下情况:

  • 某条记录加S锁时,需要先在表级别加IS锁。
  • 某条记录加X锁时,需要先在表级别加IX锁。
  • 加表级别S锁时,此表不能加有IX锁。
  • 加表级别X锁时,此表不能加有IS、IX锁。

意向锁是为了,在加表级别S、X锁时,快速判断表中记录是否被上锁,避免遍历该表的所有记录。

# 表锁

# 什么是表锁?

表锁是指上锁的时候锁住的是整个表,当下一个事务访问该表的时候,必须等前一个事务释放了锁才能进行对表进行访问;

表锁由 MySQL Server 实现,一般在执行 DDL 语句时会对整个表进行加锁,比如说 ALTER TABLE 等操作。

表锁的特点: 粒度大,加锁简单,容易冲突

# 显式加表锁

在执行 SQL 语句时,也可以明确指定对某个表进行加锁:

#
分为读锁和写锁
lock table user read(write); 
#
成功
select *
from user
where id = 100;
#
失败
,未提前获取该 role的读表锁
select *
from role
where id = 100;
#
失败
,未提前获得user的写表锁
update user
set name = 'Tom'
where id = 100;
#
显示释放表锁
unlock tables; 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

表锁使用的是一次性锁技术,也就是说,在会话开始的地方使用 lock 命令将后续需要用到的表都加上锁,在表释放前,只能访问这些加锁的表,不能访问其他表,直到最后通过 unlock tables 释放所有表锁。

# 什么时候释放表锁

  • 使用 unlock tables 显示释放锁
  • 会话持有其他表锁时执行 lock table 语句会释放会话之前持有的锁
  • 会话持有其他表锁时执行 start transaction 或者 begin 开启事务时,也会释放之前持有的锁。

# 表级别的S锁、X锁

表级别的锁一般用在执行 ALTER TABLEDROP TABLE的DDL语句时,然后再执行增删改查时会阻塞。

# 表级别的IS锁、IX锁

参看上述的读写意向锁

# 表级别的AUTO-INC锁

AUTO-INC 锁又叫自增锁(一般简写成 AI 锁),是一种表锁,当表中有自增列(AUTO_INCREMENT)时出现。

主要实现方式有两种:

  1. 采用AUTO-INC锁。执行插入语句时,加一个表级别的AUTO-INC锁,然后为每条待插入记录的AUTO_INCREMENT列分配递增的值,插入执行完毕,锁释放。
  2. 采用一种轻量级的锁(mutex,MySQL 从 5.1.22 版本开始引入)。生成自增值后释放,而不是要等插入完成才释放锁。

innodb_autoinc_lock_mode来控制使用哪种锁:

  • 值为0,一律使用AUTO-INC锁
  • 值为1,插入数量确定使用轻量级锁,不确定使用AUTO-INC锁
  • 值为2,一律使用轻量级锁(不同事务自增列值交叉,主从复制不安全)

注意事项:

当插入表中有自增列时,数据库需要自动生成自增值,它会先为该表加 AUTOINC 表锁,阻塞其他事务的插入操作,这样保证生成的自增值肯定是唯一的

AUTOINC 锁具有如下特点:

  • AUTO_INC 锁互不兼容,也就是说同一张表同时只允许有一个自增锁;
  • 自增值一旦分配了就会 +1,如果事务回滚,自增值也不会减回去,所以自增值可能会出现中断的情况。

# 行锁

# 什么是行锁?

行锁是指上锁的时候锁住的是表的某一行或多行记录,其他事务访问同一张表时,只有被锁住的记录不能访问,其他的记录可正常访问;

不同存储引擎的行锁实现不同。

特点:粒度小,加锁比表锁麻烦,不容易冲突,相比表锁支持的并发要高

# 行锁的原理

行锁的原理和索引有关。

InnoDB 是聚簇索引,也就是 B+树的叶节点既存储了主键索引也存储了数据行。而 InnoDB 的二级索引的叶节点存储的则是主键值,所以通过二级索引查询数据时,还需要拿对应的主键去聚簇索引中再次进行查询。

单行记录行锁原理

下面以两条 SQL 的执行为例,讲解一下 InnoDB 对于单行数据的加锁原理。

#
聚簇索引执行修改
update user
set age = 10
where id = 49;
#
二级索引执行修改
update user
set age = 10
where name = 'Tom';
1
2
3
4
5
6
7
8
9
10

第一条 SQL 使用主键索引来查询,则只需要在 id = 49 这个主键索引上加上写锁;

第二条 SQL 则使用二级索引来查询,则首先在 name = Tom 这个索引上加写锁,然后由于使用 InnoDB 二级索引还需再次根据主键索引查询,所以还需要在 id = 49 这个主键索引上加写锁。

多行记录行锁原理

update user
set age = 10
where id > 49;
1
2
3

MySQL Server 会根据 WHERE 条件读取第一条满足条件的记录,然后 InnoDB 引擎会将第一条记录返回并加锁,接着 MySQL Server 发起更新改行记录的 UPDATE 请求,更新这条记录。一条记录操作完成,再读取下一条记录,直至没有匹配的记录为止。

当然这中间还有很多的优化,就不细细阐述了。

# 行锁的类型

根据锁的粒度可以把锁细分为表锁和行锁,行锁根据场景的不同又可以进一步细分,依次为 Next-Key Lock,Gap Lock 间隙锁,Record Lock 记录锁和插入意向 GAP 锁。

不同的锁锁定的位置是不同的,比如说记录锁只锁住对应的记录,而间隙锁锁住记录和记录之间的间隔,Next-Key Lock 则所属记录和记录之前的间隙。

行锁的范围

# 记录锁(Record Lock)

记录锁:事务在加锁后锁住的只是表的某一条记录。(官方命名LOCK_REC_NOT_GAP)

大致触发条件:

  • 精准条件命中,并且命中的条件字段是唯一索引。
    • 例如:update user_info set name=’张三’ where id=1 ,这里的id是唯一索引。
  • 当 SQL 语句无法使用索引时,会进行全表扫描,这个时候 MySQL 会给整张表的所有数据行加记录锁,再由 MySQL Server 层进行过滤。但是,在 MySQL Server 层进行过滤的时候,如果发现不满足 WHERE 条件,会释放对应记录的锁。这样做,保证了最后只会持有满足条件记录上的锁,但是每条记录的加锁操作还是不能省略的。

所以更新操作必须要根据索引进行操作,没有索引时,不仅会消耗大量的锁资源,增加数据库的开销,还会极大的降低了数据库的并发性能。

记录锁的作用:加了记录锁之后数据可以避免数据在查询的时候被修改的重复读问题也避免了在修改的事务未提交前被其他事务读取的脏读问题

# 间隙锁(Gap Lock)

间隙锁:在事务加锁后其锁住的是表记录的某一个区间,当表的相邻ID之间出现空隙则会形成一个区间,遵循左开右闭原则。

比如下面的表里面的数据ID 为 1,4,5,7,10 ,那么会形成以下几个间隙区间,-n-1区间,1-4区间,7-10区间,10-n区间 (-n代表负无穷大,n代表正无穷大)

间隙锁

**大致触发条件:**范围查询并且查询未命中记录,查询条件必须命中索引、间隙锁只会出现在REPEATABLE_READ(重复读)的事务级别中。

例如:对应上图的表执行 select * from user_info where id>1 and id<4 (这里的id是唯一索引) ,这个SQL查询不到对应的记录,那么此时会使用间隙锁。

注意事项:

  • Infimum:表示页面最小记录
  • Supremum:表示页面最大记录

间隙锁作用防止幻读问题

# 临键锁(Next-Key Lock)

临键锁:是INNODB的行锁默认算法,总结来说它就是记录锁和间隙锁的组合,临键锁会把查询出来的记录锁住,同时也会把该范围查询内的所有间隙空间也会锁住,再之它会把相邻的下一个区间也会锁住

**例如:**下面表的数据执行 select * from user_info where id>1 and id<=13 for update ;

会锁住ID为 1,5,10的记录;同时会锁住,1至5,5至10,10至15的区间。

img

**大致触发条件:**范围查询并命中,查询命中了索引。

**临键锁的作用:**结合记录锁和间隙锁的特性,临键锁避免了在范围查询时出现脏读、重复读、幻读问题。加了临键锁之后,在范围区间内数据不允许被修改和插入。

# 插入意向锁(LOCK_INSERT_INTENTION)

插入意向锁是一种特殊的间隙锁(Insert Intention Lock)表示插入的意向,只有在 INSERT 的时候才会有这个锁。

一个事务插入一条记录,需要判断插入位置是否被别的事务加了gap锁(包含next-key锁)。如果有的话,插入操作需要等待,直到加gap锁的事务提交。

# 隐式锁

一般执行Insert不需要在内存中生成锁结构(当然如果插入的间隙被其他事务加了gap锁,那么本次Insert操作会阻塞,当前事务会在间隙插入意向锁)。

但这样可能会出现问题。举例,先插入一条记录(无关联锁结构),然后如下情况:

  • 立即使用SELECT …… LOCK IN SHARE MODESELECT …… FOR UPDATE,进行锁定读,获取锁。如果允许,那么出现脏读怎么办?
  • 立即修改这条记录(获取锁),怎么办?

这些情况下,事务id要起作用了

对于聚簇索引:trx_id是一个隐藏列,记录最后改动的事务id。新插入记录,trx_id为当前事务的id,如果想加锁,会看trx_id是否是活跃事务。如果不是,正常获取;如果是,帮助当前事务建立X锁的锁结构,is_writing为false。然后自己也创建一个锁结构,is_writing为true,进入等待状态。

  • 对于二级索引: 二级索引页面的Page Header的PAGE_MAX_TRX_ID记录改动最大的事务id。如果PAGE_MAX_TRX_ID小于当前最小活跃事务id,表明已提交,否则需要定位的聚簇索引,然后执行上述的操作。

所以,一个事务新插入记录可以不显示加锁,这个事务id相当于加了一个隐式锁。别的事务加锁,由于隐式锁存在,会给当前事务生成一个锁结构,然后给自己也生成锁结构,并且进入等待状态。

隐式锁作用:延迟生成锁结构。如果事务执行不需要获取与该隐式锁相冲突的锁,可以避免建立锁结构。

特殊情况:

  1. 插入时遇到重复键会报错,但在报错前会加锁
    1. 主键(聚簇索引)重复,读提交下加记录锁,可重复读下加临键锁
    2. 二级唯一索引重复,加临键锁。
  2. 外键检查
    1. 待查记录在外键表中可以找到,父表给该记录加记录锁
    2. 找不到时,读提交不加锁,可重复读加gap锁。

# 加锁语句分析

# 普通SELECT语句

在不同隔离级别下,普通的SELECT语句具有不同的表现:

  • 在 读未提交 隔离级别下,不加锁,直接读取记录的最新版本;可能出现脏读、不可重复读和幻读现象。
  • 在 读已提交 隔离级别下,不加锁;在每次执行普通SELECT语句的时候会生成一个ReadView;这样避免了脏读现象,但是没有避免不可重复读和幻读现象。
  • 在 可重复读 隔离级别下,不加锁;只在第一次执行普通SELECT语句时生成一个ReadView,这样可以避免脏读和不可重复读,不能完全避免幻读问题(存在特殊情况:事务T1查询出10条记录,事务未提交;事务T2,插入一条记录,提交;事务T1修改了刚刚事务T2插入的记录,那么再次查询时,事务T1会查询出11条数据)。
  • 在 串行化 隔离级别下,分为两种情况:
    • 系统变量 autocommit=0时(禁用自动提交),普通的SELECT查询会转换为SELECT …… LOCK IN SHARE MODE 这样的语句。也就是在读记录前需要先获取记录的S锁。具体加锁情况与 可重复读 情况下一样。
    • 系统变量autocommit=1时,普通SELECT语句不加锁,只是利用MVCC生成一个ReadView读取记录。为什么呢?因为启动自动提交,意味着一个事务只包含一条语句,而执行一条语句不会出现不可重复读、幻读这样的现象。

# 锁定读的语句

# 锁定读的语句

  • 语句1:SELECT …… LOCK IN SHARE MODE;
  • 语句2:SELECT …… FOR UPDATE;
  • 语句3:UPDATE ……
  • 语句4:DELETC ……

语句1和2是MySQL中规定的两种锁定读的语法格式,而语句3和4在执行中需要先定位到被改动的记录并给记录加锁,因此也可以任务是锁定读。

在了解锁定读语句加锁之前,引入两个概念:匹配模式和唯一性搜索。

# 匹配模式

使用索引执行查询时,查询优化器首先会生成若干个扫描区间。针对每个扫描区间,可以快速定位到第一条记录,然后沿着这条记录所在的单链表可以扫描这个区间的其他记录,直到某条记录不在这个区间为止。如果被扫描的区间是一个单节点扫描区间,可以说此时的匹配模式是 ** 精确匹配**。

举例:联合索引idx_a_b(a,b),

  • 边界条件时 a=1,扫描区间为[1,1],是单节点扫描区间,属于精确匹配。
  • 边界条件时 a=1 AND b=1,扫描区间为[(1,1),(1,1)],是单节点扫描区间,属于精确匹配。
  • 边界条件时 a=1 AND b>=1,扫描区间为[(1,1),(1,+∞)],是单节点扫描区间,属于精确匹配。

# 唯一性搜索

如果扫描之前,事先知道扫描区间最多包含一条记录,把这种情况称为 唯一性搜索

怎么确定最多只包含一条记录:

  • 匹配模式为精确匹配
  • 使用的索引是主键或是唯一二级索引;如果使用的是唯一二级索引,搜索条件不能是索引列 IS NULL的形式(因为对一唯一二级索引列来说,可以存储多个值为NULL的记录)
  • 如果索引中包含多个列,那么在生成扫描区间时,每一个都得被用到

# 影响语句加锁的因素

  • 事务的隔离级别
  • 语句执行时使用的索引类型
  • 是否是精确匹配
  • 是否是唯一性搜索
  • 具体的语句类型(SELECT、UPDATE、INSERT、FELETE)

# 读取某个扫描区间中记录的加锁过程

读取某个扫描区间的记录加锁情况如下:

步骤1:首先快速地在B+树叶子节点中定位到该扫描区间中的第一条记录,把该记录作为当前记录。

步骤2:为当前记录加锁。

一般情况下,对于锁定读的语句,在隔离级别不大于 读提交 时,会为当前记录加记录锁。在隔离级别不小于 可重复读 时,会为记录加临键锁。

步骤3:判断索引下推的条件是否成立。

索引下推,是来把查询中与被使用索引有关的搜索条件下推的存储引擎中判断,而不是返回server层再判断。索引下推只是为了减少回表次数,只适用于二级索引和SELECT语句。

符合索引下推条件,跳到第四步继续执行;不符合获取当前记录的单链表的下一条记录,将该记录作为新的当前记录,跳回步骤二。另外步骤3还会判断当前记录是否符合形成扫描区间的边界条件,不符合跳过步骤4和步骤5,直接向server发送“查询完毕”的信息。

需注意,步骤3不会释放锁

步骤4:执行回表操作。

二级索引回表,查到聚簇索引并给该记录加记录锁。

步骤5:判断边界条件是否成立。

符合边界条件,继续执行步骤6;否则在隔离级别不大于读提交时,释放该记录锁,并向server层返回“查询完毕”的信息。

步骤6:server层判断其余搜索条件是否成立。

如果成立,将该记录发送到客户端;否则在隔离级别不大于读提交时,释放该记录锁。

步骤7:获取当前记录所在单链表的下一条记录,并将其作为新的当前记录,并跳回步骤2。

# 特殊情况下的加锁

1、UPDATE语句 与上述流程类似,不过,如果更新了二级索引,那么所有被更新的二级索引记录在更新之前都需要加 X型记录锁。

2、DETELE语句 与上述流程类似,不过,如果表中包含二级索引,那么删除记录前要加 X型记录锁。

3、精确匹配隔离级别不大于 读提交 时,则不会为扫描区间的后面下一条记录加 记录锁;隔离级别不小于 可重复读 时,会为扫描区间后面的下一条记录加 间隙锁

4、不是精确匹配,没有找到匹配的记录。当隔离级别不小于可重复读时,为扫描区间的后面下一条记录加 临键锁

5、当隔离级别不小于可重复读时,如果使用的聚簇索引,并且扫描的区间是左闭区间,而且定位到的第一条聚簇索引记录 与 扫描区间中最小值相同,那么会为该聚簇索引加 记录锁

6、无论哪个隔离级别,只要是唯一性搜索,并且读到的记录被标记为“已删除”,为读取到的记录加 记录锁

7、一般扫描记录是从左到右,如果扫描是从右到左,当隔离级别不小于可重复读时,匹配到第一条记录的下一条记录加 间隙锁

# 半一致性读语句

当隔离级别不大于读提交 且执行 UPDATE语句时,称为半一致性读。

即UPDATE语句读到已经被其他事务加了X锁记录时,InnoDB会把最新版本的记录读出来,然后判断该版本是否与UPDATE语句中搜索条件相匹配。

不匹配,不对该记录加锁,跳到下一条记录;如果匹配,再次读取该记录并加锁。

目的:尽量减少UPDATE语句被别的语句阻塞。

# INSERT语句

INSERT语句一般情况下不需要在内存中生成锁结构,并单纯依靠 隐式锁 保护插入的记录。

插入记录前需要先在B+树中定位到记录的位置,如果该位置的下一条记录被加了 间隙锁 或者 临键锁,那么当前事务会为该记录加上 插入意向锁

执行INSERT语句,在内存中生成锁结构的两种特殊情况:遇到重复键、外键检查。

1、遇到重复键

遇到重复记录的主键和唯一二级索引时会报错,但是在报错之前会给记录加 S锁

主键重复,在不同隔离级别的加锁类型不同:

  • 隔离级别为 读未提交 时,加的是 记录锁
  • 隔离级别不小于可重复读时,加的是 临键锁

二级唯一索引重复,不管什么隔离级别都加 临键锁。

2、外键检查

待插记录的外键值可以在父表中匹配到,给父表的这条记录加 记录锁。

如果在父表中没有匹配到,会插入失败,并且:

  • 隔离级别不大于读提交时,不加锁
  • 隔离级别不小于可重复读时,加 间隙锁。

# 加锁情况总结

  1. 普通SELECT语句不加锁,主要依赖MVCC机制。
  2. 锁定读情况:
    1. 一般情况下,在隔离级别不大于 读提交 时,会为当前记录加记录锁。在隔离级别不小于 可重复读 时,会为记录加临键锁
    2. UPDATE语句更新二级索引DELETE语句删除记录中包含二级索引,需要加 X型记录锁
    3. 精确匹配隔离级别不大于 读提交 时,则不会为扫描区间的后面下一条记录加 记录锁隔离级别不小于 可重复读 时,会为扫描区间后面的下一条记录加 间隙锁
    4. 不是精确匹配,没有找到匹配的记录。当隔离级别不小于可重复读时,为扫描区间的后面下一条记录加 临键锁
    5. 当隔离级别不小于可重复读时,如果使用的聚簇索引,并且扫描的区间是左闭区间,而且定位到的第一条聚簇索引记录 与 扫描区间中最小值相同,那么会为该聚簇索引加 记录锁
    6. 无论哪个隔离级别,只要是唯一性搜索,并且读到的记录被标记为“已删除”,为读取到的记录加 记录锁
    7. 一般扫描记录是从左到右,如果扫描是从右到左,当隔离级别不小于可重复读时,匹配到第一条记录的下一条记录加 间隙锁
  3. 半一致性读语句:UPDATE语句读到已经被其他事务加了X锁记录时,InnoDB会把最新版本的记录读出来,然后判断该版本是否与UPDATE语句中搜索条件相匹配。不匹配,不对该记录加锁,跳到下一条记录;** 如果匹配,再次读取该记录并加锁**。
  4. INSERT语句:
    1. 主键重复,在不同隔离级别的加锁类型不同:隔离级别为 读未提交 时,加的是 记录锁。隔离级别不小于可重复读时,加的是 临键锁
    2. 二级唯一索引重复,不管什么隔离级别都加 临键锁。
    3. 外键检查
      1. 待插记录的外键值可以在父表中匹配到,给父表的这条记录加 记录锁
      2. 如果在父表中没有匹配到,会插入失败,并且:隔离级别不大于读提交时,不加锁;隔离级别不小于可重复读时,加 间隙锁

# 查看事务的加锁情况

# 使用 information_schema 数据库中的表获取锁信息

在数据库 information_schema 中,有几个表和事务、锁有关。

1、INNODB_TRX:该表存储了InnoDB存储引擎当前正在执行的事务信息。包括:事务id,事务状态等。

其中重点有几个属性值得关注:

  • trx_tables_locked:表示该事务目前加了多少个表级锁
  • trx_rows_locked:表示该事务目前加了多少个行级锁(不包括隐式锁)
  • trx_lock_structs:表示生成该事物生成了多少个内存结构的锁

2、INNODB_LOCKS:该表记录了一些锁信息:

  • 如果一个事务想要获取到某个锁但未获取到,则记录该锁信息
  • 如果一个事务获取到锁,但是这个锁阻塞了别的事务,则记录该锁信息。

3、INNODB_LOCK_WAITS:表明每个阻塞事务是因为获取不到那个事务持有的锁而阻塞。

# 使用 SHOW ENINGE INNODB STATUS 获取锁信息

# 死锁问题

T1和T2都在等待对方先释放掉与自己需要的锁相冲突的锁,因此T1和T2都不能继续执行,此时就称发生了死锁。

例子:

发生时间编号 T1 T2
1 BEGIN;
2 BEGIN;
3 SELECT * FROM hero WHERE number=1 FOR UPDATE;
4 SELECT * FROM hero WHERE number=3 FOR UPDATE;
5 SELECT * FROM hero WHERE number=3 FOR UPDATE;
(此操作阻塞)
6 SELECT * FROM hero WHERE number=1 FOR UPDATE;
(死锁发生,记录日志,服务器回滚一个事务)

**死锁检测机制:**检测死锁,会选择一个较小的事务(增删改记录条数较少),并向客户端发送一条报错信息。

可通过语句 SELECT ENGINE INNODB STATUS 查看最近一次死锁发生的信息。

# 参看: