MySQL是怎么提高并发度的?InnoDB为什么会设计这些锁?一篇文章串通MySQL各种锁!

锁是计算机协调多个进程或线程并发访问某一资源的机制。在数据库中,除传统的计算资源(CPU、RAM、I/0)的争用以外,数据也是一种供许多用户共享的资源。如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。从这个角度来说,锁对数据库而言显得尤其重要,也更加复杂

MySQL中的锁,按照锁的粒度分,分为一下三类:

  1. 全局锁:锁定数据库中的所有表
  2. 表级锁:每次操作锁住整张表
  3. 行级锁:每次操作锁住对应的行数据

相信不管是在工作中,还是再学习中,都会涉及到 MySQL 的锁,

本篇文章重点讨论 MySQL 的表级锁和行级锁

1 全局锁

全局锁就是对整个数据库实例加锁,加锁后整个实例就处于只读状态,后续的 DML 的写语句,DDL 语句,已经更新操作的事务提交语句都将被阻塞

其典型的使用场景是做全库的逻辑备份,对所有的表进行锁定,从而获取一致性视图,保证数据的完整性

基本操作:

使用全局锁:

FLUSH TABLES WITH READ LOCK;

释放全局锁:

UNLOCK TABLES;

备份数据库内容:

mysqldump -h -u -p... 数据库名 [表名1] [表名2] > 备份的位置  -- 不建议直接这么使用
-- 示例
mysqldump -h localhost -u root -p 123456 learn_mysql emp dept > G:/emp_copy.sql

不加锁的一致性备份,加参数: --single-transaction

mysqldump --single-transaction  -h localhost -u root -p 123456 emp > G:/emp_copy.sql

演示图:

数据库中加全局锁,是一个比较重的操作,存在以下问题:

  1. 如果在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆
  2. 如果在从库上备份,那么在备份期间从库不能执行主库同步过来的二进制日志(binlog),会导致主从延迟

解决方法:

在 InnoDB 引擎中,在备份时加上参数--single-transaction参数来完成不加锁的一致性数据备份(只适用于支持「可重复读隔离级别的事务」的存储引擎)

mysqldump --single-transaction  -h localhost -u root -p 123456 emp > G:/emp_copy.sql

原理补充:通过加上这个参数,确保了在备份开始时创建一个一致性的快照,通过启动一个新的事务来实现这一点。(该事务的隔离级别是Repeatable Read级别),从而实现在该事务读取下一直读取的是创建时的数据,而不影响其他事务的读写操作。如果不是 RR 级别,那么会出现下面的问题,例如我们需要备份两个表,一个是余额表,一个是购买成功的订单表。当备份余额表时,用户购买了一个东西。当备份订单表时,用户就好像时不花钱买了一个东西,从而造成了数据不一致。

2 表级锁

每次操作锁住整张表,锁定粒度大,发生锁的冲突的概率最高,并发度最低。所以全局锁的场景对于 JAVA 开发人员来说很少会遇到,更多的是表级锁和行级锁。那讲到这里,肯定理解为什么会有表级锁和行级锁(都是为了降低锁的粒度,提高并发度)

下面我们继续讨论表级锁,表级锁分为表级读写锁,元数据锁,意向锁,还有 InnoDB 引擎的 AUTO-INC 锁

其中表级读锁和表级写锁可以用户手动加锁,元数据锁和意向锁用户无法自动加锁

2.1 表级读锁 / 表级写锁

对于表锁,分为两类:

  1. 表共享读锁(read lock),阻塞其他客户端的写,不会阻塞读
LOCK TABLES 表名 READ;
  1. 表独占写锁(write lock),阻塞其他客户端的 写和读
LOCK TABLES 表名 WRITE;

读锁不会阻塞其他客户端的读,但是会阻塞写。写锁既会阻塞其他客户端的读,又会阻塞其他客户端的写。

表锁不支持重入:无法在同一会话中多次获得相同的表锁

表锁不支持从写锁降级为读锁:必须先释放写锁,然后单独获取读锁

表锁不支持从读锁升级为写锁:必须先释放读锁,然后单独获取写锁

