MySQL笔记:InnoDB的锁机制

MySQL 为什么需要锁?

在并发场景下,锁是一种保证线程安全的方式。而对于 MySQL 数据库来说,当并发场景下多个请求需要同时操作一个数据时,也就是多个事务同时操作同一行记录时,可能会存在 脏读、不可重复读、幻读、丢失更新 等事务并发问题。那么这些问题 MySQL 是如何解决的呢?

MySQL 数据库默认的事务隔离级别是 可重复读(RR)。对于事务的并发问题,MySQL 的 读操作 依靠 快照读(基于 MVCC 实现)和 当前读(加锁实现) 来避免,而 写操作 则依靠 加锁 的方式来避免。

所以 MySQL 需要 来避免事务的并发问题,保证数据的一致性和有效性。

因为 MySQL 中不同存储引擎使用的锁机制各不相同,像 MyISAM 使用的是表级锁,而 InnoDB 使用的是多粒度锁机制,既支持表级锁,也支持行级锁。这篇文章以 MySQL 的默认存储引擎 InnoDB 存储引擎展开,下面将具体介绍 InnoDB 的锁机制


InnoDB 的锁机制

为了可以更好地了解 InnoDB 的锁机制,下面将结合例子来依次介绍 InnoDB 的各种锁 以及 MySQL 是怎么加锁的

先来看看当前 MySQL 的版本以及事务隔离级别:

使用 select @@version; 查看数据库版本为 8.0.26

使用 select @@transaction_isolation; 查看事务隔离级别为 可重复读(RR)

创建测试表用于执行和观察后续的加锁操作:

