字节二面:说一下你对MySQL加锁的理解?

  • Post author:
  • Post category:mysql



昨天在群里看到大家在讨论一个 MySQL 锁的问题,就是

执行 select … for update 语句,如果查询条件没有索引字段的话,是加「行锁」还是加「表锁」?


如果你做过这个实验的话,你会发现执行 select … for update 语句的时候,如果查询条件没有索引字段的话,整张表都无法进行增删改了,从这个现象看,好像是把表锁起来了,那难道是因为表锁的原因吗?


先不着急说结论。


MySQL 有提供分析数据表加了什么锁的命令,我们就通过这种方式来看看具体加的是什么锁,才导致整张表都无法进行增删改了。


做好准备


为了方便后续故事的展开,先创建一张 t_user 表。


表里有一个主键索引(id 字段),其他字段都不是索引字段,而是普通字段,表里面有下面这三条记录。


一条 select 语句会加什么锁?


不知道大家有没有好奇过,执行一条 select 查询语句会加什么锁呢?


相信大家都知道普通的 select 查询(快照读)语句是不会加行级锁(Innodb 层的锁),因为它是通过 MVCC 技术实现的无锁查询。


要验证这个结论也很简单,在 MySQL 8.0 以上的版本,可以执行 select * from performance_schema.data_locks\G; 这条语句,查看 Innodb 存储引擎为事务加了什么锁。


假设事务 a 执行了这条普通 select 的查询语句:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from t_user where age < 20;
+----+--------+-----+------------+
| id | name   | age | reward     |
+----+--------+-----+------------+
|1| 路飞   |19| 3000000000 |
+----+--------+-----+------------+
1 row in set (0.00 sec)

mysql> 


select * from performance_schema.data_locks\G; 这条语句,输出结果如下:


可以看到,输出结果是空,

说明普通 select 的查询语句, Innodb 存储引擎不会为事务加任何锁



那难道什么锁都不加吗?


当然不是的。


当我们对数据库表进行 DML 和 DDL 操作的时候,MySQL 会给这个表加上 MDL 锁,即元数据锁,

MDL 锁是 server 层实现的表级锁

,适用于所有存储引擎。

对一张表进行增删查改操作(DML 操作)的时候,加的是

MDL 读锁

对一张表进行表结构变更操作(DDL 操作)的时候,加的是

MDL 写锁


之所以需要 MDL 锁,就是

因为事务执行的时候,不能发生表结构的改变

,否则就会导致同一个事务中,出现混乱的现象,如果当前有事务持有 MDL 读锁,DDL 操作就不能申请 MDL 写锁,从而

保证表元数据的数据一致性



MDL 的读锁与写锁满足

读读共享,读写互斥,写写互斥

的关系,比如:


读读共享

:MDL 读锁和 MDL 读锁之间不会产生阻塞,就是说增删改查不会因为 MDL 读锁产生而阻塞,可以并发执行,如果不是这样,数据库就是串行操作了;


读写互斥

:MDL 读锁和 MDL 写锁之间相互阻塞,即同一个表上的 DML 和 DDL 之间互相阻塞;


写写互斥

:MDL 写锁和 MDL 写锁之间互相阻塞,即两个 session 不能同时对一张表结构做变更操作,需要串行操作;


如果在工作中,发现很多会话执行的 SQL 提示”Waiting for table metadata lock”的等待,这时候就是因为 MDL 的读锁与写锁发生冲突了,如果要应急解决问题,这时候就要考虑 kill 掉持有 MDL 锁的事务了,因为

MDL 锁是在事务提交后才会释放,这意味着事务执行期间,MDL 锁是一直持有的



如何查看事务是否持有 MDL 锁?


在前面,我们的事物 A 执行了普通 select 查询语句,如果要看该事务持有的 MDL 锁,可以通过这条命令 select * from performance_schema.metadata_locks;。


可以看到,事务 A 此时持有一个表级别的 MDL 锁,锁的类型是 SHARED_READ,也就是 MDL 读锁。


对于,增删改操作,申请的 MDL 锁的类型是 SHARED_WRITE,它也属于 MDL 读锁,因为 SHARED_WRITE 与 SHARED_READ 这两个锁的类型是相互兼容的。



因此,我们常说的普通查询不加锁,其实指的是不加 Innodb 的行级锁,但实际上是需要持有 MDL 锁的



一条 select … for update 会加什么锁?


select … for update 语句属于锁定读语句,它会对表的记录加 X 型的行级锁。


不同隔离级别下,行级锁的种类是不同的。


在读已提交隔离级别下,行级锁的种类只有记录锁,也就是仅仅把一条记录锁上。


在可重复读隔离级别下,行级锁的种类除了有记录锁,还有间隙锁(目的是为了避免幻读),所以行级锁的种类主要有三类:

Record Lock,记录锁,也就是仅仅把一条记录锁上;

Gap Lock,间隙锁,锁定一个范围,但是不包含记录本身;

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


