一步步做实验,深入理解MySQL行锁
本文参考文献来自小林coding 作者:小林coding
正文
MySQL中行锁是非常重要的一部分内容,之前也查过很多资料,看过很多大神的解读,一直是很难深入地理解。同时,涉及内容较多,又容易忘记。这次干脆从头一步步用做实验地方式来彻底梳理一下行锁。
文章很长,需耐心看完!
怎么加行级锁?
MySQL两种常用的存储引擎(MyISAM和InnoDB)MyISAM不支持行锁,所以行锁都是针对于InnoDB引擎的。
普通的select语句属于快照读,是不会对记录加锁的,MVCC(事务多版本控制)主要是针对快照读。
要使查询语句加锁可以手动加锁,有如下两种方式,成为锁定读
//对读取的记录加共享锁(S型锁)
select ... lock in share mode;
//对读取的记录加独占锁(X型锁)
select ... for update;
要实现以上两种锁定读,需要在事务中实现,当事务提交了,锁就会被释放。需要在语句执行前使用begin 或 start transaction 开启事务。
除此之外 update 和 delete 操作也都会加行级锁,并且都是独占锁(X型锁)
共享锁(S锁)满足读读共享,读写互斥。独占锁(X锁)满足写写互斥、读写互斥。
行锁的种类?
根据不同事务隔离级别,行锁的类型也有所不同,通常我们只会用到两种隔离级别RC(读提交)和RR(可重复读)
在RC隔离级别下,行锁的种类只有 记录锁(Record Lock),也就是只锁住一条记录。
在RR隔离级别下,行锁的种类除了记录锁还增加间隙锁和临键锁,目的是为了防止幻读,因此行锁种类分为三种
Record Lock,记录锁,只锁定一条记录
Gap Lock,间隙锁,锁定一个范围,但不包含记录本身
Next-Key Lock,Record Lock+Gap Lock的组合,锁定一个范围,同时也锁定记录本身。
接下来详细说明一下这三种行锁
Record Lock
Record Lock 是记录锁,只锁住一条记录。同时,记录锁也分为S锁和X锁:
当一个事务对一条记录加了 S 型记录锁后,其他事务也可以继续对该记录加 S 型记录锁(S 型与 S 锁兼容),但是不可以对该记录加 X 型记录锁(S 型与 X 锁不兼容)。
当一个事务对一条记录加了 X 型记录锁后,其他事务既不可以对该记录加 S 型记录锁(S 型与 X 锁不兼容),也不可以对该记录加 X 型记录锁(X 型与 X 锁不兼容)。
假设,表中有一个 id =1 的记录锁,那么其他事务就无法update或delete id=1的这条记录,这些操作会被阻塞。
事务提交后,事务中的锁都会被释放。
Gap Lock
Gap Lock 称为间隙锁,只存在于可重复读隔离级别,目的是为了解决可重复读隔离级别下幻读的现象。
假设,表中有一个 id 范围为(1,5)的间隙锁,那么其他事务就无法插入id=2,3,4这三条记录,这样就有效防止了幻读现象的发生。
间隙锁虽然存在 X 型间隙锁和 S 型间隙锁,但是并没有什么区别,间隙锁之间是兼容的,即两个事务可以同时持有包含共同间隙范围的间隙锁,并不存在互斥关系,因为间隙锁的目的是防止插入幻影记录而提出的。
Next-Key Lock
Next-Key Lock 称为临键锁,是 Record Lock + Gap Lock 的组合,锁定一个范围,同时锁定记录本身。
当一个事务对一条记录加了 X 型记录锁后,其他事务既不可以对该记录加 S 型记录锁(S 型与 X 锁不兼容),也不可以对该记录加 X 型记录锁(X 型与 X 锁不兼容)。
假设,表中有一个范围 id 为(3,5] 的 next-key lock,那么其他事务即不能插入 id = 4 记录,也不能修改和删除 id = 5 这条记录。
假设,表中有一个范围 id 为(1,5] 的 Next-Key Lock,那么其他事务即不能插入 id=2,3,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 型的记录锁是独占锁,会与其他类型锁产生冲突的。
假设,表中有一个范围 id 为(1,5] 的 next-key lock,那么其他事务即不能插入 id =2,3, 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 型的记录锁是独占锁,会与其他类型锁产生冲突。
MySQL的行锁加锁规则
行锁的具体加锁方式,在两种常用的事务隔离级别下加以详细介绍。
读提交(READ COMMITTED)
RC的隔离级别下,由于没有间隙锁和next-key 锁的存在,因此RC隔离级别下只有记录锁这一种类型。同时,有索引和无索引的加锁方式有一定的差别。
有索引
在RC隔离级别下,当一个事务对索引字段加锁后,不管是主键索引还是非唯一索引,只会锁住符合查询条件的记录,其他数据不会受影响
举例:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`name` varchar(5) DEFAULT NULL,
`age` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_age` (`age`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
mysql> select * from t;
+----+------+------+
| id | name | age |
+----+------+------+
| 1 | a | 18 |
| 5 | b | 21 |
| 10 | c | 22 |
| 15 | d | 20 |
| 20 | e | 30 |
+----+------+------+
发起事务A,查询条件二级索引列age=22
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t where age=22 for update;
+----+------+------+
| id | name | age |
+----+------+------+
| 10 | c | 22 |
+----+------+------+
1 row in set (0.00 sec)
查看一下 data_locks 中显示的加锁情况
从上图分析得知:事务A 首先对二级索引列age=22 加了一把记录锁,同时对于该行记录的主键索引id=10也加了一把记录锁。意味着,其他事务无法更新或删除age=22和id=10的记录。
发起事务B
mysql> select * from t where age=22 for update;
//阻塞
事务B 被阻塞,由于age=22的这行数据被事务A加了记录锁。
发起事务C
mysql> select * from t where age=20 for update;
+----+------+------+
| id | name | age |
+----+------+------+
| 15 | d | 20 |
+----+------+------+
1 row in set (0.00 sec)
事务A只锁定了二级索引age=22的这一行记录,同时不存在间隙锁,不影响其他行数据的加锁。
无索引
1. select ...for update 语句
读提交的隔离级别下,select ...for update 在没有索引的条件下,扫描全表顺序加锁,再判断记录是否符合要求,符合就保留锁,不符合就释放锁。
select ...for update 语句 与 delete 语句 效果相同
做个实验:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`name` varchar(10) DEFAULT NULL,
`address` varchar(20) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
mysql> select * from t;
+----+------+---------+
| id | name | address |
+----+------+---------+
| 1 | a | aa |
| 2 | b | bb |
| 3 | c | cc |
+----+------+---------+
发起事务A
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t where name = 'b' for update;
+----+------+---------+
| id | name | address |
+----+------+---------+
| 2 | b | bb |
+----+------+---------+
1 row in set (0.00 sec)
查看锁的情况:id=2的数据行被锁住了,使用的X型记录锁
发起事务B
mysql> select * from t where name='a' for update;
//阻塞
从上图分析得知:事务B先扫描了name='a'的记录,将该行记录的主键索引 id=1加了一把记录锁。接下来继续向后扫描,扫描到下一行对主键索引 id=2 加锁时,发现事务A目前持有id=2的记录锁。因此,发生事务B阻塞。
再发起事务C ,name值为 'c'
mysql> select * from t where name='c' for update;
//阻塞
从上图中可以分析得知:事务C依然从第一行数据开始扫描,在对id=1的主键索引加记录锁时,发现事务B目前持有id=1的记录锁,因此,发生阻塞。
由以上实验可以得出这个结论,读提交的隔离级别下,select ...for update 在没有索引的条件下,扫描全表顺序加锁,再判断记录是否符合要求,符合就保留锁,不符合就释放锁。
2. update语句
读提交隔离级别下,update与select ... for update的加锁方式不同,update是先搜索出符合条件的数据,再加锁更新。
发起事务A
mysql> select * from t where name = 'a' for update;
+----+------+---------+
| id | name | address |
+----+------+---------+
| 1 | a | aa |
+----+------+---------+
1 row in set (0.00 sec)
发起事务B
mysql> update t set name='bb' where name='b';
Query OK, 1 row affected (0.01 sec)
Rows matched: 1 Changed: 1 Warnings: 0
事务B 成功执行
可重复读(REPEATABLE READ)
RR隔离级别下,行锁加锁规则比较复杂,不同场景下加锁的形式有所不同。
加锁的对象是索引,加锁的基本单位是 next-key lock,它是由记录锁和间隙锁组合而成的。
next-key lock 前开后闭区间
间隙锁 前开后开区间
next-key lock 在一些场景下会退化成记录锁或间隙锁。
具体是哪些场景?总结一句,在能使用记录锁或者间隙锁就能避免幻读现象的场景下, next-key lock 就会退化成退化成记录锁或间隙锁。
做个实验:
表结构
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`name` varchar(5) DEFAULT NULL,
`age` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_age` (`age`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
id 为主键
age 字段为非唯一普通索引
name 为普通列
表中记录为:
实验环境 mysql8.0 隔离级别为RR
唯一索引等值查询
当我们用唯一索引进行等值查询的时候,查询的记录存不存在,加锁的规则也会不同:
当查询的记录是「存在」的,在索引树上定位到这一条记录后,将该记录的索引中的 next-key lock 会退化成「记录锁」。
当查询的记录是「不存在」的,在索引树找到第一条大于该查询记录的记录后,将该记录的索引中的 next-key lock 会退化成「间隙锁」。
1. 记录存在的情况
假设事务 A 执行了这条等值查询语句,查询的记录。
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t where id=1 for update;
+----+------+---------+
| id | name | age |
+----+------+---------+
| 1 | a | 18 |
+----+------+---------+
1 row in set (0.00 sec)
查看一下加锁情况:
从上图可以看到,共加了两个锁,分别是:
表锁:X 类型的意向锁;
行锁:X 类型的记录锁;
这里我们重点关注行级锁,图中 LOCK_TYPE 中的 RECORD 表示行级锁,而不是记录锁的意思。
通过 LOCK_MODE 可以确认是 next-key 锁,还是间隙锁,还是记录锁:
如果 LOCK_MODE 为 X,说明是 next-key 锁;
如果 LOCK_MODE 为 X, REC_NOT_GAP,说明是记录锁;
如果 LOCK_MODE 为 X, GAP,说明是间隙锁;
LOCK_DATA 显示的是被锁住的数据
因此,事务A 锁住了id=1的这条记录,接下来,如果有其他事务,对 id 为 1 的记录进行更新或者删除操作的话,这些操作都会被阻塞,因为更新或者删除操作也会对记录加 X 型的记录锁,而 X 锁和 X 锁之间是互斥关系。
执行事务B后 再查看一下 锁的情况,显示事务B(THREAD_ID:3516504)所类型为记录锁,在等待id=1的锁释放。
原因就是在唯一索引等值查询并且查询记录存在的场景下,仅靠记录锁也能避免幻读的问题。
幻读的定义就是,当一个事务前后两次查询的结果集,不相同时,就认为发生幻读。所以,要避免幻读就是避免结果集某一条记录被其他事务修改或删除,或者有其他事务插入了一条新记录,这样前后两次查询的结果集就不会出现不相同的情况。
由于主键具有唯一性,所以其他事务插入 id = 1 的时候,会因为主键冲突,导致无法插入 id = 1 的新记录。这样事务 A 在多次查询 id = 1 的记录的时候,不会出现前后两次查询的结果集不同,也就避免了幻读的问题。
由于对 id = 1 加了记录锁,其他事务无法修改或删除该记录,这样事务 A 在多次查询 id = 1 的记录的时候,不会出现前后两次查询的结果集不同,也就避免了幻读的问题。
2. 记录不存在的情况
假设事务 A 执行了这条等值查询语句,查询的记录不存在。
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t where id=2 for update;
Empty set (0.00 sec)
查看下事务A加了什么锁
此时事务 A 在 id = 5 记录的主键索引上加的是间隙锁,锁住的范围是 (1, 5)。
接下来,如果有其他事务插入 id 值为 2、3、4 这一些记录的话,这些插入语句都会发生阻塞。
注意,如果其他事务插入的 id = 1 或者 id = 5 的记录话,并不会发生阻塞,而是报主键冲突的错误,因为表中已经存在 id = 1 和 id = 5 的记录了。
发起事务B
mysql> insert into t values(3,'x',10);
//阻塞
查看锁情况
事务B(THREAD_ID: 3523127) 有一个插入意向锁,在等待id为(1,5)的间隙锁释放。因此id=3的数据行无法插入显示阻塞
如果 LOCK_MODE 是 next-key 锁或者间隙锁,那么 LOCK_DATA 就表示锁的范围「右边界」,此次的事务 A 的 LOCK_DATA 是 5。
然后锁范围的「左边界」是表中 id 为 5 的上一条记录的 id 值,即 1。
因此,间隙锁的范围(1, 5)。
为什么 id = 5 记录上的主键索引的锁不可以是 next-key lock?如果是 next-key lock,就意味着其他事务无法删除 id = 5 这条记录,但是这次的案例是查询 id = 2 的记录,只要保证前后两次查询id=2的结果集相同,就能避免幻读的问题了,所以即使 id =5 被删除,也不会有什么影响,那就没必须加 next-key lock,因此只需要在 id = 5 加间隙锁,避免其他事务插入 id = 2 的新记录就行了。
为什么不可以针对不存在的记录加记录锁?锁是加在索引上的,而这个场景下查询的记录是不存在的,自然就没办法锁住这条不存在的记录。
唯一索引范围查询
当唯一索引进行范围查询时,会对每一个扫描到的索引加 next-key 锁,然后如果遇到下面这些情况,会退化成记录锁或者间隙锁:
情况一:针对「大于等于」的范围查询,因为存在等值查询的条件,那么如果等值查询的记录是存在于表中,那么该记录的索引中的 next-key 锁会退化成记录锁。
情况二:针对「小于或者小于等于」的范围查询,要看条件值的记录是否存在于表中:
当条件值的记录不在表中,那么不管是「小于」还是「小于等于」条件的范围查询,扫描到终止范围查询的记录时,该记录的索引的 next-key 锁会退化成间隙锁,其他扫描到的记录,都是在这些记录的索引上加 next-key 锁。
当条件值的记录在表中,如果是「小于」条件的范围查询,扫描到终止范围查询的记录时,该记录的索引的 next-key 锁会退化成间隙锁,其他扫描到的记录,都是在这些记录的索引上加 next-key 锁;如果「小于等于」条件的范围查询,扫描到终止范围查询的记录时,该记录的索引 next-key 锁不会退化成间隙锁。其他扫描到的记录,都是在这些记录的索引上加 next-key 锁。
1. 针对 【大于或大于等于】 的范围查询
实验一:大于的范围查询
发起事务A
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql>select * from t where id>15 for update;
+----+------+---------+
| id | name | age |
+----+------+---------+
| 20 | e | 30 |
+----+------+---------+
事务 A 加锁变化过程如下:
最开始要找的第一行是 id = 20,由于查询该记录不是一个等值查询(不是大于等于条件查询),所以对该主键索引加的是范围为 (15, 20] 的 next-key 锁;
由于是范围查找,就会继续往后找存在的记录,虽然我们看见表中最后一条记录是 id = 20 的记录,但是实际在 Innodb 存储引擎中,会用一个特殊的记录来标识最后一条记录,该特殊的记录的名字叫 supremum pseudo-record ,所以扫描第二行的时候,也就扫描到了这个特殊记录的时候,会对该主键索引加的是范围为 (20, +∞] 的 next-key 锁。
停止扫描。
查看下 data_locks中的加锁情况:
从上图中分析可以得出 事务A 在主键索引上加了两个X型 Next-Key Lock (15,20](20,+
在 id = 20 这条记录的主键索引上,加了范围为 (15, 20] 的 next-key 锁,意味着其他事务即无法更新或者删除 id = 20 的记录,同时无法插入 id 值为 16、17、18、19 的这一些新记录。
在特殊记录( supremum pseudo-record)的主键索引上,加了范围为 (20, +∞] 的 next-key 锁,意味着其他事务无法插入 id 值大于 20 的这一些新记录。
实验二:大于等于的范围查询
发起事务A
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql>select * from t where id>=15 for update;
+----+------+---------+
| id | name | age |
+----+------+---------+
| 15 | d | 20 |
| 20 | e | 30 |
+----+------+---------+
事务 A 加锁变化过程如下:
最开始要找的第一行是 id = 15,由于查询该记录是一个等值查询(等于 15),所以该主键索引的 next-key 锁会退化成记录锁,也就是仅锁住 id = 15 这一行记录。
由于是范围查找,就会继续往后找存在的记录,扫描到的第二行是 id = 20,于是对该主键索引加的是范围为 (15, 20] 的 next-key 锁;
接着扫描到第三行的时候,扫描到了特殊记录( supremum pseudo-record),于是对该主键索引加的是范围为 (20, +∞] 的 next-key 锁。
停止扫描。
查看下 data_locks中的加锁情况:
从上图中分析可以得出 事务A 在主键索引上加了三个X型锁 分别是一个 id=15的记录锁 和 两个 Next-Key Lock (15,20] (20,+
在 id = 15 这条记录的主键索引上,加了记录锁,意味着其他事务无法更新或者删除 id = 15 的这一条记录;
在 id = 20 这条记录的主键索引上,加了 next-key 锁,范围是 (15, 20] 。意味着其他事务即无法更新或者删除 id = 20 的记录,同时无法插入 id 值为 16、17、18、19 的这一些新记录。
在特殊记录( supremum pseudo-record)的主键索引上,加了 next-key 锁,范围是 (20, +∞] 。意味着其他事务无法插入 id 值大于 20 的这一些新记录。
通过前面这个实验,我们证明了:
针对「大于等于」条件的唯一索引范围查询的情况下, 如果条件值的记录存在于表中,那么由于查询该条件值的记录是包含一个等值查询的操作,所以该记录的索引中的 next-key 锁会退化成记录锁。
2.针对 【小于或小于等于】 的范围查询
实验一:小于或小于等于范围查询,并且查询条件值【不存在】的情况
发起事务A,进行范围查询,同时id为6的数据不存在
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t where id<6 for update;
+----+------+---------+
| id | name | age |
+----+------+---------+
| 1 | a | 18 |
| 5 | b | 21 |
+----+------+---------+
事务A的加锁过程如下:
从第一行数据id=1开始查找,对该主键索引加(-∞,1]的next-key 锁
由于是范围查询,会继续向后查找,找到id=5的这条记录,于是对该主键索引加(1,5]的next-key锁
由于id=5的记录满足id<6的查询条件,所以没达到终止扫描的条件,继续向后扫描
扫描到第三行id=10 的数据,不满足id<6的条件,因此这行数据的锁会退化成间隙锁,于是对该主键索引加(5,10)的间隙锁
由于id=10 的记录不满足id<6的条件,停止扫描
查看下 data_locks中的加锁情况:
从上图中分析可以得出 事务A 在主键索引上加了三个X型锁 分别是(-∞,1]的next-key lock 、(1,5]的next-key lock 和 (5,10)的间隙锁
在 id = 1 这条记录的主键索引上,加了范围为 (-∞, 1] 的 next-key 锁,意味着其他事务即无法更新或者删除 id = 1 的这一条记录,同时也无法插入 id 小于 1 的这一些新记录。
在 id = 5 这条记录的主键索引上,加了范围为 (1, 5] 的 next-key 锁,意味着其他事务即无法更新或者删除 id = 5 的这一条记录,同时也无法插入 id 值为 2、3、4 的这一些新记录。
在 id = 10 这条记录的主键索引上,加了范围为 (5, 10) 的间隙锁,意味着其他事务无法插入 id 值为 6、7、8、9 的这一些新记录。
如果事务 A 的范围查询的条件改成 <= 6 ,由于id=6的数据行不存在,因此加锁范围还是和查询条件为<6的加锁范围一致。
结论
针对「小于或者小于等于」的唯一索引范围查询,如果条件值的记录不在表中,那么不管是「小于」还是「小于等于」的范围查询,扫描到终止范围查询的记录时,该记录中索引的 next-key 锁会退化成间隙锁,其他扫描的记录,则是在这些记录的索引上加 next-key 锁。
实验二:小于范围查询,同时查询条件值【存在】的情况
发起事务A,进行小于范围查询,同时id为5的数据存在
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql>select * from t where id<5 for update;
+----+------+---------+
| id | name | age |
+----+------+---------+
| 1 | a | 18 |
+----+------+---------+
事务A的加锁过程如下:
从第一行数据id=1开始查找,对该主键索引加(-∞,1]的next-key 锁
由于是范围查询,会继续向后查找,找到id=5的这条记录。由于这是第一条不满足id<5的记录,因此,对该主键索引加的锁退化成间隙锁(1,5)。
由于找到了第一条不满足id<6条件的记录,停止扫描
查看下 data_locks中的加锁情况:
从上图中分析可以得出 事务A 在主键索引上加了两个X型锁 分别是(-∞,1]的next-key lock 和 (1,5)的间隙锁
在 id = 1 这条记录的主键索引上,加了范围为 (-∞, 1] 的 next-key 锁,意味着其他事务即无法更新或者删除 id = 1 的这一条记录,同时也无法插入 id 小于 1 的这一些新记录。
在 id = 5 这条记录的主键索引上,加了范围为 (1, 5)的 间隙锁,意味着其他事务即无法插入 id 值为 2、3、4 的这一些新记录。
实验三:小于等于范围查询,同时查询条件值【存在】的情况
发起事务A,进行小于等于范围查询,同时id为5的数据存在
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
select * from t where id<=5 for update;
+----+------+---------+
| id | name | age |
+----+------+---------+
| 1 | a | 18 |
| 5 | b | 21 |
+----+------+---------+
事务A的加锁过程如下:
从第一行数据id=1开始查找,对该主键索引加(-∞,1]的next-key 锁
由于是范围查询,会继续向后查找,找到id=5的这条记录,对该主键索引加(1,5]的next-key lock。
由于主键索引具有唯一性,不会存在两个 id = 5 的记录,所以不会再继续扫描,于是停止扫描。
查看下 data_locks中的加锁情况:
从上图中分析可以得出 事务A 在主键索引上加了两个X型锁 分别是(-∞,1]的next-key lock 和 (1,5]的next-key lock
在 id = 1 这条记录的主键索引上,加了范围为 (-∞, 1] 的 next-key 锁,意味着其他事务即无法更新或者删除 id = 1 的这一条记录,同时也无法插入 id 小于 1 的这一些新记录。
在 id = 5 这条记录的主键索引上,加了范围为 (1, 5]的 next-key 锁,意味着其他事务即无法更新或删除id=5的这一条记录,同时也无法插入 id 值为 2、3、4 的这一些新记录。
通过以上三个实验,可以得出如下结论:
针对【小于或小于等于】的唯一索引范围查找,存在一下两种情况,会将next-key lock 退化成间隙锁
如果查询条件值【不存在】于数据表中,那么不管是小于还是小于等于的范围查找,当扫描到终止扫描的数据行时,索引上加的next-key lock会退化成间隙锁。其他扫描的记录,则是在这条记录的主键索引上加next-key lock。
如果查询条件值【存在】于数据表中,
小于的范围查找,当扫描到终止扫描的数据行时,索引上加的next-key lock会退化成间隙锁。其他扫描的记录,则是在这条记录的主键索引上加next-key lock
小于等于的范围查找,主键索引上加的都是next-key lock
非唯一索引等值查询
使用非唯一索引进行等值查询时,由于存在两个索引,一个主键索引,一个非唯一索引(二级索引)。因此,在加锁时,会同时对两个索引加锁,但是对于主键索引的加锁,只有当记录存在时才会加锁。
非唯一索引的等值查询,同样分为记录【存在】和【不存在】两种情况,加锁规则也有所不同:
当查询记录【存在】时,由于不是唯一索引,所以索引字段可能存在相同值。因此,非唯一索引的等值查询是一个扫描过程,直到扫描到第一个不满足查询条件的记录才停止扫描。扫描过程中对于扫描到的二级索引记录加next-key lock, 而对于第一个不满足条件的二级索引记录加锁将退化成间隙锁,同时会在符合查询条件的记录的主键索引上加记录锁。
当查询记录【不存在】时,扫描到第一个不满足查询条件的记录时,该记录的索引会退化成间隙锁。同时由于查询记录不存在,因此不会在主键索引上加锁。
做两个实验:
1.记录存在的情况
发起事务A,对于age字段的等值查询,并且age=22 的数据存在
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t where age=22 for update;
+----+------+------+
| id | name | age |
+----+------+------+
| 10 | c | 22 |
+----+------+------+
1 row in set (0.00 sec)
查看一下 data_locks 中显示的加锁情况
从上图可以分析得出,事务A 共加了三把锁,其中,主键索引加了一把锁,二级索引加了两把锁。
在分析二级索引的锁之前,首先搞定一个问题
二级索引树如何存放记录?
二级索引树是按照二级索引值(age)的顺序存放的,在相同的二级索引值下,再按照主键顺序存放。
二级索引
由于二级索引是非唯一索引,可能存在多个age 为22的值,因此会先在索引字段age上加一把 (21,22]的next-key lock。意味着其他事务无法更改或删除age=22的记录。
继续先后扫描,扫描到数据age=30, 这行数据为第一个不满足age=22的数据,将锁退化成(22,30)的间隙锁,同时停止扫描。意味着其他事务将无法插入 age值为 23,24,25 . . . 29的记录。
主键索引
由于age=22的记录存在,因为会对该记录的主键索引加一把记录锁。id=10的这一行数据会被加记录锁。意味着其他事务将无法更改或删除id=10的这一行记录
参照上图锁的位置,分析age值为21、22和30的新记录是否能插入:
在age=22这条记录的二级索引上,加了一把范围为 (21,22] 的 next-key 锁。对于是否可以插入 age=21 和 age=22的新纪录分析如下:
是否可以插入age=21的新记录,通过观察上图可以发现,如果插入age=21 并且id值小于5的话,是可以成功的。这个插入位置的下一行记录是id=5,age=21,范围内是没有间隙锁的,因此可以插入成功。如果新纪录的id 大于5则无法插入成功,例如:id=9,age=21的新记录,此时需要将记录插入到id值5和10之间的位置,但是这个位置存在二级索引的 next-key 锁。
是否可以插入age=22的新记录,同样分析一下需要插入的位置是否有间隙锁的存在。如果id值小于10,则需要插入到id 5和10之间的位置,此位置有next-key lock无法插入。如果id值大于10,则需要插入到id值10和20 之间的位置,此位置有(22,30)的间隙锁存在,因此也无法插入。
在age=30这条记录的二级索引上,加了一把范围为(22,30)的间隙锁。是否可以插入age=22和age=30的新记录,age=22的情况上面已经说明过了。采用同样的分析方法来分析一下age=30的新纪录,如果id值小于20的记录,需要插入到id值 10和20之间的位置,此位置有(22,30)的间隙锁,无法插入成功。如果id值大于20,从图中可以看出没有其他的锁加在id值大于20的位置上,因此可以插入成功。
同时,在id=10的记录上还加了一把记录锁,因此其他事务无法更改和删除id=10的记录。
2.记录不存在的情况
发起事务A,对于age字段的等值查询,并且age=24 的数据不存在
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t where age=24 for update;
Empty set (0.00 sec)
查看一下 data_locks 中显示的加锁情况
从上图可以分析得出,事务A 只加了一把间隙锁(22,30)
事务A的加锁过程,由于age=24的记录不存在,因此向后扫描到第一个不满足条件的记录后,停止扫描。该二级索引的next-key 锁会退化成间隙锁(22,30),意味着age值为 23,24 ... 29的新记录无法插入。age值为22 和30 的新记录是否可以插入,需要看插入的位置是否有间隙锁。
参照上图间隙锁的位置,分析age值为22和30的新记录是否能插入:
插入age=22的记录
如果插入记录的id值小于10,则在二级索引树上定位的插入位置的下一条数据为id=10,age=22 ,这条记录上没有间隙锁,因此可以插入成功。
如果插入记录的id值大于10,则在二级索引树上定位的插入位置的下一条数据为id=20,age=30 ,这条记录上有(22,30)的间隙锁,插入阻塞。
插入age=30的记录
如果插入记录的id值小于20,则在二级索引树上定位的插入位置的下一条数据为id=20,age=30 ,这条记录上有(22,30)的间隙锁,插入阻塞。
如果插入记录的id值大于20,则在二级索引树上定位的插入位置的下一条数据不存在,因此可以插入成功。
非唯一索引范围查询
非唯一索引的范围查询不管是大于、大于等于、小于、小于等于,加锁方式都相同,在二级索引上都是加next-key 锁,同时对扫描到的数据的主键索引加一把记录锁。
发起事务A,对于age字段的范围查询,并且age>=22
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t where age>=22 for update;
+----+------+------+
| id | name | age |
+----+------+------+
| 10 | c | 22 |
| 20 | e | 30 |
+----+------+------+
2 rows in set (0.00 sec)
查看一下 data_locks 中显示的加锁情况
事务A的加锁过程:
开始先找到age=22的记录,虽然这里包含等值查询,但是由于不是主键索引,因此不会退化成记录锁。直接加了一把(21,22]的next-key lock, 同时对age=22的这条记录的主键索引id=10加了一把记录锁。
接着向后扫描到age=30的记录,满足查询条件,因此会对该二级索引加一把(22,30]的next-key lock。同时,对age=30的这条记录的主键索引id=20加了一把记录锁。
由于age=30是最后一条记录,存储引擎会用一个特殊记录来标识最后一条记录,同时加一把(30,+∞]的 next-key lock
主键索引(id列):
id=10的记录锁,意味着其他事务无法删除或更新 id=10 的这一行记录。
id=20的记录锁,意味着其他事务无法删除或更新 id=20 的这一行记录。
非唯一索引(age列):
插入age=21的新记录,如果id值小于5,能成功插入,如果id值大于5,阻塞。
插入age=22的新记录,不管id值大于10还是小于10都无法插入。
(21,22]的next-key lock,其他事务无法删除或更新 age=22的这行记录。对于是否能插入age=21 和age=22的新记录,需要根据id值来判断。
(22,30]的next-key lock,其他事务无法删除或更新 age=30的这行记录,同时其他事务无法插入age值范围为 23,24,25... 29的新记录。对于age=22和age=30的新记录也无法插入。
(30,+∞]的next-key lock,其他事务无法插入age值大于30的记录。
在 age >= 22 的范围查询中,明明查询 age = 22 的记录存在并且属于等值查询,为什么不会像唯一索引那样,将 age = 22 记录的二级索引上的 next-key 锁退化为记录锁?
由于age字段是非唯一索引字段,不具备唯一性,如果只是加记录锁的话,只能防止其他事务删除或更新age=22的记录。如果其他事务插入age=22的新记录就会导致事务A前后两次查询数据不一致,导致幻读。
无索引的查询
如果查询条件没有用到索引列或者索引失效,会导致全表扫描,在每条记录的主键索引上加一把next-key lock。这就相当于锁住了全表,其他事务对该表的增、删、改 都会被阻塞。
总结
本次实验MySQL版本为8.0.18,在读提交和可重复读两种隔离级别下,分别介绍了行级锁的加锁规则
根据以上实验结果,总结一下MySQL的行级锁加锁规则
读提交隔离级别
有索引
由于RC的隔离级别的行级锁类型只有记录锁,因此在有索引的情况下,加锁规则比较简单,只对符合查询条件的记录加记录锁。
无索引
select ... for update的这类读锁定的语句,会先对全表数据顺序加锁,然后再判断是否符合查询要求,符合的锁就保留,不符合的就释放,相当于全表锁。
update语句与读锁定有所不同,update语句会先查找出符合要求的语句再加锁更新
可重复读隔离级别
可重复读的隔离级别下,行级锁的加锁基本单位是 next-key lock。但是,在不同的索引类型以及是否使用索引的不同情况下,next-key lock会相应的退化成记录锁或间隙锁
唯一索引等值查询
查询记录【存在】,扫描到该行记录后,会将索引上的next-key lock 退化成 记录锁
查询记录【不存在】,扫描到第一条大于该查询条件的记录后,将该记录的next-key lock 退化成 间隙锁
非唯一索引等值查询
查询记录【存在】,由于不是唯一索引,因此可能存在多个相同索引值的情况。于是,非唯一索引等值查询是一个扫描过程,直到扫描到第一个不满足查询条件的记录才停止扫描。对于扫描到的二级索引记录加next-key lock, 对于扫描到的第一个不满足查询条件的记录,二级索引会退化成间隙锁。同时,对于符合查询条件的记录的主键索引加记录锁。
查询记录【不存在】,扫描到第一条不符合查询条件的记录后,停止扫描。同时,将扫描到的记录二级索引的next-key lock 退化成 间隙锁。因为不存在符合查询条件的记录,所以不会对主键加记录锁。
唯一索引范围查找
大于等于的范围查找,并且记录存在的情况下,存在的记录的主键索引会退化成记录锁
小于或小于等于的范围查找,在查询条件记录不存在的情况下,终止扫描行的记录上的主键索引会退化成间隙锁。
小于的范围查找,在查询条件记录存在的情况下,该记录的主键索引上的锁会退化成间隙锁
非唯一索引范围查找
非唯一索引范围查找,所有扫描到的数据都会加 next_key lock ,不存在锁退化的情况。同时,所有符合查询条件的记录还会在主键索引上加记录锁。
无索引的查询
如果查询条件没有用到索引列或者索引失效,会导致全表扫描,在每条记录的主键索引上加一把next-key lock。这就相当于锁住了全表,其他事务对该表的增、删、改 都会被阻塞。生产环境中要尽量避免这类操作,否则会带来锁表,影响生产业务的情况发生。