锁是计算机协调多个进程或线程并发访问某一资源的机制。在数据库中,除传统的计算资源(CPU、RAM、I/0)的争用以外,数据也是一种供许多用户共享的资源。如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。从这个角度来说,锁对数据库而言显得尤其重要,也更加复杂
MySQL中的锁,按照锁的粒度分,分为一下三类:
- 全局锁:锁定数据库中的所有表
- 表级锁:每次操作锁住整张表
- 行级锁:每次操作锁住对应的行数据
相信不管是在工作中,还是再学习中,都会涉及到 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
演示图:
数据库中加全局锁,是一个比较重的操作,存在以下问题:
- 如果在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆
- 如果在从库上备份,那么在备份期间从库不能执行主库同步过来的二进制日志(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 表级读锁 / 表级写锁
对于表锁,分为两类:
- 表共享读锁(read lock),阻塞其他客户端的写,不会阻塞读
LOCK TABLES 表名 READ;
- 表独占写锁(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 冲突,保证读写的正确性
- 对一张表进行 CRUD 操作时,加的是 MDL 读锁;
- 对一张表做结构变更操作的时候,加的是 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主要解决两个问题:
- 事务隔离问题:元数据的保护范围扩展到事务级别,解决 RR 级别的一个事务中可能出现两次查询不一致问题
- 数据复制问题:比如会话A执行了多条更新语句期间,另外一个会话B做了表结构变更并且先提交,就会导致slave在重做时,先重做alter,再重做update时就会出现复制错误的现象
2.3 意向锁(IS/IX)
为了避免 DML 在执行时,加的行锁与表锁的冲突,在 InnoDB 中引入了意向锁,使得表锁不用检查每行数据是否加锁,使用意向锁来减少表锁的检查
意向共享锁和意向独占锁是表级锁,不会和行级的共享锁和独占锁发生冲突,而且意向锁之间也不会发生冲突,只会和共享表锁(lock tables ... read)和独占表锁(lock tables ... write)发生冲突
- 如果没有「意向锁」,那么加「独占表锁」时,就需要遍历表里所有记录,查看是否有记录存在独占锁,这样效率会很慢
- 那么有了「意向锁」,由于在对记录加独占锁前,先会加上表级别的意向独占锁。那么在想要添加「独占表锁」时,直接查该表是否有意向独占锁,如果有就意味着表里已经有记录被加了独占锁,这样就不用去遍历表里的记录
意向锁的目的是为了快速判断表里是否有记录被加锁
意向锁之间是互相兼容的
IS 锁 | IX 锁 | |
IS 锁 | 兼容 | 兼容 |
IX 锁 | 兼容 | 兼容 |
意向锁和共享锁和排它锁互斥(这里指的是表级别的共享锁和排他锁,意向锁不会与行级的共享锁和排他锁互斥)
表级别 | IS 锁 | IX 锁 |
S 锁 | 兼容 | 互斥 |
X 锁 | 互斥 | 互斥 |
加锁方式:
- IS : 意向共享锁(先在表上加上意向共享锁,然后对读取的记录加共享锁)
SELECT ... LOCK IN SHARE MODE;
- 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 锁,最常用的锁
- 当 innodb_autoinc_lock_mode = 0 时,默认采用 AUTO_INCREMENT 锁,语句执行结束后释放锁;
- 当 innodb_autoinc_lock_mode = 1 时,采用轻量级锁,并且需要等待其他执行完后释放;
- 当 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存储引擎中。
- 记录锁(Record Lock): 锁定单个行记录的锁,防止其他事务对此行进行 update 和 delete ,在RC、RR隔离级别下都支持,InnoDB 的数据是基于索引组织的(聚集索引),记录锁是通过对索引上的索引项加锁来实现的,而不是对记录加的锁
- 间隙锁(Gap Lock): 锁定索引记录间隙(不含该记录),确保索引记录间隙不变,防止其他事务在这个间隙进行 insert,产生幻读,在RR隔离级别下都支持
- 临键锁(Next-Key Lock): 行锁和间隙锁组合,同时锁住数据,并锁住数据前面的间隙Gap,在RR隔离级别下支持
InnoDB 有三种行锁的特点:
- Record Lock(记录锁):锁住某一行记录
- Gap Lock(间隙锁):锁住一段左开右开的区间
- Next-key Lock(临键锁):锁住一段左开右闭的区间
加锁规则的两条核心:
- 查找过程中访问到的对象才会加锁(注意访问三个是 B+ 树的访问规则)
- 加锁的基本单位是 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 |
- LOCK_MODE = X 是前开后闭区间;
- X,GAP 是前开后开区间(间隙锁);
- X,REC_NOT_GAP 行锁。
3.1 Record Lock(行锁)
Record Lock 称为记录锁,锁住的是一条记录,而且记录锁是有 S 锁和 X 锁之分
InnoDB实现了以下两种类型的行锁:
- 共享锁(S):允许一个事务去读一行,阻止其他事务获得相同数据集的排它锁
- 排他锁(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 锁进行搜索和索引扫描,以防止幻读
- 针对唯一索引进行检索时,对已存在的记录进行等值匹配时,将会自动优化为记录锁
- 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 锁进行搜索和索引扫描,以防止幻读。
- 索引上的等值查询(唯一索引),给不存在的记录加锁时,优化为间隙锁
- 索引上的等值查询(非唯一索引),向右遍历时最后一个值不满足查询需求时,next-key lock 退化为间隙锁
- 索引上的范围查询(唯一索引),会访问到不满足条件的第一个值为止
- 索引上的范围查询(非唯一索引),会访问到不满足条件的第一个值为止,next-key lock 不能退化为 gap lock
4 MySQL中一条SQL是如何加锁的?
从两阶段锁说起,在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放,这样锁的时间就尽可能的短
事务隔离界别为 RR,MySQL 是如何通过 Multi-Version Concurrency Control(MVCC) 和间隙锁来解决幻读的问题?
InnoDB 有三种行锁的特点:
- Record Lock(记录锁):锁住某一行记录
- Gap Lock(间隙锁):锁住一段左开右开的区间
- Next-key Lock(临键锁):锁住一段左开右闭的区间
加锁规则的两条核心:
- 查找过程中访问到的对象才会加锁(注意是基于 B+ 树的访问规则)
- 加锁的基本单位是 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 唯一索引等值查询
当我们用唯一索引进行等值查询时,根据查询记录是否存在,加锁的规则会有所不同:
- 当查询的记录是存在的,Next-Key Lock 会退化为 Record Key
- 当查询记录不存在时,Next-key Lock 会退化为 Gap Lock
- 记录存在
select * from user where id = 25 for update;
结合加锁的两条核心:查找过程中访问到的对象才会加锁 + 加锁的基本单位是 Next-key Lock(左开右闭),我们可以分析出,这条语句的加锁范围是 (20, 25]。 不过,由于这个唯一索引等值查询的记录 id = 25 是存在的,因此,Next-key Lock 会退化成 Record Lock,因此最终的加锁范围是 id = 25 这一行
- 记录不存在
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 唯一索引范围查询
当我们用唯一索引进行范围查询时,根据查询记录是否存在,加锁的规则会有所不同:
- 当范围的边界是存在的,Next-Key Lock 会退化为 Record Key
- 当范围的边界不存在时,Next-Key Lock 会退化为 Gap Lock
- 唯一索引的范围查询需要一直向右遍历到第一个不满足条件的记录,添加 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 非唯一索引等值查询
当我们用非唯一索引进行等值查询时,根据查询记录是否存在,加锁的规则会有所不同:
- 当查询的记录是存在的,除了会加 Next-Key Lock 之外,还会添加一个额外的 Gap Lock(规则是向后遍历到第一个不符合条件的值才会停止),查找记录的左区间加 Next-key Lock,右区间加 Gap lock
- 当查询记录不存在时,Next-key Lock 会退化为 Gap Lock
- 记录存在
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)。
- 记录不存在
select * from user where a = 18 for update;
结合加锁的两条核心,这条语句首先会对普通索引 a 加上 Next-key Lock,范围是 (16,32]。
但是由于查询的记录 a = 18 是不存在的,因此 Next-key Lock 会退化为间隙锁,即最终在普通索引 a 上的加锁范围是 (16,32)
4.4 非唯一索引范围查询
当我们用非唯一索引进行范围查询时,根据查询记录是否存在,加锁的规则会有所不同:
- 当范围的下界是存在的,Next-Key Lock 也不会退化,所以始终加的是 Next-Key Lock
- 唯一索引的范围查询需要一直向右遍历到第一个不满足条件的记录,即使不满足条件,也不会像唯一索引的范围查询进行锁的退化,所以最终加的都是 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 进行降序排序,又是如何加锁的呢?
- 由于是 order by a desc,所以第一个要定位的是所以 a 上"最右边的数据",也就是 a = 32 的行,所以会添加一个(16,32] 的锁,等值查询之后,还需要向后遍历,找到第一个不符合条件的值,找到了 a = 64 的值,所以会添加一个(32,64] 的锁
- 然后继续像左遍历,扫描到 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 对上面进行的操作如下
- Session A 的 select 语句获得的锁是(5,10] 和(10,15]
- Session B 更新时也要在索引 C 上加 Next-Key Lock(5,10],但是 10 已经有了记录锁,所以此时会进入阻塞
- 然后 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,这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围