行级锁加锁规则比较复杂,不同的场景,加锁的形式是不同的。


加锁的对象是索引,加锁的基本单位是 next-key lock,它是由记录锁和间隙锁组合而成的,next-key lock 是前开后闭区间,而间隙锁是前开后开区间。


但是,next-key lock 在一些场景下会退化成记录锁或间隙锁。


那到底是什么场景呢?总结一句,在能使用记录锁或者间隙锁就能避免幻读现象的场景下, next-key lock 就会退化成记录锁或间隙锁。之前也写过一篇详细介绍了不同的场景下, 行级锁的加锁规则,参见:保姆级教程!2 万字 + 30 张图搞懂 MySQL 是怎么加行级锁的?


这次我们只讨论,执行 select … for update 语句,如果查询条件没有索引字段的话,会加什么锁?


现在假设事务 A 执行了下面这条语句,

查询条件中 age 不是索引字段


mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from t_user where age < 20for update;
+----+-----------+-----+------------+
| id | name      | age | reward     |
+----+-----------+-----+------------+
|1|  路飞   |19| 3000000000 |
+----+-----------+-----+------------+
1 rows in set (0.00 sec)


这时候有其他事务对这张表进行增删改,都会发生阻塞。


先来看看,事务 A 持有什么类型的 MDL 锁?


可以执行 select * from performance_schema.metadata_locks\G; 这条语句,查看事务 A 此时持有了有什么类型的 MDL 锁。


执行结果如下:


可以看到,事务 A 此时持有一个表级别的 MDL 锁,锁的类型是 SHARED_WRITE,属于 MDL 读锁。


而在前面我提到过,当事务对表进行增删查改操作的时候,事务会申请 MDL 读锁,而

MDL 读锁之间是相互兼容的



所以,当事务 A 执行了查询条件没有索引字段的 select … for update 语句后,

不可能是因为事务 A 持 MDL 读锁,才导致其他事务无法进行增删改操作



再来看看,事务 A 持有哪些行级锁?


可以执行 select * from performance_schema.data_locks\G; 这条语句,查看事务 A 此时持有了哪些行级锁。


输出结果如下,我删减了不必要的信息:


从上图可以看到,共加了两种类型的锁,分别是:

1 个表级锁:X 类型的意向锁(表级别的锁);

4 个行级锁:X 类型的行级锁;


什么是意向锁?


在 InnoDB 存引擎中,当事务执行锁定读、插入、更新、删除操作后,需要先对表加上「意向锁」,然后再对记录加「行级锁」。


之所以要设计「意向锁」,目的是为了快速判断表里是否有行级锁,具体的说明参见:MySQL 全局锁、表级锁、行级锁,你搞清楚了吗?


意向锁不会和行级锁发生冲突,而且意向锁之间也不会发生冲突,

意向锁只会和共享表锁(lock tables … read)和独占表锁(lock tables … write)发生冲突



所以,当事务 A 执行了查询条件没有索引字段的 select … for update 语句后,

不可能是因为事务 A 持有了意向锁,才导致其他事务无法进行增删改操作



具体是哪 4 个行级锁?


图中 LOCK_TYPE 中的 RECORD 表示行级锁,而不是记录锁的意思:

如果 LOCK_MODE 为 X,说明是 X 型的 next-key 锁;

如果 LOCK_MODE 为 X, REC_NOT_GAP,说明是 X 型的记录锁;

如果 LOCK_MODE 为 X, GAP,说明是 X 型的间隙锁;


然后通过 LOCK_DATA 信息,可以确认 next-key 锁的范围,具体怎么确定呢?


根据我的经验,如果 LOCK_MODE 是 next-key 锁或者间隙锁,那么 LOCK_DATA 就表示锁的范围最右值,而锁范围的最左值为 LOCK_DATA 的上一条记录的值。


因此,此时事务 A 在主键索引(INDEX_NAME : PRIMARY)上加了 4 个 next-key 锁,如下:

X 型的 next-key 锁,范围:(-∞, 1]

X 型的 next-key 锁,范围:(1, 2]

X 型的 next-key 锁,范围:(2, 3]

X 型的 next-key 锁,范围:(3, +∞]



这相当于把整个表给锁住了,其他事务在对该表进行增、删、改操作的时候 都会被阻塞

。只有在事务 A 提交了事务,事务 A 执行过程中产生的锁才会被释放。


为什么因为事务 A 对表所有记录加了 X 型的 next-key 锁后,其他事务就无法进行增、删、改操作了呢?


其他事务在执行「删除或者更新操作」的时候,也会申请 X 型的 next-key 锁,next-key 锁是包含记录锁和间隙锁的,间隙锁之间虽然是相互兼容的,但是记录锁之间存在 X 型和 S 型的关系,即读读共享、读写互斥、写写互斥的关系。


所以当事务 A 持有了 X 型的 next-key 锁后,其他事务就无法申请 X 型的 next-key 锁,从而发生阻塞。