-- 表级别的共享锁,也就是读锁
-- 允许当前会话读取被锁定的表,但阻止其他会话对这些表进行写操作
LOCK TABLES emp READ;

-- 表级锁的独占锁,也是写锁
-- 允许当前会话对表进行读写操作,但阻止其他会话对这些表进行任何操作(读或写)
LOCK TABLES emp WRITE;

释放所有锁(会话退出,也会释放所有锁):

UNLOCK TABLES;

2.2 元数据锁(MDL)

元数据锁(Metadata Lock )是 server 层的锁,表级锁,每执行一条 DML、DDL 语句时都会申请 MDL 锁,DML 操作需要 metadata 的读锁,DDL 操作需要 metadata 写锁,metadata 加锁过程是系统自动控制,无法直接干预

MDL 加锁过程是系统自动控制,无需显式使用,在访问一张表的时候会自动加上。MDL 锁主要作用是维护表元数据的数据一致性,在表上有活动事务的时候,不可以对元数据进行写入操作。为了避免 DML 与 DDL 冲突,保证读写的正确性

  1. 对一张表进行 CRUD 操作时,加的是 MDL 读锁;
  2. 对一张表做结构变更操作的时候,加的是 MDL 写锁;

对应SQL

锁类型

说明

适用场景

lock tables xxx read lock tables xxx write

SHARED_READ_ONLY SHARED_NO_READ_WRITE

LOCK TABLES 是一种显式的表级锁,可以指定为读锁或写锁

① select ② select ... lock in share mode

SHARED_READ

与SHARED_READ、SHARED_WRITE兼容,与 EXCLUSIVE 互斥

SHARED_READ 锁适用于查询操作,允许多个事务并发读取数据,但不允许写入

① insert ② update③ delete ④ select ...for update

SHARED_WRITE

与SHARED_READ、SHARED_WRITE兼容,与EXCLUSIVE互斥

SHARED_WRITE锁适用于修改数据的操作,允许多个事务并发写入数据,但不允许其他事务对表结构进行修改

alter table ...

EXCLYSIVE

与其他的MDL都互斥

EXCLUSIVE 锁用于表结构变更的操作,它是独占的,不允许其他事务操作该表的元数据

查看元数据锁:

SELECT object_type,object_schema,object_name,lock_type,lock_duration FROM performance_schema.metadata_locks;

LOCK_MODE

LOCK_DATA

锁范围

X,REC_NOT_GAP

15

15 那条数据的行锁

X,GAP

15

15 那条数据之前的间隙,不包含 15

X

15

15 那条数据的间隙,包含 15

在没有元数据之前,MySQL 会存在一个 bug:会话 A 执行了多条更新语句期间,另外一个会话 B 做了表结构变更并且先提交,就会导致 slave 在重做时,先重做 alter,再重做 update 时就会出现复制错误的现象

又比如:当前的事务级别为 RR,session1有一个事务正在查询数据,此时另一个会话 session2 进行了 DDL 语句,修改了表的结构,那么在 session1 的一个事务中,即使在 RR 级别下,两次查询结果也会不一致,无法满足可重复读的要求。5.1 之前对于元数据的保护是语句级别的(语句执行完毕,无论事务是否回滚还是提交,对于其他会话都是可见的),5.5 之后对于元数据的保护才是事务级别的,MDL主要解决两个问题:

  1. 事务隔离问题:元数据的保护范围扩展到事务级别,解决 RR 级别的一个事务中可能出现两次查询不一致问题
  2. 数据复制问题:比如会话A执行了多条更新语句期间,另外一个会话B做了表结构变更并且先提交,就会导致slave在重做时,先重做alter,再重做update时就会出现复制错误的现象

2.3 意向锁(IS/IX)

为了避免 DML 在执行时,加的行锁与表锁的冲突,在 InnoDB 中引入了意向锁,使得表锁不用检查每行数据是否加锁,使用意向锁来减少表锁的检查

