MySQL锁机制及其原理

2023年01月01日 2063点热度 0人点赞 0条评论

MySQL有哪些锁呢,从锁范围大小可划分为全局锁、表级锁、行级锁,下面按照这个顺序分别记录一下这些锁的机制及其可用的应用场景。
说明:本文阐述的MySQL锁机制是基于MySQL的innoDb引擎,未明确指明隔离级别的默认都为可重复读。

全局锁

全局锁是指对整个数据库实例进行加锁(可理解为库级别锁?),加全局锁的命令为:flush tables with read lock(FTWRL)。FTWRL锁定整库为只读状态,解锁时用unlock tables进行解锁,或者客户端断开链接自动释放锁。FTWRL做了什么呢?首先这条命令设置了一个全局锁,加完锁之后要对内存中的脏页进行刷新到磁盘的操作,从而保证磁盘数据与内存一致,这里设置全局只读锁也是为了保证再刷脏页的过程中内存中的数据不会更改。

create TABLE t1( `id` int not null, `a` int, `b` int, primary key(id), key(a)) ENGINE=InnoDB;
create table t2 like t1;
sessionA sessionB sessionC
flush tables with read lock;
update t1 set a = 1 where id = 1;(blocked)
insert into t2 values (2,2,2); (blocked)
unlock tables;
query ok; query ok;

全局读锁的设置要等待数据库的所有查询都完成之后才能进行,全局读锁设置后数据库的任何数据修改操作和DDL操作都要等到全局读锁释放后才能继续。

sessionA sessionB sessionC
select sleep(100) from t1;
flush tables with rea lock;(blocked)
select * from t1 limit 1;(blocked)

全局锁的典型使用场景是做全库逻辑备份:1)FTWRL能刷内存脏页到磁盘,使得磁盘的数据为最新数据。2)FTWRL能锁定整库只读,保证整库的逻辑视图一致性。

逻辑备份的其他可能手段:

1.mysqldump
mysql自带的mysqldump是采用–single-transaction参数,获取一致性视图,这种方法的优势在于导数据的同时还能同时对数据库进行操作。它要求库中所有的表都支持事务引擎才行。
2.set global readonly=true
设置全库只读,也能保证备份过程中数据不会进行变更。但这种方式并不适合,原因在于:
1)global影响范围比较大,有些主从模式会根据这个字段的设置区分数据库的主备。
2)global readonly 对于有超级权限的用户来说是无效的。
3)一旦备份过程出现异常,readonly变量的值也不会自动恢复,对业务的影响更大。

表级锁

表锁
表锁的语法是 lock tables tablename read/write;解除锁定时需要手动调用unlock tables或者客户端断开时自动释放。表锁的不仅限制当前线程同时也限制其他线程的读写。读锁限制本线程和其他线程可读,所有线程均不可写;写锁限制只能本线程可读写,其他线程不可读写表。表锁的影响范围还是比较大,常用的场景是在不支持行锁的表中做线程并发。

sessionA sessionB sessionC
lock tables t1 read,t2 write;
update t1 set a = 2 where id = 1;(blocked)
update t1 set a = 2 where id = 1;(blocked) select * from t2 limit 1;(blocked)
update t1 set a = 2 where id = 1;(Error) update t2 set a = 2 where id = 1(blocked)
select * from t1 limit 1;(Query OK)

元数据锁

元数据锁(metadata lock)简称MDL锁,是用来保证表结构在ddl过程中数据读写的正确性。MDL不需要显示的调用添加,在对数据进行增删改查时会对相应的表加MDL读锁,对表结构进行修改时,会对表加MDL写锁。metadata lock读写锁的互斥情况如下:

兼容 不兼容
不兼容 不兼容

一种修改表结构引起查询异常的场景,sessionA开启了事务进行简单查询,这时候会对t1表加一个MDL读锁,此时sessionB请求对t1表进行表结构变更,要对t1表加MDL写锁,写锁与读锁互斥所以要等到sessionA释放MDL读锁,此后其他线程再请求对t1表的增删改查均会因等待MDL写锁释放而被blcoked。这里解释一下,sessionB虽然还未获得MDL写锁,但由于mysql的锁队列问题,在sessionA释放锁之后sessionB会优先获得锁。

sessionA sessionB sessionC sessionD
begin;select * from t1 limit 1;
alter table t1 add c int;(blocked)
select * from t1 limit 1;(blocked)
update t1 set a =2 where id = 1;(blocked)

所以,对于线上表结构的修改不论大小表,都应该谨慎对待,我们再开发过程中也应该注意,1)尽量不要使用长事务,能尽早提交的事务一定要尽早提前,2)热点表ddl时设置等待时间。

意向锁
上面所讲述的全局锁、表锁、MDL锁都是mysql的server层锁,意向锁则是innodb引擎层的锁,我们知道innodb的数据存储是树状的结构,我们再请求对树的最底层(行)添加锁时,同时又要进行全表的锁定读操作 ,这时就需要对数据库的行进行遍历查询是否有行并锁住,这是个非常耗时且繁琐的过程,所以innodb引入了意向表锁,粗粒度的表锁,当线程请求表锁时不需要对表中的每一行查询是否有排他锁,直接确认表是否有排他意向锁即可。意向锁不需要显示的添加,当对数据库的行请求共享锁时,会自动给相应的表加上意向共享锁(IS),当我们请求互斥锁时,会自动给相应的表加意向互斥锁(IX),当进行全表扫描时优先