CREATE TABLE `products`  (
  `id` int(11) NOT NULL,
  `name` varchar(50) NOT NULL,
  `stock` int(11) NOT NULL,
  `price` decimal(10, 2) NOT NULL,
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE INDEX `unique_name`(`name`) USING BTREE,
  INDEX `index_price`(`price`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
复制代码

其中 id 字段为主键索引,name 字段为唯一索引,price 字段为普通索引。

初始化数据后查看如下:


如何查看事务加锁情况

初始化好环境后,接下来了解一下如何查看事务加锁情况。

方便查看加锁情况的两种方式(文章中使用第一种方式):

  1. 将事务提交设置为手动提交(使用 select @@autocommit=0; 关闭自动提交(可以通过 select @@autocommit; 查看是否设置成功))。

  2. 每次在执行语句之前加上 begin;start transaction 开启事务,查看加锁情况后再使用 commit; 手动结束事务。

使用 select ... for update 加锁:

使用 select * from performance_schema.data_locks\G; 可以查看事务的加锁情况

可以看到里面有很多信息,只需要关注下面属性即可:

  • OBJECT_NAME:执行语句操作的数据库表的表名。
  • INDEX_NAME:对应的锁住的索引的名称。
  • LOCK_TYPE:对应的锁类型(表级锁或行级锁)。
  • LOCK_MODE:对应的锁模式(也就是具体的锁)。
  • LOCK_STATUS:对应锁的状态(GRANTED或者WAITING)。
  • LOCK_DATA:对应锁住的数据。

所以可以使用 select OBJECT_NAME,INDEX_NAME,LOCK_TYPE,LOCK_MODE,LOCK_STATUS,LOCK_DATA from performance_schema.data_locks; 更简易的查询需要的信息即可。


行级锁

InnoDB 存储引擎在实现行级锁时,按照 使用方式 可以分为 共享锁排他锁 两类,按照 锁的模式 又可以分为 记录锁、间隙锁、临键锁 三种。下面将依次介绍这些锁的作用。

共享锁与排他锁

为什么说共享锁与排他锁是按照 使用方式的不同划分 的呢?因为使用 不同的 SQL 语句 加的锁不同。接下来看看到底哪些常用的 SQL 语句是加共享锁的,哪些常用的 SQL 语句是加排他锁的。

共享锁(S 锁)

共享锁:也叫 S 锁读锁,在事务下要读取一条记录时,需要先获取该记录的共享锁。

可以通过 select ... lock in share mode 对读取的记录加上共享锁。

例子:使用 select * from products where id = 1 lock in share mode; 通过 主键索引 查询记录。

(锁的信息目前只需要关注当前讲解的信息,当然这条语句还有别的信息,这些会在下文依次讲解清楚)

可以看到第二行中的 锁类型行级锁,并且 锁模式 中是 S 锁(共享锁)

排他锁(X 锁)

排他锁:也叫 X 锁写锁,在事务下要修改一条记录,需要先获取该记录的排他锁。

可以通过 select ... for updateupdatedelete 等语句对记录加上排他锁。

例子:使用 select * from products where id = 1 for update;update products set stock = 99 where id = 1; 通过主键索引查询和修改记录。

可以看到第二行中 锁类型行级锁,并且 锁模式 中是 X 锁(排他锁)

值得注意的三点

第一点:需要注意加锁语句中并没有普通的 select 语句,因为普通 select 语句是基于 MVCC 实现的,并不需要加锁(想要了解 MVCC 的可以阅读这篇文章 MySQL学习笔记之MVCC的实现 - 掘金 (juejin.cn))。

第二点:需要注意加排他锁的语句中并不包含 insert 语句(这里为什么没有排他锁可以看下文的插入意向锁内容)。

第三点:行级锁是在事务提交后,锁就会被释放。

兼容关系

相信大家一定听过 读读共享,读写互斥,写写互斥 吧!

共享锁与排他锁的兼容关系(✔ 表示兼容, ❌ 表示不兼容):

如果一个事务给同一张表的某一记录加 共享(S)锁

  • 别的事务可以获取该表中的该记录的 共享(S)锁
  • 别的事务不可以获取该表中该记录的 排他(X)锁

如果一个事务给同一张表的某一记录加 排他(X)锁

  • 别的事务不可以获取该表中的该记录的 共享(S)锁
  • 别的事务不可以获取该表中该记录的 排他(X)锁


说完了按使用方式分成的共享锁与排他锁,接下来讲讲为什么按照 锁的模式 又可以分为 记录锁、间隙锁和临键锁

MySQL 在 可重复读(RR) 的事务隔离级别下是可以防止 幻读的现象 的,而这个依靠的是 MVCC临键锁(记录锁+间隙锁) 实现的。而在 MySQL 的事务隔离级别是 读已提交(RC) 下,是不会出现 间隙锁和临键锁 的。所以也就是说在 RR 的情况下,锁才会分为 记录锁、间隙锁和临键锁 这三种锁,那么究竟在什么情况下会加什么样的锁呢,这个问题将在下文结合这三种锁的介绍中讲解。

问题一:在事务隔离级别为 RR 的情况下,什么场景会导致临键锁可以退化为记录锁或间隙锁呢?

当加 记录锁或者间隙锁 就能使得该 SQL 语句不存在 幻读现象 时,临键锁 就会退化为记录锁或间隙锁

幻读现象 指定是在一个事务中多次执行同一查询语句,由于其他事务的插入或删除操作,导致当前事务的前后两次查询返回的结果集(记录数量)不一致。

问题二:怎么查看锁是临键锁还是记录锁还是间隙锁呢?

根据 select * from performance_schema.data_locks\G; 中的 LOCK_MODE 可以判断:

  • X,REC_NOT_GAP:表示该锁是记录锁(Record Lock)
  • X, GAP:表示该锁是间隙锁(Gap Lock)
  • X:表示该锁是临键锁(Next-Key Lock)

了解以上问题的答案后,后面就可以清晰地看出为什么在不同情况下加锁不同了。接下来将依次介绍这三种锁以及出现这三种锁的情况。

记录锁

记录锁(Record Lock):最简单的行锁,锁住的是一条记录,按照不同的 SQL 语句可以分为 S 型记录锁X 型记录锁。S 型记录锁 与 X 型记录锁同样存在读读共享、读写互斥、写写互斥的兼容关系。

下面以 X 型记录锁为例看看什么情况下会加记录锁(注意下面的例子并不是全部情况):

根据主键索引等值查询并且记录存在

执行 select * from products where id = 1 for update; 查看加锁情况:

可以看到 X 型记录锁是加在主键索引上的,说明通过主键索引等值查询且记录存在的情况下会在该记录的主键索引上加记录锁。并且可以看到 LOCK_DATA 为 1,因为使用主键索引加锁时会使得 LOCK_DATA 为主键值。

为什么根据主键索引等值查询在记录存在的情况下加的锁是记录锁呢?

由于是主键索引的等值查询并且当前记录存在,所以因为主键冲突不存在新增记录情况,那么出现 幻读现象 的可能只能是当前记录被删除,那么此时完全用不着临键锁,只需要记录锁锁住当前记录即可保证其他事务不会删除当前记录,因此保证不会出现幻读现象。所以临键锁也就退化为记录锁了。

根据唯一索引等值查询并且记录存在

执行 select * from products where name = 'iPad Pro' for update; 查看加锁情况:

可以看到 X 型记录锁是加在唯一索引主键索引上的。使用唯一索引(二级索引)会使得加锁时先去唯一索引中加锁,再通过唯一索引找到主键索引,再去主键索引上加锁。

为什么需要在两个索引上都加锁?

之所以在两处加锁的原因是因为如果主键索引上不加锁,并且存在 SQL 语句不是通过唯一索引但是却要去修改或删除对应主键索引的值,也就是说这条 SQL 语句并不能感知到当前记录已经被加锁,此时就会导致加锁失效存在并发问题。所以需要在主键索引上加锁。

为什么两处索引上加的都是记录锁?

因为唯一索引的等值查询并且记录存在的情况下,因为唯一索引(唯一冲突)不会有新增记录,那么出现 幻读现象 的场景只能是当前记录被删除,所以只需要加一个记录锁就可以保证当前记录不会被其他事务修改或删除,所以临键锁可以退化为记录锁。


间隙锁

间隙锁(Gap Lock):用于锁住一个开区间(出现在这个区间内的所有索引值对应的记录都会上锁),这个区间可以是 两个索引之间(a,b)、第一个索引之前(-∞,a)、最后一个索引之后(b,+∞)。虽然间隙锁也分为 S 型和 X 型,但是间隙锁之间是都兼容的,不存在互斥关系,多个事务可以同时持有相同区间的间隙锁

下面以 X 型间隙锁为例看看什么情况下会加间隙锁(注意下面的例子并不是全部情况):

根据主键索引等值查询但是记录不存在

执行 select * from products where id = 3 for update; 查看加锁情况:

当记录不存在时,加锁过程是会去主键索引树中找第一条大于该查询记录索引值的记录,因此可以看到 LOCK_DATA 为 4,并且以该点为区间右边界,而左边界是当前 id 为 4 的记录的上一条记录,也就是 id 为 2 的记录(注意都是开区间)。

为什么这种情况下只需要加间隙锁?

因为主键索引等值查询且记录不存在,那么出现 幻读现象 的可能只能是当前记录被插入,又因为当前记录不存在,所以没办法在当前记录上加记录锁。

为什么不用加临键锁呢,因为如果临键锁(锁住左开右闭区间)其实会把 id 为 4 的这条记录也上锁,但是在这里完全没有必要,因为这里是等值查询,只要保证 id 为 3 的记录不会被插入即可。所以这里加的间隙锁 (2,4) 已经可以保证区间内的 3 不会被其他事务操作。所以临键锁退化成间隙锁。

如果是唯一索引等值查询但记录不存在呢?

执行语句查看加锁情况可以看到如果记录不存在时,只会在唯一索引上加间隙锁,正是因为记录不存在所以也找不到主键索引,所以也就没有在主键索引上加锁这么一说。


临键锁

临键锁(Next-Key Lock):由记录锁+间隙锁组成,锁住的一个左开右闭的区间(出现在这个区间内的所有索引值对应的记录都会上锁)。临键锁也分为 S 型和 X 型,并且 S 型临键锁 与 X 型临键锁同样存在读读共享、读写互斥、写写互斥的兼容关系。

下面以 X 型临键锁为例看看什么情况下会加临键锁(注意下面的例子并不是全部情况):

根据主键索引等值查询大于表中最大记录的记录

从上面的间隙锁的案例中如果将查询的记录设置为大于表中的最大记录时,会出现什么样的情况呢?

根据上面的案例可以知道当查询记录不存在时,加锁会去找第一条大于该查询记录索引值的记录,那如果找不到会出现什么情况呢?

下面执行 select * from products where id = 9 for update; 查看加锁情况:

这里 supremum pseudo-record 表示最大界限伪记录,可以理解为正无穷大。因为已经找不到比当前查询记录 id=9 还要大的记录作为右边界了。那么这种情况为什么加的是临键锁呢?

因为右边界都已经正无穷大了,这个时候间隙锁和临键锁可以看成一样的,没必要降级了,所以一般 LOCK_DATA 给出 supremum pseudo-record,基本加的都是临键锁。

根据普通索引等值查询并且记录存在

执行 select * from products where price = 799.99 for update;,查看加锁情况:

可以看到在主键索引上加了记录锁,并且在普通索引上加了间隙锁和临键锁,具体如图:

为什么普通索引等值查询且记录存在时需要这样加锁呢?

记录存在的前提下肯定是需要去主键索引上加记录锁避免其他事务将当前记录删除或修改的,至于为什么普通索引上需要加临键锁和间隙锁呢,因为普通索引不像唯一索引,它允许有多个相同的值

举个例子,在不加临键锁和间隙锁时,如果别的事务插入一条 (id = 3,price=799.99) 的数据时,是不是就会造成 幻读现象。所以需要在普通索引的 799.99 这个值的前后范围(包括自身)都上锁,这样才能避免造成 幻读现象,所以在 (399.99,799.99] 上加临键锁,在 (799.9,999.9) 上加间隙锁。


不走索引加锁的情况

通过上面三种锁的案例可以看到每一条 SQL 都是走索引加锁的,其实 InnoDB 的行级锁是通过给索引上的索引项加锁来实现的,如果使用的是主键索引,只需要在主键索引上给索引项加锁即可,如果使用的是二级索引(唯一索引、普通索引)则会在二级索引上的索引项以及根据二级索引找到主键索引上的索引项都进行加锁。那么如果不使用索引会出现什么样的情况呢

执行 select * from products where stock = 50 for update;,查看加锁情况:

可以看到如果加锁的 SQL 语句中没有使用索引作为查询条件或者索引失效的话,会导致扫描变成全表扫描,带来的后果就是每一条记录的索引都会加上临键锁,这样会导致锁住整张表

所以在使用加锁的 SQL 语句时,要使用索引并且保证索引不会失效,否则会导致锁住整张表


插入意向锁

什么时候会加插入意向锁?

当事务 A 需要执行 insert 语句插入一条记录时,会先判断插入位置是否有其他事务的间隙锁。如果此时刚好事务 B 存在一个间隙锁(这个间隙锁的区间正是事务 A 需要插入的位置),此时会在插入位置生成一个插入意向锁(注意只是生成,并没有获得,此时插入意向锁的状态是 WAITING 状态),由于间隙锁与插入意向锁存在锁冲突(不兼容问题),所以此时事务 A 执行的插入语句会阻塞。直到事务 B 提交事务释放间隙锁,事务 A 才得以执行插入语句,并且此时插入意向锁的状态为 GRANTED 状态

过程图如下:

插入意向锁有什么作用?

看到这里不知道是否明白插入意向锁的作用,回想一下前文中 insert 语句是不是除了一个表级的意向锁之外没有任何其他的锁。

我的理解是因为 insert 没有像间隙锁这样的排他锁束缚,多个事务同时向同一个索引的同一个区间中插入数据时,事务之间是不需要等待阻塞的。而插入意向锁的作用主要是保证与其他事务间隙锁冲突时,阻塞当前插入语句,保证其他事务不会出现幻读现象

也就是说因为插入意向锁的存在,insert 语句不需要额外加间隙锁导致锁冲突导致阻塞区间内其他的插入语句而是可以支持并发无阻塞地在同一个区间内执行多条插入语句,同时插入意向锁又保证了不会导致其他事务出现幻读现象。


表级锁

介绍完 InnoDB 的行级锁之后,InnoDB 同样也支持表级锁,下面将介绍常见的表级锁

表锁

表锁指的就是直接在表上加锁,锁住指定的表。表锁也可以分为 共享型(S)表锁排他型(X)表锁

使用 lock tables 表名 read; 可以给指定的表加上共享型表锁。

使用 lock tables 表名 write; 可以给指定的表加上排他型表锁。

可以通过 unlock tables 或者 直接关闭会话 的方式释放所有的表锁。

表级锁和行级锁是可以共存的,但是满足读读共享、读写互斥、写写互斥的关系。

如果当前事务给表加上了 S 型表锁,则:

  • 其他事务对该表的读操作不会阻塞,也就是可以获取表中记录的 S 型行级锁
  • 其他事务对该表的写操作会阻塞,也就是不可以获取表中记录的 X 型行级锁

如果当前事务给表加上了 X 型表锁,则:

  • 其他事务对该表的读操作会阻塞,也就是不可以获取表中记录的 S 型行级锁
  • 其他事务对该表的写操作会阻塞,也就是不可以获取表中记录的 X 型行级锁


意向锁

意向锁是用来加快判断表中是否有记录被加了行级锁。意向锁可以分为 共享型意向锁(IS) 和 排他型意向锁(IX)。意向锁是表级锁,但是不会和行级锁发生锁冲突并且意向锁之间也不会有锁冲突。意向锁只会和表锁存在读读共享、读写互斥、写写互斥的锁冲突问题

事务对表中的记录加 共享锁(S 锁) 行级锁时,会先给表上加上一个 共享型意向锁(IS 锁) 表级锁。

事务对表中的记录加 排他锁(X 锁) 行级锁时,会先给表上加上一个 排他型意向锁(IX 锁) 表级锁。

意向锁的作用是可以在事务需要向表中加表锁时,会先去判断是否有意向锁,如果有意向锁则会出现锁冲突从而阻塞向表中加表锁的操作,这样就不需要加表锁时要去遍历表中的所有记录看看是否有加行级锁的。所以意向锁的作用就是加快了判断表里是否有记录被加锁。


自增锁

自增(AUTO-INC)锁是用来实现主键自增的,是一种特殊的表级锁。

创建表时可以通过对主键字段声明 AUTO_INCREMENT 的属性开启自增,此后再插入新数据时,可以不指定主键的值,而是通过 MySQL 自动给主键赋予自增的值,这个赋值的过程是通过自增锁保证的。

实现过程:事务插入新数据时,会加上一个表级自增锁,这样其他事务执行的插入语句就会被阻塞,直到主键字段被赋值成功后等到插入语句结束,才会释放掉表级自增锁。这样就可以保证主键字段的值是连续递增的。

可以看到自增锁是在插入语句执行完后才释放的,如果有大量的插入语句时会导致性能下降,所以 InnoDB 提供了一种轻量级互斥锁来完成主键自增赋值。

可以通过 innodb_autoinc_lock_mode 系统变量来控制使用哪种锁来保证主键自增赋值:

  • innodb_autoinc_lock_mode=0:完全使用传统模式,也就是自增锁的方式,加锁后在执行完插入语句才会释放锁。
  • innodb_autoinc_lock_mode=1:对于插入前能确定插入行数的语句,采用轻量级互斥锁的方式实现,对于插入前不能确定插入行数的语句,则采用自增锁的方式。
  • innodb_autoinc_lock_mode=2:完全使用轻量级互斥锁的方式,加锁后当主键完成自增赋值时便会释放锁,不需要等到插入语句结束。
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值