意向共享锁和意向独占锁是表级锁,不会和行级的共享锁和独占锁发生冲突,而且意向锁之间也不会发生冲突,只会和共享表锁(lock tables ... read)和独占表锁(lock tables ... write)发生冲突

  1. 如果没有「意向锁」,那么加「独占表锁」时,就需要遍历表里所有记录,查看是否有记录存在独占锁,这样效率会很慢
  2. 那么有了「意向锁」,由于在对记录加独占锁前,先会加上表级别的意向独占锁。那么在想要添加「独占表锁」时,直接查该表是否有意向独占锁,如果有就意味着表里已经有记录被加了独占锁,这样就不用去遍历表里的记录

意向锁的目的是为了快速判断表里是否有记录被加锁

意向锁之间是互相兼容的

IS 锁

IX 锁

IS 锁

兼容

兼容

IX 锁

兼容

兼容

意向锁和共享锁和排它锁互斥(这里指的是表级别的共享锁和排他锁,意向锁不会与行级的共享锁和排他锁互斥)

表级别

IS 锁

IX 锁

S 锁

兼容

互斥

X 锁

互斥

互斥

加锁方式:

  1. IS : 意向共享锁(先在表上加上意向共享锁,然后对读取的记录加共享锁)
SELECT ... LOCK IN SHARE MODE;
  1. IX : 意向独占锁(先表上加上意向独占锁,然后对读取的记录加独占锁)
INSERT / UPDATE / DELETE / (SELECT ... FOR UPDATE)
-- 示例
START TRANSACTION;
-- 查找某账户的余额并加锁,以便后续更新
SELECT salary FROM emp WHERE id = 1 FOR UPDATE;
-- 执行操作,如修改余额
UPDATE emp SET salary = salary - 1000 WHERE id = 1;
COMMIT;

查看意向锁:

SELECT object_schema, object_name, index_name, lock_type, lock_mode, lock_data FROM performance_schema.data_locks;

LOCK_MODE

LOCK_DATA

锁范围

X,REC_NOT_GAP

15

15 那条数据的行锁

X,GAP

15

15 那条数据之前的间隙,不包含 15

X

15

15 那条数据的间隙,包含 15

2.4 AUTO-INC 锁

表中的主键值都会自动增加,这是通过对主键字段加上 AUTO_INCREMENT 属性实现的。之前如果想插入新记录,可以不指定主键的值,数据库会自动生成主键值增加的值,这主要是通过 AUTO_INCREMENT 锁实现的。不仅仅是自增主键,<font style="color:#000000;">AUTO_INCREMENT</font>的列都会涉及到自增锁,毕竟非主键也可以设置自增长

AUTO_INCREMENT 锁是特异的锁,不是再一个事务提交后才释放,而是执行完插入语句后就会立即释放。

在插入数据时,会加一个表级别的 AUTO_INCREMENT 锁,然后释放 AUTO_INCREMENT 锁。那么,一个事务持有 AUTO_INCREMENT 锁的过程中,其他事务的请求向表格进行插入操作,依然会影响插入的主键值的自增进程。但是,AUTO_INCREMENT 锁再大量数据插入时,影响插入性能,因为另一个事务的插入会被阻塞。因此,在 MySQL 5.1.22 版本以后,InnoDB 存储引擎提供了一种轻量级的锁来实现自增

就像是正在插入数据时,会涉及到 AUTO_INCREMENT 锁的修改,但后续这些字段值一个自增值的生成,就不需要等待整个插入语句执行完后释放锁。

InnoDB 存储引擎提供了一个 innodb_autoinc_lock_mode 的系统变量,是用来控制选择不同的 AUTO_INCREMENT 锁,最常用的锁

  1. 当 innodb_autoinc_lock_mode = 0 时,默认采用 AUTO_INCREMENT 锁,语句执行结束后释放锁;
  2. 当 innodb_autoinc_lock_mode = 1 时,采用轻量级锁,并且需要等待其他执行完后释放;
  3. 当 innodb_autoinc_lock_mode = 2 时,采用混合模式 普通 insert 语句,自增锁在申请之后就马上释放; 类似 insert...select 这样的批量插入数据的语句,自增锁还是要等语句结束后才被释放;

当 innodb_autoinc_lock_mode=2 是性能最高的方式,但是当搭配 binlog 的日志格式是 statement 一起使用的时候,在「主从复制的场景」中会发生数据不一致的问题

