之前的一篇文章介绍了mysql的四种隔离级别,本篇主要介绍一下mysql是如何进行加锁处理的。主要想了解mysql在处理高并发情况下的读写以及可能遇到的并发问题之间是如何去兼容的。我们都知道加锁是比较资源的操作,但是不加锁也会面临一些并发的问题,本篇文章将基于四种隔离级别去介绍mysql是如何进行加锁的处理的。

当前读和快照读

我们需要先了解一下当前读和快照读的区别,实际上在mysql中是区分这两种读的,看两个sql语句:1

2select * from test_test where ? for update;//这一整个事务是一个当前读(实际上是一个写操作)

select * from test_test where ?;//这个事务是一个快照读(实际上只是一个从数据库获取数据的操作)

当前读:delete,insert,update操作都可以视为当前读,从语义上可以理解为select for udpate,都是先读再写,需要保证并发的读写数据一致性。

快照读:单纯的select操作,数据库读取操作,不同的数据库隔离级别可能面临重复读的不一致问题和幻读问题。

在mysql中,针对快照读只有在最高的隔离级别serializable级别下才会对快照读进行加锁,其他的级别下都是不加锁的。针对当前读,mysql在各个级别下为了防止并发的数据不一致问题都会进行加锁,加排他锁,也可以理解为写锁。

各种隔离级别下的加锁机制

我们探讨一下RC隔离级别下的几种索引遍历加锁处理的情况:

RC+UUK(不可重复读+非唯一索引)

我们将mysql的隔离级别调整为RC,建立一个test_test表,其中id是primary key,code是unique key。分别执行下面的sql:

begin;

select * from test_test where code=5 for update;

//先在session1中执行该sql1

id name code

13yqz 5

14yqz 5

update test_test set test_test.name='pyx' where code =5;

//然后在session2中执行该sql2

执行结果,sql2获取锁超时。

结论:RC隔离级别下对快照读不加锁,会存在重复读的数据不一致问题+幻读问题。对当前读,RC隔离级别会加锁,其他事务不允许修改已经被当前读锁定的修改记录。

begin;
select * from test_test where code=5 for update;
//先在session1中执行该sql1
id name code
13yqz 5
14yqz 5
insert into test_test values(20,'yqz',7);//session2可以正常插入
insert into test_test values(20,'yqz',5);//session2不能插入,锁竞争
update test_test set code=1 where id=13;//session2不能插入,锁竞争

做了个图,分析下这里的加锁:


mysql update加锁过程 mysql 加锁处理分析_mysql update加锁过程

结论:RC隔离级别对当前读会加锁,也会加GAP间隙锁防止两次当前读之间发生后幻读。但是两次快照读之前还是不能防止幻读的(这个在mysql事务和事务隔离机制文章中可以看到相关的介绍)。

分析,insert into test_test values(20,’yqz’,5)语句碰到了GAP间隙锁导致了锁竞争,update test_test set code=1 where id=13语句碰到了主键的记录导致了锁竞争。这也是为什么在根据un unique key遍历的时候我们要加两次锁的原因,因为可能有别的sql udpate是根据primary key来进行的。这里不熟悉mysql的索引的朋友们可以了解下mysql的索引结构,primary key对应的是聚族索引(innodb数据库引擎),基于B+TREE结构,在每个叶子结点都保存了完整的信息。但是建立的所有unuque key只保存了索引相关的信息,我们可以根据un unique key建立的B+TREE去遍历到primary key,再根据这个primary key去遍历主键索引。

RC+UK(不可重复读+唯一索引)

基于上面的分析,我们可以类比得到,这种情况下当前读也会在unique key对应的结果加排他锁,同时也加两个间隙锁。在对应的主键记录的叶子节点位置加排他锁,不存在幻读问题。但是针对快照读在RC隔离级别下还是会存在不可重复读+幻读问题。

RR+UK(可重复读+唯一索引)

类比RC+UK,在当前读上的加锁处理没有什么区别,都不会存在重复读的问题和幻读问题。但是在快照读上,RR是通过了事务版本号和数据库记录版本号来限制了重复读不一致的问题的,也是旧版本的事务永远读不到新版本的事务提交的更改,删除以及新增记录。有的博客在介绍的时候会说是GAP间隙锁防止了幻读的发生,这个也有一定的道理,但是这个是针对当前读的,并不是针对快照读,快照读除非在串行的前提下才加锁,其他的情况都不加锁。

RC/RR+NK(不可重复读/可重复读 +无索引)

无索引会直接根据主键进行全表扫描,同时会将整个表锁住,这种情况是及其低效的。

分析一条复杂sql加锁

通过上面的学习,我们尝试去分析一条sql的加锁情况:

mysql update加锁过程 mysql 加锁处理分析_mysql_02

tips:这里有一种场景,mysql innodb默认会在这种查询的时候正向加锁(next-key lock===gap锁+右边的行锁),如果是倒序排序的话,会在倒序的末尾再加一个点。这是比较诡异的地方。

mysql死锁的发生

在mysql中也会发生一些死锁的情况,我们熟知发生死锁的条件能够帮助我们避免写出死锁。下面几种情况会发生死锁

