查看原文
其他

一步步做实验,深入理解MySQL行锁

运维路书 运维路书
2024-11-05

本文参考文献来自小林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的锁释放。


为什么唯一索引等值查询并且查询记录存在的场景下,该记录的索引中的 next-key lock 会退化成记录锁?


原因就是在唯一索引等值查询并且查询记录存在的场景下,仅靠记录锁也能避免幻读的问题。

幻读的定义就是,当一个事务前后两次查询的结果集,不相同时,就认为发生幻读。所以,要避免幻读就是避免结果集某一条记录被其他事务修改或删除,或者有其他事务插入了一条新记录,这样前后两次查询的结果集就不会出现不相同的情况。

  • 由于主键具有唯一性,所以其他事务插入 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的数据行无法插入显示阻塞


间隙锁的范围(1, 5) ,是怎么确定的?


如果 LOCK_MODE 是 next-key 锁或者间隙锁,那么 LOCK_DATA 就表示锁的范围「右边界」,此次的事务 A 的 LOCK_DATA 是 5。

然后锁范围的「左边界」是表中 id 为 5 的上一条记录的 id 值,即 1。

因此,间隙锁的范围(1, 5)。


为什么唯一索引等值查询并且查询记录「不存在」的场景下,在索引树找到第一条大于该查询记录的记录后,要将该记录的索引中的 next-key lock 退化成「间隙锁」?


  • 为什么 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 加锁变化过程如下:

  1. 最开始要找的第一行是 id = 20,由于查询该记录不是一个等值查询(不是大于等于条件查询),所以对该主键索引加的是范围为  (15, 20] 的 next-key 锁;

  2. 由于是范围查找,就会继续往后找存在的记录,虽然我们看见表中最后一条记录是 id = 20 的记录,但是实际在 Innodb 存储引擎中,会用一个特殊的记录来标识最后一条记录,该特殊的记录的名字叫 supremum pseudo-record ,所以扫描第二行的时候,也就扫描到了这个特殊记录的时候,会对该主键索引加的是范围为  (20, +∞] 的 next-key 锁。

  3. 停止扫描。


查看下 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 加锁变化过程如下:

  1. 最开始要找的第一行是 id = 15,由于查询该记录是一个等值查询(等于 15),所以该主键索引的 next-key 锁会退化成记录锁,也就是仅锁住 id = 15 这一行记录。

  2. 由于是范围查找,就会继续往后找存在的记录,扫描到的第二行是 id = 20,于是对该主键索引加的是范围为  (15, 20] 的 next-key 锁;

  3. 接着扫描到第三行的时候,扫描到了特殊记录( supremum pseudo-record),于是对该主键索引加的是范围为  (20, +∞] 的 next-key 锁。

  4. 停止扫描。

查看下 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的加锁过程如下:

  1. 从第一行数据id=1开始查找,对该主键索引加(-∞,1]的next-key 锁

  2. 由于是范围查询,会继续向后查找,找到id=5的这条记录,于是对该主键索引加(1,5]的next-key锁

  3. 由于id=5的记录满足id<6的查询条件,所以没达到终止扫描的条件,继续向后扫描

  4. 扫描到第三行id=10 的数据,不满足id<6的条件,因此这行数据的锁会退化成间隙锁,于是对该主键索引加(5,10)的间隙锁

  5. 由于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的加锁过程如下:

  1. 从第一行数据id=1开始查找,对该主键索引加(-∞,1]的next-key 锁

  2. 由于是范围查询,会继续向后查找,找到id=5的这条记录。由于这是第一条不满足id<5的记录,因此,对该主键索引加的锁退化成间隙锁(1,5)。

  3. 由于找到了第一条不满足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的加锁过程如下:

  1. 从第一行数据id=1开始查找,对该主键索引加(-∞,1]的next-key 锁

  2. 由于是范围查询,会继续向后查找,找到id=5的这条记录,对该主键索引加(1,5]的next-key lock。

  3. 由于主键索引具有唯一性,不会存在两个 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。这就相当于锁住了全表,其他事务对该表的增、删、改 都会被阻塞。生产环境中要尽量避免这类操作,否则会带来锁表,影响生产业务的情况发生。





继续滑动看下一个
运维路书
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存