session A 往表 t 中插入了 4 行数据,然后创建了一个相同结构的表 t2,然后往 t2 中插入数据。

如果 innodb_autoinc_lock_mode = 2,意味着「申请自增主键后就释放锁,不必等待整个插入语句执行完」

那么就可能出现这样的情况:

  • session B 先插入了两条记录 (1,1), (2,2);
  • 然后,session A 来时申请 id=3,插入了 (3,5);
  • 然后,session B 继续执行,插入两条记录 (4,3), (5,4)

可以看到,sessionB 的 insert 语句,生成的 id 不连续\ 当「主库」发生了这种情况,binlog 面对 t2 表的更新只会记录这两个 session 的 insert 语句,如果 binlog_format=statement,记录的语句就是原始语句。记录的顺序要么先记sessionA的insert语句,要么先记 session B的 insert 语句\ 但不论是哪一种,这个binlog拿去「从库」执行,这时从库是按「顺序」执行语句的,只有当执行完一条 SQL 语句后,才会执行下一条 SQL。因此,在从库上「不会」发生像主库那样两个 session「同时」执行向表 t2 中插入数据的场景。所以,在备库上执行了 sessionB 的 insert 语句,生成的结果里面,id 都是连续的。这时,主从库就发生了数据不一致。\ 要解决这问题,binlog 日志格式要设置为 row,这样在 binlog 里面记录的是主库分配的自增值,到备库执行的时候,主库的自增值是什么,从库的自增值就是什么。 所以,当 innodb_autoinc_ock_mode=2 时,并且binlog_format=row,既能提升并发性,又不会出现数据一致性问题。

3 行级锁

行级锁,每次操作锁住对应的行数据。锁定粒度最小,发生锁冲突的概率最低,并发度最高。应用在InnoDB存储引擎中。

  1. 记录锁(Record Lock): 锁定单个行记录的锁,防止其他事务对此行进行 update 和 delete ,在RC、RR隔离级别下都支持,InnoDB 的数据是基于索引组织的(聚集索引),记录锁是通过对索引上的索引项加锁来实现的,而不是对记录加的锁
  2. 间隙锁(Gap Lock): 锁定索引记录间隙(不含该记录),确保索引记录间隙不变,防止其他事务在这个间隙进行 insert,产生幻读,在RR隔离级别下都支持
  3. 临键锁(Next-Key Lock): 行锁和间隙锁组合,同时锁住数据,并锁住数据前面的间隙Gap,在RR隔离级别下支持

InnoDB 有三种行锁的特点:

  1. Record Lock(记录锁):锁住某一行记录
  2. Gap Lock(间隙锁):锁住一段左开右开的区间
  3. Next-key Lock(临键锁):锁住一段左开右闭的区间

加锁规则的两条核心:

  1. 查找过程中访问到的对象才会加锁(注意访问三个是 B+ 树的访问规则)
  2. 加锁的基本单位是 Next-key Lock,根据规则逐步退化

通过使用 select * from performance_schema.data_locks; 和操作实践,可以看出 LOCK_MODE 和 LOCK_DATE 的关系:

LOCK_MODE

LOCK_DATA

锁范围

X,REC_NOT_GAP

15

15 那条数据的行锁

X,GAP

15

15 那条数据之前的间隙,不包含 15

X

15

15 那条数据的间隙,包含 15

  1. LOCK_MODE = X 是前开后闭区间;
  2. X,GAP 是前开后开区间(间隙锁);
  3. X,REC_NOT_GAP 行锁。

3.1 Record Lock(行锁)

Record Lock 称为记录锁,锁住的是一条记录,而且记录锁是有 S 锁和 X 锁之分

InnoDB实现了以下两种类型的行锁:

  1. 共享锁(S):允许一个事务去读一行,阻止其他事务获得相同数据集的排它锁
  2. 排他锁(X):允许获取排他锁的事务更新数据,阻止其他事务获得相同数据集的共享锁和排他锁

S(共享锁)

X(排他锁)

S(共享锁)

兼容

冲突

X(排他锁)

冲突

冲突

行锁类型:

SQL

行锁类型

说明

insert,update,delete ...

排他锁

自动加锁

select

不加任何锁

select ... lock in share mode