#sessionA select * from t1 where id = 1 for update;
#sessionB select * from performance_schema.data_locks\G

sessionA sessionB
begin;select * from t1 where id = 1 for update;
lock tables t1 read;(blocked)

意向锁和表锁的互斥情况如下:

IS(意向共享锁) IX(意向拍他锁) S(共享表锁) X(排他表锁)
IS 兼容 兼容 兼容 不兼容
IX 兼容 兼容 兼容 不兼容
S 兼容 兼容 兼容 不兼容
X 不兼容 不兼容 不兼容 不兼容

小结:

表锁是myISam常用的控制并发的手段。
MDL锁保证了数据库读写和表结构变更之间的一致性。
意向锁提高了添加表锁的效率。

行级锁

行级锁是在MySQL的引擎上实现的,InnoDB引擎支持行级锁,而MyISAM不支持。行级锁,顾名思义加在数据行上的锁,mysql的行级锁又分为共享锁和排他锁。行级锁是粒度比较细的锁了,它在保证数据一致性的同时也提高了MySQL的最大并发程度。

两阶段锁协议

行锁总是再需要的时候加上,但在事务提交时才释放。

sessionA sessionB
begin;select * from t1 where id = 1;
update t1 set a = 2 where id = 1;(Query OK)
update t1 set a = 3 where id = 1;
update t1 set a = 2 where id = 1;(blocked)

共享锁

共享锁的启用方式 select * from t1 lock in share mode。共享锁常用应用场景之一是有外键的数据行更新和修改时,外键关联的表会被mysql自动加上共享锁,以防止依赖库与当前数据库数据不一致的情况发生。另一种应用场景为当前事务需要读到行最新的数据时,也可加lock in share mode。

共享锁与排他锁互斥,与共享锁之间兼容,如下sessionA对id=1加了共享锁之后,sessionB的Q2语句也要请求id=1行的共享锁,查询成功,Q3语句请求id=1行的排他锁则被阻塞。

sessionA sessionB
Q1 begin;select * from t1 where id = 1 lock in share mode;
Q2 select * from t1 where id = 1 lock in share mode;
Q3 select * from t1 where id = 1 for update;(blocked)

加锁之后的读操作是读当前已提交的最新版本,这与可重复度的隔离范围有点冲突,如下事例,再sessionA中查询id=1行的结果为(1,1,1),此时并没有对该行加锁,所以sessionB可以对该行再进行修改,修改完后,再隔离级别为RR的情况下再查询该行(Q4),显示结果为(1,1,1),加了lock in share mode 一致性读之后的查询(Q5),结果则为(1,4,1)。

sessionA sessionB
Q1 begin;
Q2 select * from t1 where id = 1;(1,1,1)
Q3 update t1 set a= 4 where id = 1;
Q4 select * from t1 where id = 1;(1,1,1)
Q5 select * from t1 where id = 1 lock in share mode;(1,4,1)

排他锁

排他锁的显示启用方式为select * from t1 where id = 1 for update,会给对应索引行加一个互斥写锁,排他锁与共享锁的互斥情况同MDL的读写锁一致。数据的任何增删改都会给对应行加一个排他锁。当需要对数据行进行独占式修改时可以使用for update独占该行进行处理。例如,库存的车票、商品库存的修改等。加了排他锁之后的读也是读当前已提交的最新版本,同共享锁一致,这里不再就做事例了。排他锁与其他线程的共享锁和排他锁都互斥。

sessionA sessionB sessionC
begin;
select * from t1 where id = 1 for update;
select * from t1 wher id = 1 lock in share mode;(blocked)
update t1 set a = 4 where id = 1;(blocked)

间隙锁

幻读
幻读是指在同一事务中两次不同的查询出现的结果可能不一致的情况。如下事例,如果我们在sessionA中使用了当然读的查询,在sessionB中再插入新的数据满足sessionA的查询条件,我们假设mysql只有行锁的情况下,在Q1执行时会产生id = 2的行锁,此时插入vlaues(3,3,3)可以成功,那么就出现了Q1和Q3查询结果不一致的问题,也就是幻读。

sessionA sessionB
Q1 begin;select * from t1 where a >1 for update;(2,2,2)
Q2 insert into t1 values (3,3,3);
Q3 select * from t1 where a >1 for update;(2,2,2),(3,3,3)

间隙锁
mysql解决幻读问题的机制是引入间隙锁,以上执行的真实情况为,sessionB在执行Q2时会被阻塞,原因在于Q1执行的过程中不仅会给查询的行加上行锁,还会给查询到的所有行周围都加上间隙锁。t1表当前的值有(1,1,1) ,(2,2,2)两个,所以加锁的地方有两个a=2的行锁和a索引上(2,+∞)的间隙锁。间隙锁与间隙锁之间是共享的,与间隙锁互斥的是在间隙区间插入数据。