比如,前面的例子,事务 B 在更新 id = 1 的记录的时候,它会申请 X 型的记录锁(

唯一索引等值操作, next-key 锁会退化为记录锁

),但是因为事务 A 持有了 X 型的 next-key 锁,所以事务 B 在申请 X 型的记录锁的时候,会发生阻塞。


我们也可以通过 select * from performance_schema.data_locks\G; 这条语句得知。


事务 C 的删除操作被阻塞的原因,也是这个原因。


事务 D 的插入操作被阻塞的原因,跟事务 B 和事务 C 的原因不同。



插入语句在插入一条记录之前,需要先定位到该记录在 B+树 的位置,如果插入的位置的下一条记录的索引上有间隙锁,如果已加间隙锁,此时会生成一个插入意向锁,然后锁的状态设置为等待状态,现象就是插入语句会被阻塞



事务 D 插入了一条 id = 10 的新记录,在主键索引树上定位到插入的位置,而该位置的下一条记录是 supremum pseudo-record,该记录是一个特殊的记录,用来标识最后一条记录,而该特殊记录上正好持有了间隙锁(next-key 锁包含间隙锁),所以这条插入语句会发生阻塞。


我们也可以通过 select * from performance_schema.data_locks\G; 这条语句得知。


为什么只是查询年龄 20 岁以下的行记录,而把整个表给锁住了呢?


这是因为事务 A 的这条锁定读查询语句,没有使用索引列作为查询条件,所以扫描的方式是

全表扫描,行级锁是在遍历索引的时候加上的,并不是针对输出的结果加行级锁



不只是锁定读查询语句不加索引才会导致这种情况,update 和 delete 语句如果查询条件不加索引,那么由于扫描的方式是全表扫描,于是就会对每一条记录的索引上都会加 next-key 锁,这样就相当于锁住的全表。


因此,在线上在执行 update、delete、select … for update 等具有加锁性质的语句,

一定要检查语句是否走了索引,如果是全表扫描的话,会对每一个索引加 next-key 锁,相当于把整个表锁住了,这是挺严重的问题



如果数据量很大,还是一样的原因吗?


前面我们结论得出,如果如果锁定读查询语句,没有使用索引列作为查询条件,导致扫描是全表扫描。那么,每一条记录的索引上都会加 X 型的 next-key 锁(行级锁)。正是因为这个原因,才导致其他事务,无法对该表进行增删改操作。


那如果一张表的数据量超过几百万行,还是一样对每一条记录的索引上都会加 X 型的 next-key 锁吗?


群里有小伙伴提出了这个说法,说如果 MySQL 认为数据量太大时,自动将行所升级到表锁。


不着急说结论,我们直接做个实验。


我在 t_user 表插入了 300 多万条数据。


现在有个事务执行了这条查询语句,查询条件 age 字段不是索引字段。

mysql> begin;
Query OK, 0rows affected (0.00 sec)

mysql> select * from t_user where age < 20 for update;


然后,我们执行 select * from performance_schema.data_locks\G; 这条语句(我执行了好长时间,至少有几十分钟)。


可以看到,每一条记录的索引上都会加 X 型的 next-key 锁(行级锁)。


所以,MySQL 认为数据量太大时,自动将行所升级到表锁 ,

这句话并不准确



总结


在执行 select … for update 语句的时候,会有产生 2 个表级别的锁:

一个是 Server 层表级别的锁:

MDL 锁

。事务在进行增删查改的时候,server 层申请 MDL 锁都是 MDL 读锁,而 MDL 读锁之间是相互兼容的,MDL 读锁只会和 MDL 写锁发生冲突,在对表结构进行变更操作的时候,才会申请 MDL 写锁。

一个是 Inoodb 层表级别的锁:

意向锁

。事务在进行增删改和锁定读的时候,inoodb 层会申请意向锁,意向锁不会和行级锁发生冲突,而且意向锁之间也不会发生冲突,意向锁只会和共享表锁(lock tables … read)和独占表锁(lock tables … write)发生冲突。


如果 select … for update 语句的查询条件没有索引字段的话,整张表都无法进行增删改了,从这个现象看,好像是把表锁起来了,

但是并不是因为上面这两个表级锁的原因



而是

因为如果锁定读查询语句,没有使用索引列作为查询条件,导致扫描是全表扫描。那么,每一条记录的索引上都会加 next-key 锁(行级锁),这样就相当于锁住的全表,这时如果其他事务对该表进行增、删、改操作的时候,都会被阻塞


资源获取:

大家

点赞、收藏、关注、评论

啦 、

查看

👇🏻
👇🏻
👇🏻

微信公众号获取联系方式

👇🏻
👇🏻
👇🏻


精彩专栏推荐订阅:



下方专栏

👇🏻
👇🏻
👇🏻
👇🏻


每天学四小时:Java+Spring+JVM+分布式高并发,架构师指日可待



版权声明:本文为m0_63437643原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。