共享锁

需要手动select之后加上lock in share mode

select ... for update

排他锁

需要手动在select之后for update

默认情况下,InnoDB在 REPEATABLE READ 事务隔离级别运行,InnoDB使用 next-key 锁进行搜索和索引扫描,以防止幻读

  1. 针对唯一索引进行检索时,对已存在的记录进行等值匹配时,将会自动优化为记录锁
  2. InnoDB的记录锁是针对于索引加的锁,不通过索引条件检索数据,那么 InnoDB 将对表中的所有记录加锁,此时 就会升级为表锁

3.2 Gap Lock(间隙锁)

Gap Lock 称为间隙锁,只存在于可重复读隔离级别,目的是为了解决可重复读隔离级别下幻读的现象。假设,表中有一个范围 id 为 (3,5) 间隙锁,那么其他事务就无法插入 id=4 这条记录了,这样就有效的防止幻读现象的发生

Gap Lock 只能防插入,不能防已有行的更新 / 删除,间隙锁的引入就是为了解决幻读问题的

间隙锁虽然存在 X 型间隙锁和 S 型间隙锁,但是并没有什么区别,间隙锁之间是兼容的,即两个事务可以同时持有包含共同间隙范围的间隙锁,并不存在互斥关系,因为间隙锁的目的是防止插入幻影记录而提出的

3.3 Next-Key Lock(临键锁)

Next-Key Lock 称为临键锁,是 Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。

假设,表中有一个范围id为 (3,5] 的 Next-Key Lock,那么其他事务即不能插入 id=4 记录,也不能修改 id = 5 这条记录

所以,Next-Key Lock 即能保护该记录,又能阻止其他事务将新纪录插入到被保护记录前面的间隙中。

Next-Key Lock 是包含间隙锁 + 记录锁的,如果一个事务获取了 X 型的 Next-Key Lock,那么另外一个事务在获取相同范围的 X 型的 Next-Key Lock 时,是会被阻塞的。比如,一个事务持有了范围为 (1,10] 的 X 型的 Next-Key Lock,那么另外一个事务在获取相同范围的 X 型的 Next-Key Lock 时,就会被阻塞。虽然相同范围的间隙锁是多个事务相互兼容的,但对于记录锁,我们是要考虑 X 型与 S 型关系,X 型的记录锁与 X 型的记录锁是冲突的

默认情况下,InnODB在 REPEATABLE READ事务隔离级别运行,InnoDB使用 next-key 锁进行搜索和索引扫描,以防止幻读。

  1. 索引上的等值查询(唯一索引),给不存在的记录加锁时,优化为间隙锁
  2. 索引上的等值查询(非唯一索引),向右遍历时最后一个值不满足查询需求时,next-key lock 退化为间隙锁
  3. 索引上的范围查询(唯一索引),会访问到不满足条件的第一个值为止
  4. 索引上的范围查询(非唯一索引),会访问到不满足条件的第一个值为止,next-key lock 不能退化为 gap lock

4 MySQL中一条SQL是如何加锁的?

从两阶段锁说起,在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放,这样锁的时间就尽可能的短

事务隔离界别为 RR,MySQL 是如何通过 Multi-Version Concurrency Control(MVCC) 和间隙锁来解决幻读的问题?

InnoDB 有三种行锁的特点:

  1. Record Lock(记录锁):锁住某一行记录
  2. Gap Lock(间隙锁):锁住一段左开右开的区间
  3. Next-key Lock(临键锁):锁住一段左开右闭的区间

加锁规则的两条核心:

  1. 查找过程中访问到的对象才会加锁(注意是基于 B+ 树的访问规则)
  2. 加锁的基本单位是 Next-key Lock,根据规则逐步退化

假设有这么一张 test 表,id 为主键(唯一索引),a 是普通索引(非唯一索引),b 都是普通的列,其上没有任何索引

id(唯一索引)

a(非唯一索引)

b

10

4

A

15

8

B

20

16

C

25

32

D

30

64

E

CREATE TABLE `test`(
    `id` int UNIQUE KEY COMMENT '唯一索引',
    `a` int COMMENT '非唯一索引',
    `b` varchar(1) COMMENT '普通列',
    INDEX `not_unique`(`a`)
);

