Innodb 加锁规则 及 8种锁

565 阅读18分钟

✒️本文所用数据库版本:8.0.39

🔗掘金观感不佳,可以前往语雀

1. 前言

hello,我是Anthonyzhao。

开始正文之前,需要一些简单的基础知识。放心,我会言简意赅的介绍😄。

1.1. 记录锁、间隙锁、临键锁、行锁、意向锁

  • 记录锁(Record Lock),锁住的是一个记录
  • 间隙锁(Gap Lock),锁住的是一个开区间
  • 临键锁(Next Lock), 锁住的是一个左开右闭的范围

如下图,有1、5、10、15四个记录,红色表示被加上了锁。

  • 行锁:你的印象中,MySQL存储的数据是怎样的?应该是类似于Excel的,是一行一行的,由多行组成一个表。对应到上图中,一行就是一个记录。以上三种锁你会发现其实就是在一个范围内对某几行数据及其间隙在加锁。这样的锁,我们称之为“行锁”。
  • 意向锁(IX、IS) :这里简单记,后边会细说。当一个表中有行锁时,这个表就会被添加一个标记,标识这个表有行锁存在。这个标记就是意向锁。其中IX表示 意向写锁,IS 表示 意向读锁。

1.2. 如何查看表被加了哪些锁

SELECT
    OBJECT_NAME "表名",
    INDEX_NAME "索引名",
    LOCK_TYPE,
    LOCK_MODE ,
    LOCK_STATUS ,
    LOCK_DATA
FROM
    performance_schema.data_locks;

  • X:写锁
  • REC_NOT_GAP:就是记录锁,表示的是加锁的区间范围
  • GAP:就是间隙锁
SHOW ENGINE INNODB STATUS;

1.3. 先创建个表

DROP TABLE IF EXISTS t;

