MySQL锁 Posted 2023-04-03 郝少康
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了MySQL锁相关的知识,希望对你有一定的参考价值。
锁
文章目录
这篇文章来聊聊mysql 中的各种锁,以及这些锁的使用场景
全局锁
当开启全局锁之后,整个数据库会进入只读状态,这时候如果有其他线程(包括当前线程)执行以下操作,都会被阻塞。
执行insert,update,delete等修改数据的操作 执行alter table ,drop table等修改表结构的操作
开启全局锁命令:flush tables with read lock
关闭全局锁命令:unlock tables
全局锁的应用场景:全局锁主要应用于全库数据备份。
假如全库数据备份前不加全局锁,会出现什么情况呢?
使用全局锁的问题:
但是全局锁也有一个大问题,就是如果数据库的数据量比较大,就会造成备份的时间比较长,那也就会造成业务的停滞时间比较长。因为全局锁期间只能读数据,不能写数据。
对于InnoDB存储引擎有一个比较好的解决办法,就是在可重复读隔离级别下,开启事务,进行全库数据备份。开启事务会创建read view,那么整个事务执行期间,也就是数据备份期间,看到的数据和事务启动时看到的数据是一致的。这样并不影响其他线程写数据。但是对于MyISAM存储引擎,它不支持事务,所以就没办法用这种方式数据备份
表级锁
MySQL中表级别的锁有:表锁,元数据锁(MDL),意向锁,AUTO-INC锁
表锁
对student表加表锁的命令:
lock tables student read ;
lock tables student write ;
释放当前会话的所有表锁:
unlock tables ;
使用命令会释放当前会话的所有表锁,此外如果会话退出,也会释放所有表锁。
当对student加上共享表锁后,当前线程以及其他线程都只能读,不能写,当前线程尝试写会直接报错,其他线程尝试写会阻塞,等当前线程释放锁之后,才能写成功。
当对student加上独占表锁后,当前线程可以读,也可以写,其他线程不能读,不能写。其他线程尝试读或者写都会阻塞,直到当前线程释放独占表锁
元数据锁(MDL)
MDL不需要显示使用,因为当对表进行操作的时候会自动加MDL锁
对一张表进行CRUD操作,加的是MDL读锁,执行完CRUD自动释放锁 对一张表做结构变更操作的时候,加的是MDL写锁,执行完语句自动释放锁
当前线程如果在执行CRUD操作(加MDL读锁)期间,如果其他线程尝试更改表结构(申请MDL写锁)会被阻塞,直到执行完CRUD操作(释放MDL读锁),如果尝试执行CRUD操作,则不会被阻塞,读-读不冲突。
当前线程如果在执行更改表结构操作(加MDL写锁)期间,如果其他线程尝试执行CRUD操作(申请加MDL读锁)则会被阻塞,直到当前线程执行完更改表结构操作。
如果在事务中执行CRUD,或者做更改表结构操作,那MDL的释放是在事务提交后自动释放的。
接下来看一个问题:
线程A开启了事务(但是一直不提交),然后执行select语句,此时就会对表加上MDL读锁 线程B尝试更改表结构,会发生读-写冲突,会被阻塞 线程C尝试select,但是会被阻塞
当线程B被阻塞后,后续如果有其他线程尝试执行CRUD都会被阻塞,按理来说,当前表加的是MDL读锁,如果有其他线程尝试读不应该会阻塞,因为读-读不冲突,但是实际情况是会阻塞,原因是:
事情MDL锁的操作会形成一个优先级队列,在这个队列中,写锁优先级的获取高于读锁,所以必须得等写锁被获取后,然后释放了写锁,线程才能获取读锁。所以,由于线程A中的事务一直不提交,导致线程B写锁一直获取不到,进而导致其他线程的CRUD被阻塞。
所以在变更表结构的时候,可以提前看看数据库中的长事务(长时间未提交的事务)是否对表加了MDL读锁
意向锁
在InnoDB引擎中,在对表中记录加锁(也就是加行级锁)之前,要先对表加意向锁:
在对表中记录加共享锁之前,要对表先加上意向共享锁 在对表中记录加独占锁之前,要对表先加上意向独占锁
意向共享锁和意向独占锁是表级锁,不会和行级的独占锁和共享锁发生冲突的。
AUTO-INC锁
表中的主键通常会设置成自增的,那如何保证在多线程环境下,主键自增不会发生线程安全问题呢?
如果两个线程同时插入一条语句,同时判断当前自增主键的最大值是5,然后就会插入两条主键是6的记录,这就发生了线程安全问题,为了解决这种问题,就需要用到AUTO-INC锁。
AUTO-INC锁是一种特殊的表锁机制,在事务中用到时,会等插入语句执行完就立即释放锁,而不是等事务提交后才释放锁。
当一个事务持有AUTO-INC锁时,其他事务如果想要插入语句,就会被阻塞,因此解决了上面提到的线程安全问题。
行级锁
InnoDB引擎是支持行级锁的,而MyISAM不支持行级锁
共享锁(S锁)满足读读共享,读写互斥。独占锁满足读写互斥,写写互斥。
Record Lock
Record Lock称为记录锁,锁住的是一条记录。记录锁有S型记录锁和X型记录锁之分。
当一个事务对一条记录加了S型记录锁,其他事务也可以继续对该纪录加 S 型记录锁,因为读读不互斥。但是不能对该记录加X型锁。因为读写互斥
当一个事务对一条记录加了X型记录锁,其他事务不可以继续对该纪录加 S 型记录锁,因为读写互斥。也不能对该记录加X型锁。因为写写互斥
Gap Lock
Gap Lock称为间隙锁,只存在于可重复读隔离级别下,目的是解决可重复读隔离级别下的幻读现象。 如果表中有一个id范围是(3,5)的间隙锁,那么其他事务就没法插入id=4的这条记录了,这样就解决了幻读。
间隙锁存在X型间隙锁和S型间隙锁,但是间隙锁之间是兼容的,并不会存在互斥关系,两个事务可以同时持有包含共同间隙范围的间隙锁。
如果一个事务A加了(15,20]范围的X型的next-key lock,则事务B也是可以在(15,20)的范围内加X型的间隙锁的(因为相当于在(15,20)范围内加了两个间隙锁,间隙锁是可兼容的),但是事务B不能加(15,20]范围内的X型的next-key lock,X型的临键锁和X型的临键锁是不兼容的。
Next-Key Lock
Next-Key Lock称为临键锁,是Record Lock+Gap Lock的组合,锁定一个范围并且锁定记录本身。如果表中有一个范围ID为(3,5]的临键锁,那么其他事务既不能在id(3,5)的范围内插入记录,也不能修改id为5的这条记录。
Next-Key Lock可以看成记录锁和间隙锁的组合,比如上面的(3,5]之间的临键锁,在(3,4)之间可以看成间隙锁(在这个范围内插入记录会被阻塞),对于id==5这条记录可以看成加了记录锁(既不能修改也不能删除)
插入意向锁
在可重复度隔离级别下,一个事务要插入一条记录时,需要先判断该位置是否已经被其他事务加了间隙锁(next-key lock也包含间隙锁),如果有的话,则插入会被阻塞。除了会被阻塞之外,该事务还会生成一个插入意向锁,表明想在此区间插入记录。
假如事务A已经对范围id为(3,5)之间加了间隙锁,那当事务B要插入id为4的记录时,就会被阻塞,而且事务B还会生成一个插入意向锁,然后将锁的状态设置为等待状态(MySQL加锁时是先生成锁结构,然后设置锁的状态,如果锁的状态是等待状态,表明该事务并没有获取到锁,而是等待获取锁,只要当锁的状态是正常状态,才表明成功获取到锁)。
间隙锁往往锁住的是一个区间,而插入意向锁锁住的是一个点。
行级锁是怎么加的
行级锁是怎么加的,这是一个很大的话题,因为行级锁是怎么加的和隔离级别有关,和索引有关,和等值查询还是范围查询也有关。不同的隔离级别下支持的锁不同,加锁的方式也不同
在读已提交隔离级别下,行级锁仅有记录锁
而在可重复读隔离级别下,行级锁有记录锁,间隙锁,还有临键锁。
X型锁和X型锁不兼容,X型锁和S型锁不兼容(不兼容就是不能同时加S型锁和X型锁),S型锁和S型锁兼容(兼容就是可以同时加两个S型锁)
两个锁定读语句:select for update(对读取的记录加X型行级锁),select lock in share mode(对读取的记录加S型行级锁)
在可重复读隔离级别下,insert,update,delete,以及两个锁定读语句都会加行级锁 。但是普通的select语句不会加锁,因为使用MVCC解决了读-写冲突。在加行级锁的情况下加锁的对象是索引,加锁的基本单位是next-key lock,也就是正常情况下,每次加行级锁加的都是next-key lock,但是有些情况下next-key lock会退化成记录锁或者间隙锁。那具体什么情况下会发生退化,总结一句话就是:如果使用记录锁或者间隙锁就足以解决幻读,那么就会退化。
接下来的讨论都是基于MySQL的InnoDB引擎,可重复读隔离级别下,MySQL的版本是8.0
首先创建一张测试用表并插入几条记录:id(主键),age(加了非唯一索引),name(没有加索引)
开头就提到了,加了什么行级锁,和有没有用到索引 有关,和用到什么类型索引有关,和等值查询还是范围查询有关,和要查询的记录是否存在也有关,所以接下来分情况讨论:
唯一索引等值查询
在这种情况下,要查询的记录是否存在,加锁的规则是不同的:
既然,记录是否存在加锁规则有差别,那接下来分开讨论:
⭕️查询的记录是存在的:
首先在事务A中查询id为1的这条记录,读取方式是X型锁定读:
那么,当在事务A中执行该查询语句后,会对id为1的这条记录加上X型的记录锁,后续如果有其他事务对这条记录执行update,delete等操作都会阻塞(因为X型锁和X型锁不兼容,也就是不能同时加两个锁)
除了update,delete涉及到id=1的这条记录会阻塞外,如果锁定读涉及到id=1的这条记录同样也会阻塞 如果执行锁定读并且读取的范围中包含了id为1的这条记录也会阻塞(因为X型锁和S,X型锁都不兼容),如果执行锁定锁但是读取的范围不包含id为1的这条记录则不会阻塞。
使用select * from performance_schema.data_locks\\G这条sql语句可以查看什么表中加了什么锁
下面,看一下事务A加了什么锁:
从结果中可以看到共加了两个锁:一个是表级别的X类型的意向锁,一个是行级别的X类型的记录锁
LOCK_TYPE: TABLE代表的是表级锁,RECORD代表的是行级锁
对于行级锁:
LOCK_MODE字段可以分辨出是记录锁,间隙锁,还是临键锁:
如果LOCK_MODE为:X :说明是X型的next-key lock 如果LOCK_MODE为:X,REC_NOT_GAP : 说明是X型的记录锁 如果LOCK_MODE为:X,GAP :说明是X型的间隙锁
🎃唯一索引等值查询,如果查询的记录存在且使用的是for update类型的锁定读,则会给该记录加上X型的记录锁,则其他事务对该记录的更新(会加X型记录锁),删除(会加X型记录锁),以及锁定读(加X类型记录锁或者S型记录锁)都会阻塞。对于记录锁来说,X和X不兼容,X和S不兼容,仅有S和S兼容。
为什么唯一索引等值查询且查询的记录存在的情况下,next-key lock会退化成记录锁?
原因是在这种场景下,仅凭记录锁就能解决幻读问题: 因为id是主键,所以无法再插入一条id=1的记录,而且对id为1的记录加了间隙锁,其他事务无法对该记录更新或者删除,主键+间隙锁,就足以解决幻读,所以next-key lock退化成记录锁
⭕️查询的记录不存在:
SQL :select * from user where id=2 for update;
查看一下加的锁:
加了两个锁:
那这个间隙锁的范围是什么呢?而且上面提到了锁是加到索引上的,而id==2的这条记录根本就不存在,那怎么加到索引上的呢?
上面我们也提到了会找到第一条大于id==2的记录,也就是找到id == 5的这条记录,给这条记录的索引上加一个间隙锁。
一般而言,如果是间隙锁或者临键锁,LOCK_DATA就代表的是锁的范围的右边界。左边界是id为5的记录的上一条记录的id值,也就是1。因此间隙锁的法范围是(1,5),间隙锁加在了id为5这条记录的索引上。
那如果插入的记录的id超过表中最大id了呢,比如插入id为50的这条记录,那间隙锁该加到哪条记录的索引上呢?
做个测验:select * from user where id=50 for update;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wfXuxR5h-1679531801784)(C:\\Users\\86178\\AppData\\Roaming\\Typora\\typora-user-images\\image-20221226161640127.png)]
当间隙锁的范围id是(1,5)时,其他事务都不能在id为(1,5)之间插入记录了,如果尝试插入不仅会阻塞,还会生成一个插入意向锁:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5zFe5JRC-1679531801784)(C:\\Users\\86178\\AppData\\Roaming\\Typora\\typora-user-images\\image-20221226162700650.png)]
当其他事务尝试在(1,5)的范围内插入记录时,从LOCK_MODE中可以看出,生成的是X类型的间隙锁和插入意向锁,而且锁的状态是等待状态,说明该锁还没有获取到。所以如果事务A先加了一个间隙锁,然后事务B如果要在这个范围内插入记录则需要生成一个插入意向锁,不过并没有真的生成,因为锁的状态是等待转态。
间隙锁只会阻塞其他事务的插入,但不会阻塞其他事务的查找,删除,更新。、
为啥记录不存在的情况下,next-key lock会退化成间隙锁,然后加在大于不存在记录的第一条记录的索引上?
因为仅有一个间隙锁就足以解决幻读问题,首先:查询的记录不存在,是没法加记录锁的,其次,如果要加next-key lock在id==5的这条记录上,锁的范围是(1,5],则id=5的这条记录是没法被其他事务修改了,而我们的查询语句的where条件是id == 2,那么id == 5的这条记录是否存在并不会影响id ==2查询结果集,我们不能多此一举给id == 5的这条记录锁上,所以最合适的是间隙锁。
🐢总结:唯一索引等值查询加锁分记录存在和记录不存在两种情况,对于for update类型的锁定读来说,记录存在加X类型的记录锁,记录不存在加X类型的间隙锁(加到第一个大于不存在记录的索引上)
唯一索引范围查询
范围查询的规则和等值查询时不太一样的,而且分的情况比较多,接下来我们一个情况一个情况来讨论
1️⃣针对大于的范围查询:
SQL语句:select * from user where id>15 for update;
从查询结果中,可以看到加了三个锁:
一个是X类型的表锁 一个是范围是(15,20]的next-key lock 一个是范围是 (20, +∞]的next-key lock
为啥会有个范围是(20, +∞]的next-key lock呢?
我们看到的表中最后一个记录是id==20的记录,但是实际上在InnoDB引擎中,会用一个特殊的记录作为最后一条记录(并不是id == 20的这条记录),该记录的名字叫做supermum pseudo-record(上确界伪记录),所以当LOCK_DATA为supermum pseudo-record时,说明该锁的上边界是+∞,而下边界是表中真实的最后一条记录
id范围是(15,20]的next-key lock保证了(15,20)范围内不能插入记录相当于范围在(15,20)的间隙锁,而且id==20的这条记录不可以更新或者删除。相当于对id == 20的这条记录加了一个记录锁。 然后 id范围是(20, +∞]的next-key lock保证了在(20, +∞]这个范围内不能插入任何记录。
通过上面的两个next-key lock,当保证查询条件为id>15时,既可以保证可重复读,还可以保证幻读。
那如果查询条件是id>7时,会加什么锁呢?
我们想象一下,如果要保证可重复读,还要保证不发生幻读,就需要在id=10的记录,和id=15的记录,以及id=20的记录,以及supermum pseudo-record记录的索引上各自加一个next-key lock。
查询结果:只截取了行级锁。
也就是范围是(5,10]和(10,15]以及(15,20]以及(20, +∞]的next-key lock,其实可以理解为在(5,+∞]的范围区间内不能插入记录,不能修改记录,不能删除记录。这样既解决了不可重复读的问题,还可以解决幻读的问题。
2️⃣针对大于等于的范围查询:
SQL:select * from user where id>=15 for update;
根据上面的范围查询的经验,我们来思考一下查询条件是id>=15的条件下如何保证可重复读和保证不幻读。在id>=15的范围内,不能再插入记录,不能修改或者删除已有的记录。
首先对于id=15的记录应该加上记录锁,保证id ==15的记录不可以被更新或者删除,然后在id>15的范围内,应该给id=20的记录加上一个临键锁,范围是(15,20],可以保证在(15,20)的区间内不被插入记录,还可以保证id == 20的这条记录不被更新或者删除,然后再来一个id范围是(20, +∞]的next-key lock,保证不能插入id>20的记录。
查询结果:(只截取了行级锁)
id>=15和id>15的区别就在于多了一个id==15的记录锁,其他都一样。
4️⃣针对小于的范围查询:
针对小于的范围查询还需要分查询条件中的记录是否在表中:
首先来看查询条件中的记录不在表中:
SQL:select * from user where id<6 (id为6的记录不在表中)
首先我们来预期一下应该会加什么锁,才能在id<6的范围内保证可重复读和保证不出现幻读 。
首先, (-∞, 1] 的范围内应该加上一个next-key lock,保证id<1的范围内不可以插入记录,id==1的这条记录不能被更新或者修改。然后(1,5]的范围内应该加一个next-key lock,保证在(1,5)的范围内不可插入且id == 5的记录不能被修改或者删除。但是id == 5并没有达到终止扫描的条件,因为id为5还是小于6的,所以接着往后扫描,应该在id=10的记录的索引上加一个间隙锁,保证在(5.10)的范围内不可插入元素,这个锁用间隙锁就足够了,没有必要用next-key lock,因为id == 10的记录是否更改,是否删除都不会影响到id<6的查询条件的查询结果
查看加锁结果:(只截取了行级锁)
从查询结果中可以看到加的锁为:(-∞, 1] 范围的next-key lock,(1,5]范围的next-key lock,(5,10)范围的间隙锁。和预想的结果是一样的。
查询条件中的记录在表中:
SQL:select * from user where id<5 for update;
预期:在id<5的范围内,要保证可重复读还要保证不发生幻读,则我们预期要加的锁为:
(-∞, 1] 范围的next-key lock,(1,5)范围的间隙锁。没有必要给id == 5这条记录加一个记录锁,因为查询的条件是id<5, 即使id == 5这条记录发生了更新或者删除,也不会影响到查询结果。到这里我们就大致明白了,什么时候用哪种行级锁。假如必须得用next-key lock才够用就得用next-key lock,如果仅用记录锁或者是间隙锁就能够保证不发生不可重复读或者幻读现象,那就用没必要用next-key lock,加的锁能保证查询语句读取范围内的记录不发增删或者修改就OK了,范围外的记录没必要保证。
查询结果:(只截取了行级锁)
4️⃣针对小于等于的范围查询:
查询条件中的记录存在于表中:
SQL: select * from user where id<=5 for update;
查询结果:(只截取了行级锁)
加了两个锁:
(-∞, 1]的next-key lock (1,5]的next-key lock
对于查询条件中的记录不存在于表中:
SQL:select * from user where id<=6 for update;
分析:
首先(-∞, 1]的next-key lock和(1,5]的next-key lock肯定得有,然后因为5还是没有越过6这个边界,所以还需要向后扫描,最终应该给id == 10的这条记录的索引加一个间隙锁,范围是(5,10)
查询结果:(只截取了行级锁)
🐢总结:从范围查询中,我们了解到对于范围边界,能使用间隙锁或者记录锁来解决不可重复读或者幻读问题,就使用间隙锁或者记录锁,如果使用者两种锁不够,再考虑使用临键锁。加锁得加够,但是不能多加。
非唯一索引等值查询
当使用非唯一索引进行等值查询时,会对主键索引和二级索引都加锁,但是对主键索引进行加锁的时候,只有满足查询条件的记录,才会对他们的主键索引加锁。
1️⃣记录不存在的情况:
SQL:select * from user where age=25 for update;
在二级索引树上查找age=25的记录,但是对应的记录不存在,则会定位到第一条age>25的记录,然后在该记录的二级索引上加范围是(22,39)的间隙锁。这意味着其他事务尝试插入age为23,24,25……38的记录时会被阻塞,但是能否插入age为22以及age为39的记录还需另当别论。
查询结果:(只截取了行级锁)
LOCK_DATA中有两个数值:39和20,39代表的是age,代表的是间隙锁的右边界,20是age=39这条记录对应的id值。 在age为39的记录的二级索引上加了间隙锁范围是(20,39)
正如上面这种情况,那什么时候插入age为22或者39会被阻塞,什么时候会成功插入呢?我们需要清楚,插入一条语句时,啥情况会阻塞?
当插入一条记录时,需要先定位该记录在B+树上的位置,如果该记录后面的记录的索引上有间隙锁,此时插入才会被阻塞。此外,还需要一个前置知识:二级索引树中,先按二级索引age排序,在age相同的情况下,再按主键id排序。
🔑插入age=22的记录成功和失败的情况:
成功:当其他事务插入一条age=22,id=3的记录的时候,在二级索引树上定位到插入位置,则该插入位置的下一条是age=22,id=10的记录,该记录的索引上并没有加间隙锁,所以可以插入成功。 失败:当其他事务插入一条age=22,id=12的记录时,会先在二级索引树上定位插入位置,该插入位置的下一条记录是age=39,id=20的记录,该记录是二级索引上有间隙锁,所以插入会被阻塞
🔑插入age=39的记录成功和失败的情况:
成功:当其他事务插入一条age=39,id=21的记录时,在二级索引树上定位到插入位置,插入位置是表中最后一条记录,下一条记录不存在,所以插入成功。 失败:当其他事务插入一条age=39,id=3的记录时,在二级索引树上确定插入位置,发现插入位置的下一条记录是age=39,id=20的记录,该记录的二级索引上加了间隙锁,所以插入会被阻塞。
🐢当一个事务持有二级索引(非唯一索引)的间隙锁,age范围是(22,39)时,其他事务尝试插入age为23,24,…38的记录时都会被阻塞,但是插入age为22或者39的记录时,有可能会被阻塞,有可能会成功插入,是成功插入还是阻塞取决于待插入记录在二级索引树中的位置,该位置后面那一条记录是否加了间隙锁,如果加了间隙锁则会阻塞,如果没有间隙锁则不会阻塞。
而确定待插入记录在二级索引树中的位置,不仅要根据二级索引age判断,还需要根据主键id来判断,整体上是按age排序,但是当age相等时,需要按id排序,
2️⃣记录存在的情况:
SQL:select * from user where age=22 for update;
查询结果:(只截取了行级锁)
在查询的记录存在的情况下,唯一索引等值查询时加的行级锁仅有一个记录锁,但是在非唯一索引查询时加的行级锁有三个:
首先对age=22的这条记录,在其二级索引上加了age范围是(21,22]的next-key lock,然后因为(age=22,id=10,name=山治)的这条记录符合查询条件,所以还要在对该记录的主键索引上加上记录锁。然后在二级索引树上加上age范围是(22,39)的间隙锁。
对主键索引(id=10的记录的索引)加了X型的记录锁,其他事务修改或者删除(id=10,age=22)的这条记录都会被阻塞。
有了上面这一个X型的记录锁之后,该记录就不能被修改或者删除了,那为啥还要有一个next-key lock和一个间隙锁呢,这其实是为了解决幻读问题,如果没有这两个锁,那插入一条(age=22,id=18)的记录是绝对可以插入成功的,然后再查询age=22的记录,就会查询出两条age=22的记录,这就出现了幻读问题。所以为了解决幻读问题,二级索引上的这两个锁是必要的。
当有了这两个锁之后,凡是age=22的记录是一定无法插入的,如果待插入记录age=22,id<10,则该待插入记录在二级索引B+树中的下一条记录是加了next-key lock的,会发送阻塞。如果待插入记录是age=22,id=10,无法插入,因为id是主键,具有唯一性。如果待插入记录age=22,id>10,则该待插入记录的下一条记录在二级索引的B+树中的下一条记录是age=39的记录,该记录加了间隙锁,所以插入会被阻塞。所以经分析,有了一个next-key lock,和一个间隙锁,凡是age=22的记录插入一定不会成功。这就解决了幻读。此处非唯一索引和唯一索引处的等值查询的区别是:唯一索引处,有主键限制,不能插入id一样的记录,但是非唯一索引处没有这个限制,本来是可以插入age一样的记录的,但是考虑到幻读问题,使用了两个间隙锁来让age一样的记录的插入受到阻塞。
有了这两个间隙锁,(21,39)之间是无法插入记录的,那age=21的记录,和age=39的记录能否插入成功呢?分析原理为:如果待插入记录的下一条记录有间隙锁或者next-key lock,则插入会阻塞,否则插入成功。
当插入age=21的记录时,还要看该记录的id值,如果该记录的id<5,则该待插入记录在二级索引树中的下一条记录是索隆,不会发生阻塞,如果该记录的id>5,则该待插入记录在二级索引树中的下一条记录是山治,山治这条记录的索引上加了next-key lock,所以会发生阻塞。
当插入age=38的记录时,也同样要看该记录的id值,如果该记录的id>20,则该待插入记录在二级索引树中是最后一条记录,不会发生阻塞,如果该记录的id<20,则该待插入记录在二级索引树中的下一条记录是香克斯,香克斯这条记录的索引上加了间隙锁,所以会发生阻塞。
其实age=21或者age=38的记录能否插入并不会影响到查询条件是age=22的查询结果,所以能否插入并不重要,重要的是保证age=22的记录不能被插入。
非唯一索引范围查询
非唯一索引的范围查询和唯一索引的范围查询的加锁情况还有不同,接下来通过一个案例来分析一下非唯一索引的范围查询的加锁情况:
二级索引树上加的行级锁:三个锁都是next-key lock
主键索引树上加的行级锁:
对id=10和id=20的记录的索引加了记录锁 因为符合查询条件的记录有这两个记录,所以这两个记录在主键索引上加了记录锁
这五个锁的图示:
三个next-key lock:范围是(21,22],(22,39],(39,+∞],这三个锁保证不会出现幻读现象:第一次查询结果中有两条记录是符合的,即age=22和age=39的记录。(21,22],(22,39]这两个范围确保了其他事务插入age=22的记录会被阻塞,(22,39],(39,+∞]这两个范围确保了其他事务插入age=39的记录会被阻塞。所以这三个next-key lock避免了幻读现象的发生。
两个主键索引上的X型记录锁,保证了这两条记录不会被更新或者删除。
📛总结:在非唯一索引查询过程中,对于符合查询条件的记录,在主键索引树中要对该记录的索引加上记录锁,保证记录不会被修改或删除,另外因为是非唯一索引,于是索引字段可以出现重复,但是为了保证不出现幻读,通常还需要间隙锁或者next-key lock,来保证一个或者多个区间内无法插入记录,以此来避免幻读。
不加索引的查询
前面的查询都用到了索引,先使用索引进行查询记录,再对查询到的记录加锁。
如果对于锁定读语句,update,delete如果条件中没有用到索引,则会走全表扫描,结果就是每一条记录的索引都会加上next-key lock。
SQL:select * from user where name=‘路飞’ for update;
可以看到对未使用索引进行查询,会对所有记录都加上next-key lock,想当与锁了全表,所以并发性能会大打折扣,要尽量避免这种情况。
上面演示的锁定读语句,查询时不使用索引,而是进行全表扫描,会锁全表,update和delete也是一样,如果没有用到索引,而是进行全表扫描,会锁全表。
死锁
在MySQL的并发事务中,为了解决读-写冲突,引入了行级锁,表级锁,这些锁使用不当就有可能发送死锁,下面通过一个死锁的例子来演示什么是死锁:
t_order 表:id(自增主键),order_no(非唯一索引),create_date(普通字段)
然后两个事务执行以下sql:
在可重复度隔离级别下,此时两个事务的插入都会阻塞,都在等待对方释放锁,发生了死锁。
接下来我们,我们分析一下加了什么锁导致的死锁:
1️⃣事务A先检查1007订单是否存在加的锁:(只截取了行级锁):
加的是(1006, +∞]的next-key lock。
上面学习非唯一索引等值查询的时候,如果记录不存在,加的是间隙锁,但是这里为啥是next-key lock呢?
这里有两种情况:
如果表中最后一条记录是1006,那查询1007就会加next-key lock 如果表中最后一条记录是1010,那查询1007就会加间隙锁
🐢也就是,如果查询的记录比最后一条记录还大,那加的是next-key lock,如果查询的记录没有最后一条记录大,那加的是间隙锁。
2️⃣事务B检查1008订单是否存在加的锁:(只截取了行级锁):
两个next-key lock,范围都是(1006, +∞],说明事务B和事务A加的锁一样,都是(1006, +∞]的next-key lock。
3️⃣事务A和事务B都要在(1006, +∞]范围内插入记录:
事务A和事务B都持有(1006, +∞]的next-key lock,所以当事务A要插入1007的记录时,会和事务B的next-key lock发生冲突,会被阻塞,当事务B要插入1008的记录时,会和事务A的next-key lock发生冲突,会被阻塞。此时两个事务都在等待对方释放锁,就僵持住了,也就是形成了死锁。
事务A插入1007会因为事务B持有(1006, +∞]范围的next-key lock而阻塞,但是不会因为事务A本身持有(1006, +∞]范围的next-key lock而阻塞。如果只是事务A持有(1006, +∞]范围的next-key lock,事务B并没有持有(1006, +∞]范围的next-key lock,那事务A是可以插入1007的。
为啥间隙锁与间隙锁是可以兼容的?
也就是为啥两个事务可以同时持有同一个范围的间隙锁?
间隙锁的意义只在于阻止区间被插入,一个事务获取到了间隙锁不会阻止另一个事务获取同一范围的间隙锁,而且间隙锁虽然有X型和S型之分,但是功能上并没有差别,两个X型的间隙锁也是可以兼容的。
但是对于next-key lock来说,如果范围相同的两个X型的next-key lock,是不可以兼容的。比如两个X型的范围是(5,15]的next-key lock是不兼容的。
有个例外是,两个X型的范围是(1006, +∞]的next-key lock是兼容的
insert加行级锁
insert语句会加什么行级锁?
insert语句正常执行,没有遇到冲突时是不会行级锁加锁的,是不会生成锁结构的,它是靠聚簇索引记录自带的trx_id字段作为隐式锁来保护记录的,但是某些情况下隐式锁会转换为显示锁。
当记录之前有间隙锁,insert在这个间隙锁范围内插入记录时会生成插入意向锁 如果insert的记录和已经存在的记录之间存在唯一性冲突,则会给已经存在的记录加锁
记录之间有间隙锁
每次进行insert的时候,都需要看一下待插入记录的下一条记录上是否加了间隙锁,如果加了间隙锁,那此时insert是会被阻塞的。而且此时会生成一个插入意向锁,然后将锁的状态设置为等待状态。(MySQL加锁时是先生成锁结构,再设置锁状态,如果锁的状态是等待状态,表明该事务并没有真正获取到锁。只有锁的状态是正常状态时,才表明该事务获取到锁了)
表中记录:
先在事务A中执行:select * from t_order where order_no=1008 for update;
我们知道会生成(1006,1010)的间隙锁:
然后在事务B中插入1008的记录:insert into t_order values(8,1008,now());
这时候就因为插入的记录在间隙锁的范围内,就发生了冲突,就会生成插入意向锁:
可以看到锁的状态是WATING,表明并没有成功获取到锁。
插入意向锁和间隙锁是互斥关系,是不兼容的。当事务A持有某一范围的间隙锁时,事务B再获取这个范围的插入意向锁就会阻塞。
唯一键冲突
当插入记录时,如果插入的记录与表中已有记录的唯一字段的值相同的记录,则会对表中记录加上S型锁。
如果主键索引重复,则会给表中已存在的记录加S型的记录锁
如果唯一二级索引重复,则会给表中已存在的记录加S型的next-key lock
主键索引重复:
表中记录:(id是主键)
如果再插入一条id为1的记录,则会发生主键冲突: insert into t_order values(1,1001,now());
这条记录是不能成功插入的,而且会给表中已有的id为1的记录加S型的记录锁:
🐢所以在插入记录时,如果发生主键索引重复这种冲突,则会给表中已有的记录加上S型的记录锁
唯一二级索引重复:
还是上面这张表,给其中的order_no字段添加了唯一二级索引,然后执行:
insert into t_order values(7,1002,now());
因为1002在表中已经存在,所以会报错,而且还会给表中已有记录加S型的next-key lock:
Mysql加锁过程详解-基本知识
Mysql加锁过程详解(9)-innodb下的记录锁,间隙锁,next-key锁
1、背景
MySQL/InnoDB的加锁分析,一直是一个比较困难的话题。我在工作过程中,经常会有同事咨询这方面的问题。同时,微博上也经常会收到MySQL锁相关的私信,让我帮助解决一些死锁的问题。本文,准备就MySQL/InnoDB的加锁问题,展开较为深入的分析与讨论,主要是介绍一种思路,运用此思路,拿到任何一条SQL语句,都能完整的分析出这条语句会加什么锁?会有什么样的使用风险?甚至是分析线上的一个死锁场景,了解死锁产生的原因。
注: MySQL是一个支持插件式存储引擎的数据库系统。本文下面的所有介绍,都是基于InnoDB存储引擎,其他引擎的表现,会有较大的区别。
2、多版本并发控制MVCC:Snapshot Read vs Current Read
MySQL InnoDB存储引擎,实现的是基于多版本的并发控制协议——MVCC (Multi-Version Concurrency Control ) (注:与MVCC相对的,是基于锁的并发控制,Lock-Based Concurrency Control)。MVCC最大的好处,相信也是耳熟能详:读不加锁,读写不冲突 。在读多写少的OLTP应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能,这也是为什么现阶段,几乎所有的RDBMS,都支持了MVCC。
在MVCC并发控制中,读操作可以分成两类:快照读 (snapshot read)与当前读 (current read) 。快照读,读取的是记录的可见版本 (有可能是历史版本),不用加锁。当前读,读取的是记录的最新版本,并且,当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。
在一个支持MVCC并发控制的系统中,哪些读操作是快照读?哪些操作又是当前读呢?以MySQL InnoDB为例:
快照读: 简单的select操作,属于快照读,不加锁。(当然,也有例外,下面会分析)
select * from table where ?;
当前读: 特殊的读操作,插入/更新/删除操作,属于当前读,需要加锁。
select * from table where ? lock in share mode;
select * from table where ? for update;
insert into table values (…);
update table set ? where ?;
delete from table where ?;
所有以上的语句,都属于当前读,读取记录的最新版本。并且,读取之后,还需要保证其他并发事务不能修改当前记录,对读取记录加锁。其中,除了第一条语句,对读取记录加S锁 (共享锁)外 ,其他的操作,都加的是X锁 (排它锁)。
为什么将 插入/更新/删除 操作,都归为当前读?可以看看下面这个 更新 操作,在数据库中的执行流程:
从图中,可以看到,一个Update操作的具体流程。当Update SQL被发给MySQL后,MySQL Server会根据where条件,读取第一条满足条件的记录,然后InnoDB引擎会将第一条记录返回,并加锁 (current read)。待MySQL Server收到这条加锁的记录之后,会再发起一个Update请求,更新这条记录。一条记录操作完成,再读取下一条记录,直至没有满足条件的记录为止。因此,Update操作内部,就包含了一个当前读。同理,Delete操作也一样。Insert操作会稍微有些不同,简单来说,就是Insert操作可能会触发Unique Key的冲突检查,也会进行一个当前读。
注 :根据上图的交互,针对一条当前读的SQL语句,InnoDB与MySQL Server的交互,是一条一条进行的,因此,加锁也是一条一条进行的。先对一条满足条件的记录加锁,返回给MySQL Server,做一些DML操作;然后在读取下一条加锁,直至读取完毕。
3、Cluster Index:聚簇索引
InnoDB存储引擎的数据组织方式,是聚簇索引表:完整的记录,存储在主键索引中,通过主键索引,就可以获取记录所有的列。关于聚簇索引表的组织方式,可以参考MySQL的官方文档:Clustered and Secondary Indexes 。本文假设读者对这个,已经有了一定的认识,就不再做具体的介绍。接下来的部分,主键索引/聚簇索引 两个名称,会有一些混用,望读者知晓。
4、2PL:Two-Phase Locking
传统RDBMS加锁的一个原则,就是2PL (二阶段锁):Two-Phase Locking 。相对而言,2PL比较容易理解,说的是锁操作分为两个阶段:加锁阶段与解锁阶段,并且保证加锁阶段与解锁阶段不相交。下面,仍旧以MySQL为例,来简单看看2PL在MySQL中的实现。
从上图可以看出,2PL就是将加锁/解锁分为两个完全不相交的阶段。加锁阶段:只加锁,不放锁。解锁阶段:只放锁,不加锁。
5、事务隔离级别Isolation Level
隔离级别:Isolation Level ,也是RDBMS的一个关键特性。相信对数据库有所了解的朋友,对于4种隔离级别:Read Uncommited,Read Committed,Repeatable Read,Serializable,都有了深入的认识。本文不打算讨论数据库理论中,是如何定义这4种隔离级别的含义的,而是跟大家介绍一下MySQL/InnoDB是如何定义这4种隔离级别的。
MySQL/InnoDB定义的4种隔离级别:(mysql的级别不是ios定义的级别,iso定义的RR存在幻读)
Read Uncommited
可以读取未提交记录。此隔离级别,不会使用,忽略。
Read Committed (RC)
快照读忽略,本文不考虑。
针对当前读,RC隔离级别保证对读取到的记录加锁 (记录锁) ,存在幻读现象。
Repeatable Read (RR)
快照读忽略,本文不考虑。
针对当前读,RR隔离级别保证对读取到的记录加锁 (记录锁),同时保证对读取的范围加锁,新的满足查询条件的记录不能够插入 (间隙锁) ,不存在幻读现象。
Serializable
从MVCC并发控制退化为基于锁的并发控制。不区别快照读与当前读,所有的读操作均为当前读,读加读锁 (S锁),写加写锁 (X锁)。
Serializable隔离级别下,读写冲突,因此并发度急剧下降,在MySQL/InnoDB下不建议使用。
在MYSQL的事务引擎中,INNODB是使用范围最广的。它默认的事务隔离级别是REPEATABLE READ(可重复读),在标准的事务隔离级别定义下,REPEATABLE READ是不能防止幻读产生的。INNODB使用了next-key locks实现了防止幻读的发生。
6、加锁过程分析
1)一条简单SQL的加锁实现分析
在介绍完一些背景知识之后,本文接下来将选择几个有代表性的例子,来详细分析MySQL的加锁处理。当然,还是从最简单的例子说起。经常有朋友发给我一个SQL,然后问我,这个SQL加什么锁?就如同下面两条简单的SQL,他们加什么锁?
针对这个问题,该怎么回答?我能想象到的一个答案是:
这个答案对吗?说不上来。即可能是正确的,也有可能是错误的,已知条件不足,这个问题没有答案。如果让我来回答这个问题,我必须还要知道以下的一些前提,前提不同,我能给出的答案也就不同。要回答这个问题,还缺少哪些前提条件?
没有这些前提,直接就给定一条SQL,然后问这个SQL会加什么锁,都是很业余的表现。而当这些问题有了明确的答案之后,给定的SQL会加什么锁,也就一目了然。下面,我将这些问题的答案进行组合,然后按照从易到难的顺序,逐个分析每种组合下,对应的SQL会加哪些锁?
注: 下面的这些组合,我做了一个前提假设,也就是有索引时,执行计划一定会选择使用索引进行过滤 (索引扫描)。但实际情况会复杂很多,真正的执行计划,还是需要根据MySQL输出的为准。
组合一: id列是主键,RC隔离级别
组合二: id列是二级唯一索引,RC隔离级别
组合三: id列是二级非唯一索引,RC隔离级别
组合四: id列上没有索引,RC隔离级别
组合五: id列是主键,RR隔离级别
组合六: id列是二级唯一索引,RR隔离级别
组合七: id列是二级非唯一索引,RR隔离级别
组合八: id列上没有索引,RR隔离级别
组合九: Serializable隔离级别
排列组合还没有列举完全,但是看起来,已经很多了。真的有必要这么复杂吗?事实上,要分析加锁,就是需要这么复杂。但是从另一个角度来说,只要你选定了一种组合,SQL需要加哪些锁,其实也就确定了。接下来,就让我们来逐个分析这9种组合下的SQL加锁策略。
注:在前面八种组合下,也就是RC,RR隔离级别下,SQL1:select操作均不加锁,采用的是快照读,因此在下面的讨论中就忽略了 ,主要讨论SQL2:delete操作的加锁。
组合一:id主键+RC
这个组合,是最简单,最容易分析的组合。id是主键,Read Committed隔离级别,给定SQL:delete from t1 where id = 10; 只需要将主键上,id = 10的记录加上X锁即可。如下图所示:
结论:id是主键时,此SQL只需要在id=10这条记录上加X锁即可。
组合二:id唯一索引+RC
这个组合,id不是主键,而是一个Unique的二级索引键值。那么在RC隔离级别下,delete from t1 where id = 10; 需要加什么锁呢?见下图:
此组合中,id是unique索引,而主键是name列。此时,加锁的情况由于组合一有所不同。由于id是unique索引,因此delete语句会选择走id列的索引进行where条件的过滤,在找到id=10的记录后,首先会将unique索引上的id=10索引记录加上X锁,同时,会根据读取到的name列,回主键索引(聚簇索引),然后将聚簇索引上的name = ‘d’ 对应的主键索引项加X锁。为什么聚簇索引上的记录也要加锁?试想一下,如果并发的一个SQL,是通过主键索引来更新:update t1 set id = 100 where name = ‘d’; 此时,如果delete语句没有将主键索引上的记录加锁,那么并发的update就会感知不到delete语句的存在,违背了同一记录上的更新/删除需要串行执行的约束。
结论:若id列是unique列,其上有unique索引。那么SQL需要加两个X锁,一个对应于id unique索引上的id = 10的记录,另一把锁对应于聚簇索引上的[name=‘d‘,id=10]的记录。
组合三:id非唯一索引+RC
相对于组合一、二,组合三又发生了变化,隔离级别仍旧是RC不变,但是id列上的约束又降低了,id列不再唯一,只有一个普通的索引。假设delete from t1 where id = 10; 语句,仍旧选择id列上的索引进行过滤where条件,那么此时会持有哪些锁?同样见下图:
根据此图,可以看到,首先,id列索引上,满足id = 10查询条件的记录,均已加锁。同时,这些记录对应的主键索引上的记录也都加上了锁。与组合二唯一的区别在于,组合二最多只有一个满足等值查询的记录,而组合三会将所有满足查询条件的记录都加锁。
结论:若id列上有非唯一索引,那么对应的所有满足SQL查询条件的记录,都会被加锁。同时,这些记录在主键索引上的记录,也会被加锁。
组合四:id无索引+RC
相对于前面三个组合,这是一个比较特殊的情况。id列上没有索引,where id = 10;这个过滤条件,没法通过索引进行过滤,那么只能走全表扫描做过滤。对应于这个组合,SQL会加什么锁?或者是换句话说,全表扫描时,会加什么锁?这个答案也有很多:有人说会在表上加X锁;有人说会将聚簇索引上,选择出来的id = 10;的记录加上X锁。那么实际情况呢?请看下图:
由于id列上没有索引,因此只能走聚簇索引,进行全部扫描。从图中可以看到,满足删除条件的记录有两条,但是,聚簇索引上所有的记录,都被加上了X锁。无论记录是否满足条件,全部被加上X锁。既不是加表锁,也不是在满足条件的记录上加行锁。
有人可能会问?为什么不是只在满足条件的记录上加锁呢?这是由于MySQL的实现决定的。如果一个条件无法通过索引快速过滤,那么存储引擎层面就会将所有记录加锁后返回,然后由MySQL Server层进行过滤。因此也就把所有的记录,都锁上了。
注:在实际的实现中,MySQL有一些改进,在MySQL Server过滤条件,发现不满足后,会调用unlock_row方法,把不满足条件的记录放锁 (违背了2PL的约束)。这样做,保证了最后只会持有满足条件记录上的锁,但是每条记录的加锁操作还是不能省略的。
结论:若id列上没有索引,SQL会走聚簇索引的全扫描进行过滤,由于过滤是由MySQL Server层面进行的。因此每条记录,无论是否满足条件,都会被加上X锁。但是,为了效率考量,MySQL做了优化,对于不满足条件的记录,会在判断后放锁,最终持有的,是满足条件的记录上的锁,但是不满足条件的记录上的加锁/放锁动作不会省略。同时,优化也违背了2PL的约束。
组合五:id主键+RR
上面的四个组合,都是在Read Committed隔离级别下的加锁行为,接下来的四个组合,是在Repeatable Read隔离级别下的加锁行为。
组合五,id列是主键列,Repeatable Read隔离级别,针对delete from t1 where id = 10; 这条SQL,加锁与组合一:[id主键,Read Committed ]一致。
组合六:id唯一索引+RR
与组合五类似,组合六的加锁,与组合二:[id唯一索引,Read Committed ]一致。两个X锁,id唯一索引满足条件的记录上一个,对应的聚簇索引上的记录一个。
组合七:id非唯一索引+RR
还记得前面提到的MySQL的四种隔离级别的区别吗?RC隔离级别允许幻读,而RR隔离级别,不允许存在幻读。但是在组合五、组合六中,加锁行为又是与RC下的加锁行为完全一致。那么RR隔离级别下,
如何防止幻读呢?问题的答案,就在组合七中揭晓。
组合七,Repeatable Read隔离级别,id上有一个非唯一索引,执行delete from t1 where id = 10; 假设选择id列上的索引进行条件过滤,最后的加锁行为,是怎么样的呢?同样看下面这幅图:
此图,相对于组合三:[id列上非唯一锁,Read Committed ]看似相同,其实却有很大的区别。最大的区别在于,这幅图中多了一个GAP锁,而且GAP锁看起来也不是加在记录上的,倒像是加载两条记录之间的位置,GAP锁有何用?
其实这个多出来的GAP锁,就是RR隔离级别,相对于RC隔离级别,不会出现幻读的关键。确实,GAP锁锁住的位置,也不是记录本身,而是两条记录之间的GAP。
如何保证两次当前读返回一致的记录,那就需要在第一次当前读与第二次当前读之间,其他的事务不会插入新的满足条件的记录并提交。为了实现这个功能,GAP锁应运而生。
如图中所示,有哪些位置可以插入新的满足条件的项 (id = 10),考虑到B+树索引的有序性,满足条件的项一定是连续存放的。记录[6,c]之前,不会插入id=10的记录; [6,c]与[10,b]间可以插入[10, aa];[10,b]与[10,d]间,可以插入新的[10,bb],[10,c]等;[10,d]与[11,f]间可以插入满足条件的[10,e],[10,z]等;而[11,f]之后也不会插入满足条件的记录。因此,为了保证[6,c]与[10,b]间,[10,b]与[10,d]间,[10,d]与[11,f]不会插入新的满足条件的记录,MySQL选择了用GAP锁,将这三个GAP给锁起来。
Insert操作,如insert [10,aa],首先会定位到[6,c]与[10,b]间,然后在插入前,会检查这个GAP是否已经被锁上,如果被锁上,则Insert不能插入记录。因此,通过第一遍的当前读,不仅将满足条件的记录锁上 (X锁),与组合三类似。同时还是增加3把GAP锁,将可能插入满足条件记录的3个GAP给锁上,保证后续的Insert不能插入新的id=10的记录,也就杜绝了同一事务的第二次当前读,出现幻象的情况。
有心的朋友看到这儿,可以会问:既然防止幻读,需要靠GAP锁的保护,为什么组合五、组合六,也是RR隔离级别,却不需要加GAP锁呢?
首先,这是一个好问题。其次,回答这个问题,也很简单。GAP锁的目的,是为了防止同一事务的两次当前读,出现幻读的情况。而组合五,id是主键;组合六,id是unique键,都能够保证唯一性。一个等值查询,最多只能返回一条记录,而且新的相同取值的记录,一定不会在新插入进来,因此也就避免了GAP锁的使用。其实,针对此问题,还有一个更深入的问题:如果组合五、组合六下,针对SQL:select * from t1 where id = 10 for update; 第一次查询,没有找到满足查询条件的记录,那么GAP锁是否还能够省略?此问题留给大家思考。
结论:Repeatable Read隔离级别下,id列上有一个非唯一索引,对应SQL:delete from t1 where id = 10; 首先,通过id索引定位到第一条满足查询条件的记录,加记录上的X锁,加GAP上的GAP锁,然后加主键聚簇索引上的记录X锁,然后返回;然后读取下一条,重复进行。直至进行到第一条不满足条件的记录[11,f],此时,不需要加记录X锁,但是仍旧需要加GAP锁,最后返回结束。
什么时候会取得gap lock或nextkey lock 这和隔离级别有关,只在REPEATABLE READ或以上的隔离级别下的特定操作才会取得gap lock或nextkey lock。
组合八:id无索引+RR
组合八,Repeatable Read隔离级别下的最后一种情况,id列上没有索引。此时SQL:delete from t1 where id = 10; 没有其他的路径可以选择,只能进行全表扫描。最终的加锁情况,如下图所示:
如图,这是一个很恐怖的现象。首先,聚簇索引上的所有记录,都被加上了X锁。其次,聚簇索引每条记录间的间隙(GAP),也同时被加上了GAP锁。这个示例表,只有6条记录,一共需要6个记录锁,7个GAP锁。试想,如果表上有1000万条记录呢?
在这种情况下,这个表上,除了不加锁的快照度,其他任何加锁的并发SQL,均不能执行,不能更新,不能删除,不能插入,全表被锁死。
当然,跟组合四:[id无索引, Read Committed ]类似,这个情况下,MySQL也做了一些优化,就是所谓的semi-consistent read。semi-consistent read开启的情况下,对于不满足查询条件的记录,MySQL会提前放锁。针对上面的这个用例,就是除了记录[d,10],[g,10]之外,所有的记录锁都会被释放,同时不加GAP锁。semi-consistent read如何触发:要么是read committed隔离级别;要么是Repeatable Read隔离级别,同时设置了innodb_locks_unsafe_for_binlog 参数。更详细的关于semi-consistent read的介绍,可参考我之前的一篇博客:MySQL+InnoDB semi-consitent read原理及实现分析 。
结论:在Repeatable Read隔离级别下,如果进行全表扫描的当前读,那么会锁上表中的所有记录,同时会锁上聚簇索引内的所有GAP,杜绝所有的并发 更新/删除/插入 操作。当然,也可以通过触发semi-consistent read,来缓解加锁开销与并发影响,但是semi-consistent read本身也会带来其他问题,不建议使用。
组合九:Serializable
针对前面提到的简单的SQL,最后一个情况:Serializable隔离级别。对于SQL2:delete from t1 where id = 10; 来说,Serializable隔离级别与Repeatable Read隔离级别完全一致,因此不做介绍。
Serializable隔离级别,影响的是SQL1:select * from t1 where id = 10; 这条SQL,在RC,RR隔离级别下,都是快照读,不加锁。但是在Serializable隔离级别,SQL1会加读锁,也就是说快照读不复存在,MVCC并发控制降级为Lock-Based CC。
结论:在MySQL/InnoDB中,所谓的读不加锁,并不适用于所有的情况,而是隔离级别相关的。Serializable隔离级别,读不加锁就不再成立,所有的读操作,都是当前读。
2)一条复杂的sql语句
写到这里,其实MySQL的加锁实现也已经介绍的八八九九。只要将本文上面的分析思路,大部分的SQL,都能分析出其会加哪些锁。而这里,再来看一个稍微复杂点的SQL,用于说明MySQL加锁的另外一个逻辑。SQL用例如下:
如图中的SQL,会加什么锁?假定在Repeatable Read隔离级别下 (Read Committed隔离级别下的加锁情况,留给读者分析。),同时,假设SQL走的是idx_t1_pu索引。
在详细分析这条SQL的加锁情况前,还需要有一个知识储备,那就是一个SQL中的where条件如何拆分?具体的介绍,建议阅读我之前的一篇文章:SQL中的where条件,在数据库中提取与应用浅析 。在这里,我直接给出分析后的结果:
在分析出SQL where条件的构成之后,再来看看这条SQL的加锁情况 (RR隔离级别),如下图所示:
从图中可以看出,在Repeatable Read隔离级别下,由Index Key所确定的范围,被加上了GAP锁;Index Filter锁给定的条件 (userid = ‘hdc’)何时过滤,视MySQL的版本而定,在MySQL 5.6版本之前,不支持Index Condition Pushdown (ICP),因此Index Filter在MySQL Server层过滤,在5.6后支持了Index Condition Pushdown,则在index上过滤。若不支持ICP,不满足Index Filter的记录,也需要加上记录X锁,若支持ICP,则不满足Index Filter的记录,无需加记录X锁 (图中,用红色箭头标出的X锁,是否要加,视是否支持ICP而定);而Table Filter对应的过滤条件,则在聚簇索引中读取后,在MySQL Server层面过滤,因此聚簇索引上也需要X锁。最后,选取出了一条满足条件的记录[8,hdc,d,5,good],但是加锁的数量,要远远大于满足条件的记录数量。
结论:在Repeatable Read隔离级别下,针对一个复杂的SQL,首先需要提取其where条件。Index Key确定的范围,需要加上GAP锁;Index Filter过滤条件,视MySQL版本是否支持ICP,若支持ICP,则不满足Index Filter的记录,不加X锁,否则需要X锁;Table Filter过滤条件,无论是否满足,都需要加X锁。
7、死锁原理与分析
本文前面的部分,基本上已经涵盖了MySQL/InnoDB所有的加锁规则。深入理解MySQL如何加锁,有两个比较重要的作用:
下面,来看看两个死锁的例子 (一个是两个Session的两条SQL产生死锁;另一个是两个Session的一条SQL,产生死锁):
上面的两个死锁用例。第一个非常好理解,也是最常见的死锁,每个事务执行两条SQL,分别持有了一把锁,然后加另一把锁,产生死锁。
第二个用例,虽然每个Session都只有一条语句,仍旧会产生死锁。要分析这个死锁,首先必须用到本文前面提到的MySQL加锁的规则。针对Session 1,从name索引出发,读到的[hdc, 1],[hdc, 6]均满足条件,不仅会加name索引上的记录X锁,而且会加聚簇索引上的记录X锁,加锁顺序为先[1,hdc,100],后[6,hdc,10]。而Session 2,从pubtime索引出发,[10,6],[100,1]均满足过滤条件,同样也会加聚簇索引上的记录X锁,加锁顺序为[6,hdc,10],后[1,hdc,100]。发现没有,跟Session 1的加锁顺序正好相反,如果两个Session恰好都持有了第一把锁,请求加第二把锁,死锁就发生了。
结论:死锁的发生与否,并不在于事务中有多少条SQL语句,死锁的关键在于:两个(或以上)的Session加锁的顺序不一致。而使用本文上面提到的,分析MySQL每条SQL语句的加锁规则,分析出每条语句的加锁顺序,然后检查多个并发SQL间是否存在以相反的顺序加锁的情况,就可以分析出各种潜在的死锁情况,也可以分析出线上死锁发生的原因。
8、总结
写到这儿,本文也告一段落,做一个简单的总结,要做的完全掌握MySQL/InnoDB的加锁规则,甚至是其他任何数据库的加锁规则,需要具备以下的一些知识点:
了解数据库的一些基本理论知识:数据的存储格式 (堆组织表 vs 聚簇索引表);并发控制协议 (MVCC vs Lock-Based CC);Two-Phase Locking;数据库的隔离级别定义 (Isolation Level);
了解SQL本身的执行计划 (主键扫描 vs 唯一键扫描 vs 范围扫描 vs 全表扫描);
了解数据库本身的一些实现细节 (过滤条件提取;Index Condition Pushdown;Semi-Consistent Read);
了解死锁产生的原因及分析的方法 (加锁顺序不一致;分析每个SQL的加锁顺序)
有了这些知识点,再加上适当的实战经验,全面掌控MySQL/InnoDB的加锁规则,当不在话下。
以上是关于MySQL锁的主要内容,如果未能解决你的问题,请参考以下文章
MySQL 进阶 锁 -- MySQL锁概述MySQL锁的分类:全局锁(数据备份)表级锁(表共享读锁表独占写锁元数据锁意向锁)行级锁(行锁间隙锁临键锁)
mysql插入意向锁测试
mysql数据库锁MDL锁的解释
Mysql加锁过程详解-基本知识
Day522.Mysql锁 -mysql
大牛总结的MySQL锁优化