INSERT INTO test VALUES(10, 4, 'A'), (15, 8, 'B'), (20, 16, 'C'), (25, 32, 'D'), (30, 64, 'E'), (35, 128, 'F'); 

唯一索引+唯一条件 时,锁是可退化的,锁只作用于那条记录,不加后间隙锁;

非唯一索引 或 非唯一条件 时,InnoDB 会锁定从第一个匹配到最后一个匹配,再到它后面的第一个非匹配记录之间的整个区间(记录锁 + 前后两端的间隙锁)

4.1 唯一索引等值查询

当我们用唯一索引进行等值查询时,根据查询记录是否存在,加锁的规则会有所不同:

  1. 当查询的记录是存在的,Next-Key Lock 会退化为 Record Key
  2. 当查询记录不存在时,Next-key Lock 会退化为 Gap Lock
  1. 记录存在
select * from user where id = 25 for update;

结合加锁的两条核心:查找过程中访问到的对象才会加锁 + 加锁的基本单位是 Next-key Lock(左开右闭),我们可以分析出,这条语句的加锁范围是 (20, 25]。 不过,由于这个唯一索引等值查询的记录 id = 25 是存在的,因此,Next-key Lock 会退化成 Record Lock,因此最终的加锁范围是 id = 25 这一行

  1. 记录不存在
select * from user where id = 22 for update;

结合加锁的两条核心:查找过程中访问到的对象才会加锁 + 加锁的基本单位是 Next-key Lock(左开右闭),我们可以分析出,这条语句的加锁范围是 (20, 25],由于这个唯一索引等值查询的记录 id = 22 是不存在的,因此,Next-key Lock 会退化成 Gap Lock,因此最终在主键 id 上的加锁范围是 Gap Lock (20, 25)

4.2 唯一索引范围查询

当我们用唯一索引进行范围查询时,根据查询记录是否存在,加锁的规则会有所不同:

  1. 当范围的边界是存在的,Next-Key Lock 会退化为 Record Key
  2. 当范围的边界不存在时,Next-Key Lock 会退化为 Gap Lock
  3. 唯一索引的范围查询需要一直向右遍历到第一个不满足条件的记录,添加 Next-Key 只后再进行退化 下边界不存在时,8.0.17 版本会加下一个前开后闭范围的锁。而 8.0.18 版本及以后,进行了优化,主键时判断不等,不会锁住后闭的区间,加一个前开后开的范围的锁 下边界存在时,8.0.17 会锁住下一个 next-key 的前开后开区间,而 8.0.18 及以后版本,将不会添加下一个间隙锁
select * from user where id >= 20 and id < 22 for update;

查询条件的前半部分 id >= 20,因此,这条语句最开始要找的第一行是 id = 20,然后向右遍历,结合加锁的两个核心,需要加上 Next-key Lock (15,20]。又由于 id 是唯一索引,且 id = 20 的这行记录是存在的,因此会退化成记录锁,也就是只会对 id = 20 这一行加锁。

再来看语句查询条件的后半部分 id < 22,由于是范围查找,就会继续往后找第一个不满足条件的记录,也就是会找到 id = 25 这一行停下来,然后加 Next-key Lock (20, 25],重点来了,但由于 id = 25 不满足 id < 22,因此会退化成间隙锁,加锁范围变为 (20, 25)

所以,上述语句在主键 id 上的最终的加锁范围是 Record Lock id = 20 以及 Gap Lock (20, 25)

select * from user where id >= 20 and id <= 25 for update;

id = 20 的记录锁和上面的情况一样。然后会加一个(20,25] 的邻键锁,因为 21,22,23,24 都在范围内且这些数据不存在,所以不会退化为记录锁,所以退化为间隙锁,但是 id=25 是存在的所以退化为记录锁

(20,25] next-key lock ➔ (20,25) gap lock + id=25 record lock

所以加锁范围就是(20,25]

在 8.0.18 版本之前还会继续往后遍历找到第一个不满足条件的记录,然后添加(25,30)的间隙锁。但是在 8.0.18 版本之后,就不会添加(25,30)的间隙锁了。找到了边界条件就不再往后遍历了。