sessionA sessionB
begin;select * from t1 where a = 2 for update;
insert into t1 values (3,3,3)(blocked)

加锁原则

查询过程中访问的对象才会加锁
加锁的基本单位是next-key lock,行锁+间隙锁(前开后闭区间)
唯一索引的范围查询会访问到第一个不满足条件的行为止
优化1:唯一索引上的等值查询会退化为行锁
优化2:非唯一的普通索引上的查询会退化为间隙锁,即最后一个不满足条件的行不会加锁。
事例1:

全表扫描,验证是否所有被访问的记录都加了锁&所有间隙都加了锁。结果如下所示,验证了规则1,2

#sessionA
truncate table t1;
insert into t1 values (1,1,1),(3,3,3),(5,5,5);
begin;
select * from t1 for update;
#sessionB
select THREAD_ID,OBJECT_SCHEMA,OBJECT_NAME,INDEX_NAME,LOCK_TYPE,LOCK_MODE,LOCK_STATUS,LOCK_DATA from data_locks;
#sessionC
insert into t1 values(2,2,2); //blocked

事例2:

查询b<5的所有记录,可以看到c=5这一行也加了排他锁,结合刚说的加锁原则我们可以分析一下这个过程,首先我们查询的是b的值,b没有索引,所以从头遍历所有数据,第一个数据(1,1,1)加上row1的排他锁,加锁单位为next-key lock,所以为(-∞,1],符合规则继续下一个(3,3,3),加锁(1,3],符合规则继续下一个(5,5,5),加锁(3,5]不符合规则。继续下一个,数据库已经没有行,加(5,+∞)的间隙锁。由此可见,没有索引的查询是全表扫描,再没有索引的列上的一致性查询会造成全表的锁定。

#sessionA select * from t1 where b < 5 for update;

事例3:

把上述事例换成唯一索引上的查询,结果如下所示,加锁范围为(-1∞],(1,3],间隙锁(3,5)。可见到a=5行之后并没有再继续,且id=5行也没有上锁,验证规则3、5.

select * from t1 where a < 5 for update ;

事例4:

那我们把条件换成唯一索引上的等值查询再试一下呢,如下,结果加锁只有id=5这一行。验证规则4。

select * from t1 where id = 5 for update;

锁问题
mysql的锁为数据库的并发带来了保障的同时也会随之而来带来很多问题,在不了解锁或者使用不当的情况下也会对我们的业务系统造成影响。

阻塞
查询阻塞,我们在开发过程中也会遇到一些即使很简单的SQL执行起来却很慢的问题,上面在讲述MDL锁时就提到过一个场景,可以再回顾一下,这就是等待MDL锁造成的查询阻塞问题。

再比如我们上面总结片段的事例,一个事务在没有索引的列上进行查询时,会造成全表的锁定,从而导致其他客户端的修改操作都会被锁定,这是行锁和间隙锁造成的查询阻塞。

长事务和加表级锁是造成阻塞的大部分原因,所以我们再开发过程中应尽量避免长事务的存在,对于表级锁的使用也要谨慎。一旦出现这种阻塞问题,我们也可以通过mysql的一些系统表信息进行查看,及时kill掉影响业务的线程从而避免对业务系统造成比较大的影响。

查看mysql锁现象的几种手段

show engine innodb status

可以看到当然mysql实例的状态,其中就有当前正在等待的事务信息。如下红框所示,语句select * from t1 where id = 5 for update语句正在等待记录X锁,仅行锁非间隙锁。

可以看到当前正在执行的事务及其使用的锁的个数

select * from information_schema.innodb_trx\G

分别可以看到行锁、表锁及行锁等待时间等信息。

select * from performance_schema.data_locks , metadata_locks, data_lock_waits\G

死锁

死锁产生的原因在于并发进程中两个进程互相等待对方释放自己所需要的锁从而陷入循环之中造成死锁。mysql处理死锁的手段立马回滚造成死锁的事务中权重最低的一个事务,从而保证其他事务能够正常进行。

事例1:两个进程同时多个资源的锁且顺序不一致,并发情况下有可能触发。

sessionA sessionB
begin;select * from t1 where id in (1,3,5) for update;加锁顺序 1,3,5
begin;select * from t1 where id in (1,3,5) order by id desc for update;加锁顺序5,3,1

事例2:两个进程同时请求多个资源锁分布进行。

sessionA sessionB
begin;select * from t1 where id = 1 for update;
begin;select * from t1 where id = 3 for update;
select * from t1 where id = 3 for update;(blocked)
select * from t1 where id = 1 for update;(Dead lock)

事例3:查询数据产生间隙锁,与另一并发进程同时插入数据造成死锁。

sessionA sessionB
begin;select * from t1 where a = 7 for update;
begin;select * from t1 where a = 5 for update;
insert into t1 values (7,7,7);(blocked)
insert into t1 values (7,7,7);(dead lock)

路灯

这个人很懒,什么都没留下

文章评论