目 录 1 innoDB锁简介 3
2 表锁 3
3 意向锁 5
4 自增锁 5
5 行锁 5
5.1 记录锁(record locks) 5
5.2 间歇锁(gap locks) 6
5.3 临键锁(next-key locks) 6
5.4 插入意向锁(insert intention locks) 7
5.5 行锁小结 7
6 innodb四种事务隔离级别 8
7 死锁 9
7.1 死锁例一 9
7.2 死锁例二 9
7.3 死锁例三 10
7.4 死锁例四 10
7.5 死锁例五 11
7.6 死锁小结 11
innoDB锁简介
innoDb支持多种粒度的锁,按照粒度来分,可分为表锁(LOCK_TABLE)和行锁(LOCK_REC)。
按照机制来分,可分为共享锁和排他锁,共享锁也叫读锁,排他锁也叫写锁。加在同一个资源上,写锁会阻塞另外一把写锁或读锁的获取,读锁则允许另外一把读锁的获取,也就是读读之间允许并发,读写或者写写会阻塞,innodb中表锁和行锁都支持共享锁(简写S)和排他锁(简写X)。
因为innoDB支持多粒度的锁,允许表锁和行锁的并存,为了方便多粒度锁冲突的判断,innoDB中还存在一种名叫意向锁(Intention Locks)的锁。
除此之外,还有一种特殊的表锁,自增锁,主要用来并发安全的生成自增id;一种特殊的意向锁,插入意向锁,用来防止幻读问题。
表锁
表锁,锁定的粒度是整个表,也分共享锁和排他锁。不同于行锁,表锁MySQL Server层就有实现(所以MyISAM支持表锁,也只支持表锁),innoDb则在存储引擎层面也实现了一遍表锁。 哪些时候会触发表锁呢?在执行某些ddl时,比如alter table等操作,会对整个表加锁,禁止dml,允许查询(注:mysql5.6之前版本,5.6之后为online ddl)。也可以手动执行锁表语句:LOCK TALBES table_name [READ | WRITE],READ为共享锁,WRITE为排他锁,手动解锁的语句为:UNLOCK TABLES,会直接释放当前会话持有的所有表锁。
5.6 online ddl推出以前,执行ddl主要有两种方式copy方式和inplace方式,inplace方式又称为(fast index creation)。相对于copy方式,inplace方式不拷贝数据,因此较快。但是这种方式仅支持添加、删除索引两种方式,而且与copy方式一样需要全程锁表,实用性不是很强。下面以加索引为例,简单介绍这两种方式的实现流程。
5.6之后 ddl分成copy和online ddl两种方式,对于不支持online的ddl操作采用copy方式,比如修改列类型,删除主键,修改字符集等,这些操作都会导致记录格式发生变化,无法通过简单的全量+增量的方式实现online;对于online ddl方式,mysql内部以“是否修改记录格式”为基准分为两类,一类需要重建表(重新组织记录),比如optimize table、添加索引、添加/删除列、修改列NULL/NOT NULL属性等;另外一类是只需要修改表的元数据,比如删除索引、修改列名、修改列默认值、修改列自增值等。Mysql将这两类方式称为rebuild方式和no-rebuild方式。online ddl主要包括3个阶段,prepare阶段,ddl执行阶段,commit阶段,rebuild方式比no-rebuild方式实质多了一个ddl执行阶段(允许读写),prepare阶段和commit阶段(禁止读写,但速度极快)。下面将主要介绍ddl执行过程中三个阶段的流程。
参考文档:https://www.cnblogs.com/cchust/p/4639397.html
意向锁
意向锁是一种特殊的表级锁,意向锁是为了让InnoDB多粒度的锁能共存而设计的。取得行的共享锁和排他锁之前需要先取得表的意向共享锁(IS)和意向排他锁(IX),意向共享锁和意向排他锁都是系统自动添加和自动释放的,整个过程无需人工干预。主要是用来辅助表级和行级锁的冲突判断,因为Innodb支持行级锁,如果没有意向锁,则判断表锁和行锁冲突的时候需要遍历表上所有行锁,有了意向锁,则只要判断表是否存在意向锁就可以知道是否有行锁了。
自增锁
自增锁是一种特殊的表级别锁,如果一个表的某个行具有AUTO_INCREMENT的列,则一个事务在插入记录到这个表的时候,会先获取自增锁。如果一个事务持有自增锁,会阻塞其他事物对该表的插入操作,保证自增连续。
行锁
Innodb中的行锁种类繁多,可以分为:记录锁(record locks)、间隙锁(gap locks)、临键锁(next-key locks),插入意向锁(insert intention locks)。行锁在逻辑上都可以看作作用于索引或者索引间隙之上,索引分为主键索引和非主键索引两种,如果一条sql语句操作了主键索引,MySQL就会锁定这条主键索引;如果一条语句操作了非主键索引,MySQL会先锁定该非主键索引,再锁定相关的主键索引。
很多语句都会加行锁,比如Update、Delete、Insert等操作,或者使用SELECT ... FOR SHARE | UPDATE [NOWAIT |SKIP LOCKED]来进行当前读(Locking Reads),其中SHARE表示加共享锁,UPDATE表示加排他锁。当要加的锁与当前行已有锁互斥时,会一直阻塞等待一段时间(innodb_lock_wait_timeout定义了等待时间)。加上NOWAIT参数则不会阻塞,会立即返回,并显示一个错误,加上SKIP LOCKED则会在结果集中跳过这些冲突的记录(慎用)。
在不同的语句,不同的事务隔离级别下,甚至不同的索引类型下,行锁会表现成不同的形式,下面介绍这些形式:
记录锁(record locks)
在逻辑上,记录锁可以理解为锁定的是某个具体的索引,当SQL执行按照唯一性(Primary key、Unique key)索引进行数据的检索时,查询条件等值匹配且查询的数据是存在,这时 SQL 语句加上的锁即为记录锁。
间歇锁(gap locks)
在逻辑上,间隙锁可以理解为锁住的是索引之间的间隙,是一个左开右开的区间。当SQL执行按照索引进行数据的检索时,查询条件的数据不存在,这时SQL语句加上的锁即为间隙锁。
如上图,因为这些语句查询的值都不存在,所以锁住的都是间隙。并且在 InnoDb 存储引擎里,每个数据页中都会有两个虚拟的行记录,用来限定记录的边界,分别是:Infimum Record 和 Supremum Record,Infimum 是比该页中任何记录都要小的值,而 Supremum 比该页中最大的记录值还要大,这两条记录在创建页的时候就有了,并且不会删除。所以当查询的值比当前已有记录最大值还大时候,锁住的会是最大值到Supremum之间的间隙。比如第一条语句,查询的时候就算是等值匹配,只要这个不存在的数据落在两个索引节点之间,就算不是一个范围,也会锁住索引节点间的所有数据即gap3,范围(7,11)。
间隙锁是可以共存的,共享间隙锁与独占间隙锁之间是没有区别的,两者之间并不冲突。其存在的目的都是防止其他事务往间隙中插入新的纪录,故而一个事务所采取的间隙锁是不会去阻止另外一个事务在同一个间隙中加锁的。
间隙锁是设计用来防止幻读的,当锁定一个gap时,其他事务没有办法再往这个gap中插入数据,PostgreSQL没有这种机制,所以PostgreSQl没有办法锁住不存在的行,无法防止幻读。
临键锁(next-key locks)
在逻辑上,临键锁可以理解为锁住的是索引本身以及索引之前的间隙,是一个左开右闭的区间。当SQL执行按照非唯一索引进行数据的检索时,会给匹配到行上加上临键锁。
如上图,当执行select * from table_name where id = 3 for update时会锁定(-∞,3 ]区间,因为按照这个SQL的语义,即是为了锁住id=3的数据,不允许其他操作,如果只是锁住记录本身,肯定是没有办法保证的,因为这是非唯一索引,还有可能插入其他id=3的数据,如果把间隙都给锁住,则其他对这个间隙的插入操作都会被阻塞,从而保证了一致性,这也是临键锁的用意。
如果加锁时,查询条件没有命中索引(非ICP的查询),则InnoDB会尝试给全表每一条记录都加上临键锁,效果相当于锁表了。
插入意向锁(insert intention locks)
插入意向锁是一种间隙锁形式的意向锁,在真正执行INSERT操作之前设置。当执行插入操作时,总会检查当前区间是否存在间隙锁或者临键锁,如果存在,则判定和插入意向锁冲突,当前插入操作就需要等待,也就是配合上面的间隙锁或者临键锁一起防止了幻读操作。
因为插入意向锁是一种意向锁,意向锁只是表示一种意象,所以插入意向锁之间不会互相冲突,多个插入操作同时插入同一个gap时,无需互相等待,比如当前索引上有记录4和8,两个并发session同时插入记录6,7。他们会分别为(4,8)加上插入意向锁,但相互之间并不冲突。
行锁小结
行锁在不同的语句中和环境条件下可以表现成:记录锁(record locks)、 间隙锁(gap locks)、临键锁(next-key locks)和插入意向锁(insert intention locks)。记录锁锁住具体的记录,间隙锁锁住记录之间的间隙,临键锁锁住记录和记录前面的间隙,插入意向锁则是特殊的间隙锁,在插入前判断行将要插入的间隙是否会有冲突。
以上说的各种行锁的加锁情况都是在可重复读(REPEATABLE READ)隔离级别下,这个级别可以防止幻读,也是innoDB默认的事务隔离级别,但是其实不同语句在不同隔离级别下加锁的情况会有非常大的区别,以下会简单说明。
innodb四种事务隔离级别
未提交读:会读到其他事务中未提交修改的数据
已提交读:只能读取到提交事务的数据,但不可重复读
可重复读:innodb默认级别,保证了在同一个事务内同一查询结果一致,并且可以防止幻读
串行读:完全串行化的读,每次读都需要获得表级共享锁,读写相互都会阻塞
已提交读和可重复读在行锁方面主要的区别是已提交读取消了间隙锁,临键锁也退化成了记录锁。也就是说加锁时,如果没有符号条件的查询并不加锁,有符合条件的查询也只会给记录加上记录锁。因为没有了间隙锁,所以会出现幻读问题。在可重复读隔离级别下,加锁时如果查询条件没有命中索引(非ICP的查询),则会给表中每条记录都加上临键锁。而不可重复读隔离级别下因为没有间隙锁,则会退化成给表中每条数据加上记录锁,并且还会把没有匹配的行上的锁给释放掉,而不是把全表所有记录不管有没有匹配都给锁上。
死锁
因为使用表锁时,需要一次性申请所有所需表的锁,所以在只使用表锁的情况下不会出现死锁,一般出现死锁的情况都是行锁。innoDB有死锁探测机制,在申请锁的时候,都会先进行死锁判断,采用的算法深度优先搜索,并且如果在搜索过程中发现有环,就说明发生了死锁。出现死锁时,innoDB会选择一个回滚代价比较小的事务进行回滚。以下会举几个比较典型的死锁例子(均在可重复度隔离级别下),首先会先建一张测试的表:
死锁例一
这是最简单最典型的死锁情况了,两个事务互相锁定持有资源,并且等待对方的资源,最后形成一个环,死锁出现。最后某个事务回滚,写业务代码的时候,应该对并发条件可能出现这种情况的语句有所警觉。
死锁例二
前提:事务开始时,student表里有id=1的记录
两个事务分别对某个记录申请共享锁,因为共享锁性质,两个事务都能获取到。然后又都对这条记录申请排他锁,T3中事务一申请排他锁,等待事务二的共享锁释放,加入锁等待队列,T4中事务二又申请排他锁,于是形成环,死锁条件达成。所以在事务开始时就要想到后面可能会做的操作,提前获取足够强度的锁,而不是中途升级。
死锁例三
前提:事务开始时,student表里没有id=100的记录
如上,在可重复读隔离级别下,如果两个事务同时对某个间隙用SELECT...FOR UPDATE加排他锁,在没有符合该条件记录情况下,两个线程都会加间歇锁成功。程序发现记录尚不存在,就试图插入一条新记录,如果两个线程都这么做,就会出现死锁。因为在记录真正插入之前会加插入意向锁,插入意向锁和间隙锁互斥,所以在T3时,事务一阻塞申请插入意向锁排队等待事务二的间隙锁释放,T4时,事务二又申请插入意向锁,需要等待事务一的间隙锁释放,形成环,死锁条件达成。
死锁例四
前提:事务开始时,student表里没有uuid=uuid100的记录
注意这里插入意向锁之间不互斥,这种死锁的原因是INSERT的时候会对唯一索引进行Duplicate Key判断,如果唯一键冲突,则会加共享锁等待,也就是T3时候的事务二和事务三,都会获得共享锁。T4时,事务一回滚,事务二和事务三都会申请升级排他锁,这样就造成类似死锁案例二的情况,形成死锁了。
死锁例五
这个例子引用自淘宝数据库内核月报-InnoDB 事务锁系统简介,这个地方的死锁是因为事务一的加锁顺序是先锁二级索引name_index,再锁聚集索引,事务二的加锁顺序是,先锁聚集索引,再锁二级索引name_index,不同的加锁顺序在并发时可能导致死锁。
死锁小结
出现死锁后某个事务会回滚,其他事务成功,上层业务会捕获到死锁错误,再重试一般会成功,如果出现大量锁重试,则说明哪里出了问题,写代码的时候可以注意以下几点可以减少死锁出现的概率:
- 类似的业务逻辑尽量以固定的顺序访问表和行;
- 如果业务允许,大事务拆小,大事务持有锁的时间更长,更容易出现死锁
- 为表添加合理的索引,可以看到可重复读级别下,如果不走索引(非ICP的查询)将会为表的每一行记录加锁
- dml语句的where条件尽量用主键,因为间隙锁和临键锁的存在,锁住的可能不止是一行记录
5)禁止使用读锁(for share),因为先用for share锁住行,后面再update很容易死锁