所以,上述语句在主键 id 上的最终的加锁范围是 Record Lock id = 20 以及 Gap Lock (20, 25)

4.3 非唯一索引等值查询

当我们用非唯一索引进行等值查询时,根据查询记录是否存在,加锁的规则会有所不同:

  1. 当查询的记录是存在的,除了会加 Next-Key Lock 之外,还会添加一个额外的 Gap Lock(规则是向后遍历到第一个不符合条件的值才会停止),查找记录的左区间加 Next-key Lock,右区间加 Gap lock
  2. 当查询记录不存在时,Next-key Lock 会退化为 Gap Lock
  1. 记录存在
select * from user where a = 16 for update;

这条语句首先会对普通索引 a 加上 Next-key Lock,范围是 (8,16]。

又因为是非唯一索引等值查询,且查询的记录 a= 16 是存在的,所以还会加上间隙锁,规则是向下遍历到第一个不符合条件的值才能停止,因此间隙锁的范围是 (16,32)。

所以,上述语句在普通索引 a 上的最终加锁范围是 Next-key Lock (8,16] 以及 Gap Lock (16,32)。

  1. 记录不存在
select * from user where a = 18 for update;

结合加锁的两条核心,这条语句首先会对普通索引 a 加上 Next-key Lock,范围是 (16,32]。

但是由于查询的记录 a = 18 是不存在的,因此 Next-key Lock 会退化为间隙锁,即最终在普通索引 a 上的加锁范围是 (16,32)

4.4 非唯一索引范围查询

当我们用非唯一索引进行范围查询时,根据查询记录是否存在,加锁的规则会有所不同:

  1. 当范围的下界是存在的,Next-Key Lock 也不会退化,所以始终加的是 Next-Key Lock
  2. 唯一索引的范围查询需要一直向右遍历到第一个不满足条件的记录,即使不满足条件,也不会像唯一索引的范围查询进行锁的退化,所以最终加的都是 Next-Key Lock
select * from user where a >= 16 and a < 18 for update;

先来看语句查询条件的前半部分 a >= 16,因此,这条语句最开始要找的第一行是 a = 16,结合加锁的两个核心,需要加上 Next-key Lock (8,16]。虽然非唯一索引 a = 16 的这行记录是存在的,但此时并不会像 唯一索引范围查询 那样退化成记录锁

再来看语句查询条件的后半部分 a < 18,由于是范围查找,就会继续往后找第一个不满足条件的记录,也就是会找到 id = 32 这一行停下来,然后加 Next-key Lock (16, 32]。虽然 id = 32 不满足 id < 18,但此时并不会向 唯一索引范围查询 那样退化成间隙锁


进一步理解锁的最小单位是 Next-Key Lock

所以,上述语句在普通索引 a 上的最终的加锁范围是 Next-key Lock (8, 16] 和 (16, 32],也就是 (8, 32]

select * from user where a >= 16 and a <= 32 order by a desc for update;

在这语句中,我们需要按照索引 a 进行降序排序,又是如何加锁的呢?

  1. 由于是 order by a desc,所以第一个要定位的是所以 a 上"最右边的数据",也就是 a = 32 的行,所以会添加一个(16,32] 的锁,等值查询之后,还需要向后遍历,找到第一个不符合条件的值,找到了 a = 64 的值,所以会添加一个(32,64] 的锁
  2. 然后继续像左遍历,扫描到 16 时,添加(8,16],然后继续向左寻找第一个不符合条件的值,找到了 a = 8,所以会添加(4,8] 的锁

讲到这里是不是就很清除加锁的过程了?就是遍历过程中遇到一个记录,就给这个记录添加一个 Next-Key Lock,然后再根据不同的情况决定是否需要退化 Next-Key Lock

4.5 limit 语句加锁

对于非唯一索引而言,如果语句明确加了 limit 的限制,相当于告诉了 InnoDB 此次只需要变量多少需要的记录。相当要告诉 InnoDB 不再需要遍历到第一个不满足条件的记录。例如 t 表中的数据有

c(非唯一索引)

id(唯一索引)

0

0

5

5

10

10

10

30

15

15

20

20

25