CREATE TABLE `t` (
  `a` int NOT NULL COMMENT '主键',
  `b` int DEFAULT NULL COMMENT '创建唯一索引',
  `c` int DEFAULT NULL COMMENT '创建普通索引',
  `d` int DEFAULT NULL COMMENT '不创建索引',
  PRIMARY KEY (`a`),
  UNIQUE KEY `uq_idx_b` (`b`),
  KEY `idx_c` (`c`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;


INSERT INTO t VALUES
        (1, 1, 1, 1),
        (5, 5, 5, 5),
        (10, 10, 10, 10),
        (15, 15, 15, 15),
        (20, 20, 20, 20)

为了方便后续展示,这里转置了一下,每列表示一行数据,深色表示主键值。

2. 加锁规则

这里抛出一个基础:所有的锁都是加在索引上的

索引可以分为三类:主键索引,唯一索引,普通索引。

主键索引必然会存在,即便是没有为表设置主键(此时Innodb会生成默认的自增主键)。唯一索引和普通索引最终都会指向主键索引,典型的例子是回表查询,因为主键索引中是包含着全表数据的。如果用图形表示的话,可以表示为如下样子

不同隔离级别加锁规则不同,接下来细说

2.1. 读未提交 隔离级别

无论什么条件,什么索引,只加记录锁和表级意向锁,不会锁间隙。

记录不存在,则不加锁,表级意向锁也没有。

隔离级别+条件验证截图
读未提交主键索引等值查询
读未提交主键索引范围查询
读未提交主键索引记录不存在
读未提交普通索引等值查询
读未提交普通索引范围查询

2.2. 读已提交 隔离级别

无论什么条件,什么索引,只加记录锁和表级意向锁,不会锁间隙。

表级意向锁一定有

隔离级别 + 条件验证截图
读已提交主键索引等值查询
读已提交主键索引范围查询
读已提交主键索引记录不存在
读已提交普通索引等值查询
读已提交普通索引范围查询

2.3. 可重复读 隔离级别

2.3.1. 加锁规则

首先给出不同条件的加锁规则,然后case by case的给出具体例子。

  • 查询过程中访问到的对象都会加锁,而加锁的基本单位是next-key lock(左开右闭);
  • 加锁都是从左向右,一直加到第一个不满足条件的记录为止,然后开始优化。
  • 唯一索引和普通索引,范围查询不会进行任何优化。
  • 等值查询(=)和 主键索引 会对左右两侧进行优化。优化的原则是,有没有必要多锁一个记录,或多锁一个区间。

具体的我们在例子中来理解。

2.3.2. 条件:=5,主键索引

第一个满足条件的记录是5,从左侧第一个满足条件的记录开始加锁,加锁的基本单位是临键锁。一直到右侧第一个不满足记录10为止。然后开始优化。

先从左侧考虑,左侧的这个区间有没有可能有一个满足条件的记录插入进来?满足条件的记录应该就是再插入5,因为是唯一索引,不可能再有个5插入,因此也就没有必要锁住这个间隙。

再考虑右侧,10有必要锁住么?显然没必要,因为 10不满足条件。那 (5,10)这个开区间的间隙有必要锁住么?同左侧分析一样,也不能有个5再插入。也没有必要锁住这个间隙。

最终,加锁的结果是,在主键索引上加一个记录锁X没有间隙,锁住的数据是5。当然还有表级的意向锁IX


2.3.3. 条件:=5,唯一索引

从左侧第一个满足条件的开始加临键锁,一直加到第一个不满足条件的记录

然后开始优化

  • 左侧:唯一索引,左侧的间隙不可能再插入满足条件的记录,因此左侧间隙可以优化掉
  • 右侧:记录10 及其间隙也都不可能插入满足条件的记录,右侧的记录和间隙也可以优化掉

这里只需要给主键对应记录加X锁就可以。


2.3.4. 条件:=5,普通索引

加锁过程的结果与主键索引是一模一样滴,不同的是优化结果

由于是普通索引,左侧间隙是可以插入满足条件的记录的,因此,左侧的间隙不能优化。

右侧 10不满足条件,可以被优化。但是,(5, 10)这个间隙内也是可以插入满足条件的记录的,不能优化。

最终,普通索引加锁的结果是

再给其对应的主键索引加锁,最终的结果是


2.3.5. 条件:5<x≤15,唯一索引

注意,这是一个复合范围。分别按照左右单独看待就可以了。

唯一索引和普通索引,的范围查询不会有任何优化,最终在唯一索引上的加锁结果如下

但是,在对应的主键索引上是会优化的,没有必要锁住20这个记录,因此主键索引上对应的枷锁结果为

  • 唯一索引上: 10,10``15,15``20,20 分别加X锁
  • 主键索引上:10,10``15,15 分别加X锁,不带gap

2.3.6. 条件:=7, 不存在的记录

不存在的记录则仅会对满足条件的间隙加锁,不会优化。本例是以普通索引为例,唯一索引,主键索引也是一样的加锁规则。

2.3.7. 加锁全览

上边介绍了四种加锁case,过多的细节也不再赘述了。贴上一张图,你可以随意组合条件,自己尝试判断一下会加什么锁,然后再下图中验证一下。

2.4. 序列化 隔离级别

amazing 啊,可重复读和序列化隔离级别写操作加的锁居然是相同的。

事实上,RR 隔离级别通过多版本并发控制(MVCC)和 next-key lock 已经能提供足够的隔离性

两种隔离级别,主要区别在于自动提交模式下的行为

  • 在 Serializable 隔离级别下,InnoDB 会将所有普通 SELECT 语句隐式转换为 SELECT ... FOR SHARE
  • 而在 RR 隔离级别下,普通 SELECT 是快照读,不加锁

2.5. 只有被访问到的索引才会加锁

之前的所有例子,都说在给普通索引和唯一索引加锁时,也会给主键索引加锁。这是不一定的,我们看以下两个sql语句

SELECT a,b FROM t WHERE b=5 FOR UPDATE;
SELECT a,b FROM t WHERE b=5 lock in share mode;

语句1是会给主键加锁的,而语句2是不会给主键加锁的。验证一下

原因是,语句2只需要访问唯一索引就可以获得所有要查询的数据,不要回表。而语句1是要更新表,必然要更新记录所在的主键索引。因此,需要访问主键索引。

下边给出一个lock in share mode需要回表的例子

SELECT a, b, c, d FROM t WHERE b=5 lock in share mode;

3. Innodb 中的锁

对于锁本身的分类,无关与Innodb,我更喜欢将其分为两类:

  • 锁的类型:从兼容性方面考虑,排他和共享,这是锁本身的功能。
  • 锁的范围:要锁住哪些内容。下图中的行锁和表锁及其细分锁,都是锁住的范围不同。

3.1. 共享锁、排他锁

共享锁:允许多个事务同时读

排他锁:独占数据,禁止其他事务读、更禁止其他事务写

特性共享锁(S Lock)排他锁(X Lock)
别名读锁(Read Lock)写锁(Write Lock)
并发性允许多事务同时持有仅允许一个事务持有
操作权限只读(不能修改)可读可写
兼容性与共享锁兼容,与排他锁不兼容与任何锁都不兼容
典型SQLSELECT ... LOCK IN SHARE MODESELECT ... FOR UPDATE
自动加锁需要显式加锁INSERT/UPDATE/DELETE 自动加锁

3.2. 意向锁

考虑这样一个场景:进程A对表t的某一行添加了写锁,进程B要对该表加表锁 lock tables t write。而表的写锁和记录的写锁是互斥的,进程B在加表锁是,就需要知道该表的每一行记录有没有被加写锁。难道要让进程B遍历每一行记录来判断么?聪明的你一定会想到,如果进程A加行锁时,在一个公共变量记一下,表t现在有行锁,那进程B直接判断这个公共变量不就可以了么。

是滴,意向锁实现的就是这样一个作用。意向锁其实就是这样一个标记。

3.3. 记录锁、间隙锁、临键锁

  • 记录锁(Record Lock) ,锁住的是一个记录
  • 间隙锁(Gap Lock) ,锁住的是一个开区间
  • 临键锁(Next Lock) , 锁住的是一个左开右闭的范围

如下图,有1、5、10、15四个记录,红色表示被加上了锁。

3.4. 插入意向锁

插入意向锁是一种特殊的间隙锁,既然叫意向锁,当然它也是一个标记。接下来细说间隙锁和插入意向锁的区别。

对比项间隙锁插入意向锁
锁的范围- 锁定索引值之间的间隙,而非具体的索引值。例如,若表中有索引值 10 和 20,间隙锁可能锁定区间 (10, 20)(-∞, 10)(20, +∞)
  • 示例:在 REPEATABLE READ 隔离级别下,查询 SELECT * FROM table WHERE id BETWEEN 10 AND 20 FOR UPDATE 可能会对间隙 (10, 20) 加间隙锁,阻止其他事务插入 10<id<20 的记录。 | - 是间隙锁的一种特殊类型,但仅在插入操作时使用。
  • 锁定的间隙与插入值相关。例如,插入 id=15 时,会在间隙 (10, 20) 上加插入意向锁,但仅针对 15 这个点的插入意图。 | | 兼容矩阵 | - 间隙锁与间隙锁兼容
  • 插入意向锁与谁都不兼容,包括自己 | |

考虑如下case:

  • 事务A 插入 a=6,添加插入意向锁
  • 事务B 插入 a=7,能否顺利插入?

答案是可以的,如下图。因为插入意向锁针对的是区间的某个点,而不是整个区间。这也算是对并发插入的优化。

3.5. 元数据锁(MDL, meta data lock)

首先简单介绍一下什么是元数据。

在 MySQL 里,元数据指的是数据库自身的结构信息,并非用户存储的实际数据。这些信息涵盖数据库、表、列、索引、用户权限等的定义和属性。

MySQL 把元数据存于系统数据库和特定的表中,例如:

  • information_schema:这是一个特殊的数据库,其中包含了所有数据库对象的元数据,像表、列、索引、视图等。我们建表语句的字段类型、长度以及评论等关于字段的属性信息就存在information_schema.columns这张表里。例如,本文刚开始创建的那张表

  • performance_schema:是一个用于监控和分析数据库运行时性能的系统数据库。它存储了实时性能数据和服务器内部执行信息,例如锁信息。同样是本文开始,介绍了如何查看表被加了哪些锁,就是从performance_schema.data_locks查的。
SELECT
    OBJECT_NAME "表名",
    INDEX_NAME "索引名",
    LOCK_TYPE,
    LOCK_MODE ,
    LOCK_STATUS ,
    LOCK_DATA
FROM
    performance_schema.data_locks;

言归正传,继续说元数据锁。想像这样一个场景:一个事务正在写一张表,另一个事务把这张表的字段删了,那不就尴尬了么😥。即便是,一个事务正在读一张表,另一个事务把字段删了也不行啊。为了防止此类事情发生,CURD语句都会给表结构(表的元数据)加锁。元数据锁的信息存放在 performance_schema.metadata_locks中。

为什么右侧会查出两行锁记录?因为这些数据时从表performance_schema.metadata_locks查出来,执行查询语句时,也会给这个表加元数据锁。

元数据锁也分读锁写锁,但是这里的读写指的是对元数据的读写。上图展示的就是读锁,我们是在访问表结构;当变更表结构时,加的就是写锁。那你觉得像表中写入数据是对元数据加什么锁?也是读锁。所以,DML语句加的都是元数据读锁;DDL语句加的才是元数据写锁。

元数据的读写锁兼容互斥原则与正常的锁有些不同:

  • 相同内容:读读兼容、读写互斥、写写互斥。
  • 不同内容:写锁的优先级高于读锁。

关于写锁的优先级高于读锁,你可以理解为存在一个加锁队列,如下图。先到达的三个读锁,兼容,可同时获得锁。有一个写锁到达后,读写互斥,写锁要等待前三个读锁释放。后续到达的读锁,由于前边有写锁,也要等待。不会因为,后续到达的是读锁,和先到达的三个读锁兼容而获得锁。

由上可以得到已给结论,当表结构变更时,数据表不可读、不可写。完犊子了,那整张表就相当于是锁住了呀,有点危险。 尤其是当访问量比较大时,连接很快就会被打满的。那是不是访问量很大的表,即便是仅有几个字段、几百条数据的小表,执行DDL语句也很危险呀?理论上是的。

但还有另外一种技术以应付该问题, online DDL,已经是MySQL的默认设置,感兴趣可自行研究一下:dev.mysql.com/doc/refman/…

3.6. 自增锁和自增值

3.6.1. 自增锁基础知识

出于对性能的考虑(页分裂、页合并),我们在建表一般会自定义非业务主键,并使其自增(Auto Increment)。当多进程并发时,MySQL就是通过自增锁来保证插入时的自增值不重复滴。

既然与插入有关,那么我们就从插入说起。如下是MySQL支持的三种插入方式

插入方式解释
简单插入可以预估插入行数,例如普通的 insert/replace into但不包含插入冲突处理语句 insert ... on duplate key update
批量插入无法预估插入行数的语句,例如- insert into user select * from temp_user;
  • replace into users (id, name) values (1, 'anthonyzhao');
  • load data 语句 | | 混合额插入 | 部分指定了自增键,部分未指定自增键- insert into user(id, name) values(null, 'Alice'),(3, 'Bob'); |

MySQL 自增锁利用名为auto-increment lock的互斥锁实现。执行插入语句时,MySQL自动获取锁,无需人工干预。

需要注意的是,即使多个insert语句在同一事务中,每次insert也会独立申请和释放自增锁,与事务提交或回滚无关。

关于自增锁,MySQL提供了三种模式,通过 innodb_autoinc_lock_mode 设置:

模式解释
传统模式innodb_autoinc_lock_mode=0执行语句时加表级锁,语句执行后释放。表锁,锁颗粒度大,比较重
连续模式innodb_autoinc_lock_mode=1- 批量插入时,加表级锁
  • 简单插入时,使用轻量的页锁,只针对待插入记录所在页加锁8.0版本之前的默认设置。 | | 交叉模式innodb_autoinc_lock_mode=2 | 不使用auto-increment lock表级锁 ,而是使用一个轻量级的乐观锁,来一个insert语句分配自增值,真正执行插入时则检查该自增值是否已经被使用,如果被使用了,则重新申请自增值。由于锁的粒度减小,多条语句在插入时进行锁竞争,自增长的值可能不连续。并且当 Binlog 模式为 statement(SBR)时, 直接导致主从之间同行的数据**主键 ID 不同。**8.0 版本默认设置 |

3.6.2. 自增实践

我们先建一个有自增id的表

CREATE TABLE users (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  username VARCHAR(50) NOT NULL UNIQUE,
  update_version int not null default 0
);

插入数据后可以通过建表语句来查看当前自增值。自增值是一个单独的变量auto_increament,插入语句执行前直接将auto_increament的值分配给插入语句,然后变更auto_increament。如下图,下一个插入语句得到的自增值将会是2。

举一个我公司实际的例子,这是我们组内的一张表,包括自增id和业务唯一键等其他字段。业务背景是,我们要从第三方公司拉取数据插入到该表中,当业务唯一键冲突时则更新行。


不知道你看到上述结果是否和我有同样的疑问,明明表中没有那么多数据,为什么自增id这么大?而且自增id怎么不连续呢?

先解释自增id为什么会这么大。我们刚刚建的表现有数据如下

现在我们再执行如下插入语句

INSERT INTO users (username, update_version)
VALUES ('anthonyzhao', 0)
ON DUPLICATE KEY UPDATE
update_version = update_version + 1;

该插入语句获得的自增id是2,但是由于唯一键冲突,只会更新记录。此时,自增值2因为被分配下去了,即便是没有使用,也不会被改回去。

所以,现象就是,自增值增加了,而记录数没有增加。

现在自增值不连续的问题也知道为什么了。

细心的你可能会问,那我过我插入后回滚了呢?自增值会不会回退呢?答案是不会的,还是那句话,即便是没有使用,auto_increament 也不会被改回去。

此时,我就开始担心了,当自增id达到最大值怎么办?

这会分两种情况

  • 建表时显示设置自增了id:达到最大值4294967295后,再插入数据自增值也不再增加了,插入数据会提示主键冲突

  • 未设置主键:达到最大值后会重新从零开始。这是因为,隐式主键row_id虽然是一个8字节无符号长整型,但是实际上只使用6位。即达到6位最大值后继续自增,这6位都会变成零。

4. 两个由间隙锁引发的死锁

4.1. 临键锁加锁过程并非原子滴

可重复读隔离级别下,考虑以下执行步骤,为什么会发生死锁

事务A事务B
1begin;select a from t where c = 10 for udpate;
2update t set d = d + 1 where c = 10;(blocked)
3insert into t value(8, 8, 8, 8);
4ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

这里边关键的一点就是,临键锁的加锁过程并非是原子的,间隙锁和记录锁可以分步获得。具体的解释如下

事务A事务B解释
1begin;select a from t where c = 10 for udpate;
2update t set d = d + 1 where c = 10;(blocked)事务B此时已获取到 5~10之间的间隙锁,等待事务A释放10
3insert into t value(8, 8, 8, 8);事务A等待事务B释放 5~10之间的间隙锁,循环等待,死锁
4ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

4.2. 插入意向锁和间隙锁互斥

小林coding中有一篇文章,讲的是字节的面试题,内容就是关于间隙锁引发死锁的问题。

可重复读场隔离级别下会发生什么?