session1
select * from test_test where code=5 for udpate;
select * from test_test where id=1 for update;
session2
select * from test_test where id=1 for update;
select * from test_test where code=5 for udpate;
//这种是比较简单的情况,循环加锁导致死锁
session1
select * from test_test where code=5 for udpate;
id name code
13yqz 5
select * from test_test where code=4 for udpate;
id name code
12yqz 4
session2
select * from test_test where id=12 for udpate;
select * from test_test where id=13 for udpate;

//这里虽然不是直接的进行了循环加锁,但是由于在聚簇索引的节点处也加了锁,实际上是有一个加锁的闭环的。我们通过mysql的加锁处理分析可以更准确的得到加锁信息,从而分析出死锁

MDL表级锁

mysql事务在执行的过程中,对表级别的操作分为两部分:表数据的增删改查,以及表结构的增删改查。表数据的增删改查需要获取MDL读锁,表结构的增删改查需要获取MDL写锁。MDL表级锁具备如下特性:

1.MDL读锁是共享锁,不同事务不阻塞。

2.MDL写锁是独占锁,会阻塞在读锁和写锁上。

表级锁在实际操作过程中,可能因为不恰当的操作导致业务发生异常,这里给个实例,下面三个事务依次执行:

session a:

begin;

select * from test_test where code=2;//事务a获取到mdl表级锁的读锁1

2session b:

alter table add column city varchar(16);//事务b获取mdl写锁被block1

2session c:

select * from test_test where code=1;//事务c获取mdl读锁被block

这里我们看到读锁和读锁之间虽然是共享锁,但是如果中间穿插了一个写锁的block,还是会有序被block住的。这里我们如果将事务a提交,mysql会依次执行b,c。所以我们在实际 操作过程中如果要对线上的数据的表进行变更,需要保证没有长事务,如果当前正有个长事务在对表数据进行操作,那么执行表结构变更的写操作会被block导致其他的读操作也被block。可以考虑在执行表结构变更的时候:查看执行中是否有长事务,kill长事务

alter等修改操作设置等待时间(需要引擎支持)

mysql行锁

mysql中仅innodb支持行锁,其他引擎不支持行锁。我们通过一个实例来进行介绍mysql的行锁。假如A在B影院买了一张电影票,C也在B影院买了一张电影票。那么这两个事务都需要对客户账户余额做-操作,同时对影院的余额也做+操作。先介绍一些主要的概念再进行展开。减少客户的账户余额

增加商家的账户余额

增加一条交易记录

上面的整个过程需要保证原子性。我们通常称之为一个事务,要么全部成功要么全部失败。

两阶段锁协议:innodb中的行锁是需要的时候会进行加锁,但是并不是不需要的时候就会释放锁,需要事务提交之后才会释放锁。

死锁:行锁发生了循环引用导致发生死锁,线程在锁上持续等待。

如何解决死锁:代码中避免循环引用;可以通过设置超时时间和开启数据库死锁检测防止死锁。设置超时时间可能导致部分正常业务也就是没有发生死锁的事务被回滚,对业务是有损的;开启数据库死锁检测对业务是无损的,但是死锁的检测机制会占用大量的cpu。

热点更新:这里我们讨论的热点更新,可以假设在上面的事务过程中,出现了双11类似的疯狂买票操作。那么这个场景下对商家余额的记录的写操作是非常频繁的。如果开始了死锁检测,那么等待的线程假设是1000个,那么这些线程会不断去检测死锁也就是1000*1000的复杂度,这个占用大量的cpu。

如何解决热点更新: 在并发量比较大的情况下,我们可以通过增加热点更新记录的条数来减少锁的竞争。例如我们可以维护十条B账户余额的记录,在更新的时候随机取其中的某一条去更新,之后将所有的余额数据进行累加。这样的设计也是可以直接减少锁的竞争提高并发量。但是这样的设计需要考虑逆向操作,也就是如果发生退款操作导致其中的某条记录变成了0,代码需要特殊处理。

并发量如何提高:尽量将一个事务中可能发生锁竞争的语句往后靠,前面的事务过程中增加商家的余额,这种热点更新其实可以放在后面,减少占有锁之后的锁持有时间。

死锁检测:这里需要注意的是死锁检测是当前线程在发现锁阻塞之后就回去扫描别的线程,所以在并发量非常高的时候这个检测影响的性能也就更加明显。通常在秒杀的场景下,单个热点更新会导致不停的进行死锁检测,极大的降低了性能。

总结

本篇主要分析了mysql是如何进行加锁的处理的。mysql在RR隔离级别及以下对快照读是比较友好的,不会加锁,这样也极大地提高了数据库处理并发的能力。但是针对快照读RC隔离级别下还是不能防止幻读,也不能重复读。值得注意的是,mysql在RR,RC的隔离级别下都是有对当前读防止幻读的,通过加排他锁和间隙锁的共同使用。针对RR隔离级别的快照读,通过加版本号达到可重复读和防止幻读的目的。通过总结希望从sql语句,结合隔离级别以及索引判断出在执行过程中可能发生的加锁和锁竞争。