25

DELETE FROM t WHERE c = 10 limit 2;

如果不加 limit 2,加的锁将是(c=5,id=5) 到 (c=15,id=15) 的开区间

但是添加了 limit 2,InnoDB 再遍历到第二个满足条件的数据时(10,30),就不会继续往下遍历了

所以加的锁是(c=5,id=5) 到(c=10,id=30) 这个前开后闭区间

所以在删除数据的时候尽量加 limit,这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围

4.6 死锁的例子

c(非唯一索引)

id(唯一索引)

0

0

5

5

10

10

10

30

15

15

20

20

25

25

Session A 和 Session B 对上面进行的操作如下

  1. Session A 的 select 语句获得的锁是(5,10] 和(10,15]
  2. Session B 更新时也要在索引 C 上加 Next-Key Lock(5,10],但是 10 已经有了记录锁,所以此时会进入阻塞
  3. 然后 session A 要再插入 (8,8,8) 这一行,被 session B 的间隙锁锁住。由于出现了死锁,InnoDB 让 session B 回滚

所以到现在加锁的过程就很明朗了,session B 的“加 next-key lock(5,10] ”操作,实际上分成了两步,先是加 (5,10) 的间隙锁,加锁成功;然后加 c=10 的行锁,这时候才被锁住的。

也就是说,我们在分析加锁规则的时候可以用 next-key lock 来分析。但是要知道,具体执行的时候,是要分成间隙锁和行锁两段来执行的

4.7 为什么加锁逻辑是这样的?

首先你要明白的是间隙锁的引入是为了解决幻读问题的,其次幻读特指其他事务在我查询的范围中新插入了数据,明白了这一点,你就能抓住重心,如何才能锁住不造成幻读的范围

对于 唯一索引的等值查询 来说,我能确保该值只可能出现一个,所以我只需要添加 Record Lock 就能保证其他事务想要修改我,或者想要变为我,都是加锁失败的。

对于 唯一索引的范围查询 来说,我只需要将查询的范围区间覆盖,这样就阻塞了其他事务在这个范围中插入数据。如果区间的临界值时存在的,那么还能进一步缩减区间。如果区间的临界值不存在,我们就不应该缩减区间。例如表中唯一索引包含的数据有 id = 20 和 id = 25,查询范围是 [20, 22) ,那我们应该锁住的是 [20, 25)这个区间,而不是锁住 [20, 22) 这个区间,我们是基于 B+ 树 的扫描过程进行加锁的,不是想当然的进行加锁的。

对于 非唯一索引的等值查询,因为值是可以重复的。所以当一个值是存在的时候,我们需要锁住当前值,以及他的前后区间,防止前后区间的数据被修改成为当前值。此外,如果一个值是不存在的,那我们只需要锁住这个值可能出现的区间位置即可,比如 有非唯一索引 a = 16,a = 32,当想要锁住 a = 18 时,发现记录不存在,那么我们就只需要锁住它可能出现的范围 (16, 32) 即可。

对于 非唯一索引的范围查询,因为值是可以重复的,所以不管区间的临界值时是否存在,我们都需要锁住这个记录,以及它前后的区间。但是这个地方我们不能像 唯一索引的范围查询 一样进行区间的缩减了。比如 上面的例子中,如果将 (8, 32] Next-Key Lock 退化为 (8, 32) Gap Lock,那么就可能会出现 a = 32 值,被修改到了 (8, 32) 区间内的值!为什么会出现这种情况?因为如果是 (8, 32) Gap Lock,会因为 Gap Lock 是兼容的,而导致其他 Gap Lock 也加锁成果。比如我们修改 a = 32 时,根据 非唯一索引等值 的加锁流程我们会添加 (16, 32], (32, 64) 这两把锁。所以是能加锁成功的,也就代表了,我们是能够将 a = 32 修改为 (8, 32) 中间的值,也就会继续出现幻读的现象。所以对于 非唯一索引的范围查询 ,我们不能缩减区间,根本原因就是 Gap-Key 是兼容的,Gap Lock 只能防插入,不能防已有行的更新/删除

最后我们将了 limit 情况,得出的结论是删除数据的时候尽量加 limit,这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值