锁的结构
锁在内存中的结构如图所示:
当我们去拿锁的时候,就会在内存中创建一个这样的结构,像上图的结构的产生流程如下:
- 事务id为T1的事务,通过sql获取记录的X锁,在内存中就会生成一个锁的结构,结构中记录了事务的信息,并把is_waiting设置为false。
- 事务id为T2的事务,通过sql想要获取记录的X锁,发现内存中已经有锁的结构了,于是生成另一个锁的结构,结构中记录了事务的信息,并把is_waiting设置为true。
- 当事务id为T1的事务执行完成执行,会把内存中指向该记录的其他内存结构的is_waiting设置为false,并唤醒线程去竞争锁。
快照读和当前读
- 快照读:我们执行如
SELECT * FROM TABLE
,MySQL并不会对这条语句加任何的锁,而是通过MVCC机制读取到数据的快照。 - 当前读:通过MySQL的锁机制,读取到最新的数据,加锁。
SELECT * FROM TABLE LOCK IN SHARE MODE
SELECT * FROM TABLE FOR UPDATE
UPDATE
DELETE
INSERT
共享锁(S)和独占锁(X)
- 独占锁:指该锁一次只能被一个线程锁持有,通过
FOR UPDATE
,UPDATE
,DELETE
,INSERT
可加上独占锁,加独占锁之前,锁定资源不能有其他独占锁和共享锁,加锁之后,该资源也不能再被其他线程加上独占锁和共享锁。 - 共享锁:值该锁可被多个线程所持有,通过
LOCK IN SHARE MODE
可以加上共享锁。
意向锁
MySQL在加表级锁的时候,要确认行级锁的情况,比如加表级X锁,就要确保没有任何行级锁(无论是S锁还是X锁),如果遍历去查询,那么性能是非常低的,设计者为了解决这个问题,提出了意向锁的概念。
- 意向独占锁(IX):在要加行级独占锁之前,要先向对应的表挂上意向独占锁。
- 意向共享锁(IS):在要加行级共享锁之前,要先向对应的表挂上意向共享锁。
这样在加表级锁之前,可以先确定是否存在IX, IS,以此来判断能不能加上表级锁。
表级锁
MySQL的一些存储引擎,像MyIsam、Memory、Merge这些只有表级锁,而且这些存储引擎也并不支持事务,因此在这些存储引擎上加锁,是针对会话的。
比如Session1执行select语句,就相当于对目标表加了一个S锁,Session2执行update语句,就相当于加了X锁。因为有这种特性,所以这些存储引擎不适合用在写频繁的场景下。
InnoDB引擎的表级锁
InnoDB提供的表级锁可以说想当的鸡肋,一般情况下我们都不会用到,如果需要用到可以手动用以下语句加锁:
LOCK TABLES 表名 READ //加上表级S锁
LOCK TABLES 表名 WRITE //加上表级X锁
举一个例子,我们执行DML(select、update等)语句,那么我们执行DDL(alter、drop等)就需要阻塞。而我们执行DDL(alter、drop等),在执行DML(select、update等)语句,也需要阻塞。
乍一看,好像用了表级锁来控制,然而并不是,MySQL压根没用InnoDB的表级锁,而是在server层使用了一种在称为元数据锁的东西。
IS意向锁和IX意向锁
这两种锁都是加在表上的,主要是用来加表锁时快速的判断表内行级锁的情况。
表级别AUTO-INC锁
我们给表的某一列加上AUTO_INCREMENT
在插入数据的时候,我们就可以不用指定该列的值,MySQL会自动给我们插入对应的值,该值的获取 -> 加一操作是要保证原子性的,InnoDB通过表级别的AUTO-INC锁来保证这个操作的原子性。
具体来说,先获取AUTO-INC锁,把值赋个该列,执行插入,释放AUTO-INC锁。
我们可以看到AUTO-INC锁的释放要在整个语句执行完毕,在我们不清楚插入记录数量的时候,就必须要通过AUTO-INC锁来保证自增列的原子性。
假如我们已经确定插入记录的数量,那么就可以不用AUTO-INC锁了,可以用一种轻量级锁,具体操作可以变为先获取AUTO-INC锁,把值赋个该列,释放AUTO-INC锁,执行插入。
元数据锁
元数据锁主要是面向DML和DDL之间的并发控制,如果对一张表做DML增删改查操作的同时,有一个线程在做DDL操作,不加控制的话,就会出现错误和异常。元数据锁不需要我们显式的加,系统默认会加。
当做DML操作时,会申请一个MDL读锁。
当做DDL操作时,会申请一个MDL写锁。
读锁之间不互斥,读写和写写之间都互斥。
行级锁
record lock
我们常说的行级锁就是指的这个类型record lock的行级锁,record lock分为S型record lock和X型record lock,这种锁是作用在记录级别的。
我们给一条记录加上X型record lock,其他线程就不能在这条记录上加S型record lock和X型record lock。给一条记录加上S型record lock,其他线程可以加S型record lock,但是不能加上X型record lock。
gap lock
gap lock,称为间隙锁,MySQL在REPEATABLE READ隔离级别下用该锁来解决幻读的问题。假如我们有以下表:
实际上,在页中这些记录的存储都是连续的并没有像图中显示的这样有间隙,这里只是为了方便理解。
假如我们给id为8的记录加上gap锁,那么这条记录往前的区间(3,8)也会加锁。
id为8的记录往后到+∞也有着一个区间(8,+∞),这一个区间的加上gap锁要依靠最后一条记录所在页的supremum
行。
Next-Key lock
Next-Key lock是record lock和gap lock的组合,比如我们给id为8的记录加上Next-Key lock,那么会给id为8的记录加上record lock,并且给区间(3, 8)加上gap lock。
Insert Intention Lock
我们说MySQL的gap lock可以解决幻读的问题,假如我们在id为8的记录加上gap lock或者Next-Key lock,那么区间(3,8)也会被锁上,我们就没办法往这个区间插入数据,当我们尝试往这个区间插入数据时,会生成一个Insert Intention Lock结构(意向间隙锁),该结果会指向这条记录。
产生图上结构的锁流程如下:
- trx为T1的事务,为id为8的记录加上gap lock或者Next-Key lock,区间(3,8)也会被锁上。
- trx为T2的事务,想插入id为4的记录,trx为T3的事务,想插入id为5的记录,由于区间(3,8)被锁上,因此内存中产生了两个锁的结构,请求插入的线程被阻塞。
- trx为T1的事务执行结束,释放间隙锁,并把内存中T2,T3的锁结构的is_waiting改为false,唤醒T2,T3的线程。
隐式锁
在MySQL用锁就意味着要有对应的内存结构,对这种资源的消耗,MySQL的设计者也是能省就省,在INSERT一条记录之后,MySQL不会有任何的锁结构,哪怕插入数据的事务还没有结束,那么假如其他的事务要读取这条记录,就会出现脏读(还没提交,就可以读到)。
一般来说,我们插入一条数据,然后别的事务立刻来读的情况发生的可能性不高,但是也不是完全不会发生,MySQL设计者肯定不能容忍这样的错误。
我们先假设出现这样问题的流程如下:
- 事务T2插入了id为4的记录,但是事务T2还没有结束。
- 事务T3通过
LOCK IN SHARE MODEL
或者FOR UPDATE
查询读取id为4的记录,想要去加锁。
MySQL是这样解决的,事务T3读取的时候会生成READVIEW,READVIEW中有一个活跃事务集合,但是事务T3发生要上锁的记录中记录的trx_id(本例是T2)存在活跃事务集合中,那么就会进行以下操作: - 在内存中生成一个锁结构,trx_id为T2,is_waiting为false
- 在内存中给自己生成一个锁结构,trx_id为T3,is_waiting为true
- 阻塞等待
在事务T2结束的时候,正常情况下内存中是没有该事务的锁结构的,如果有,证明有事务在等着给该记录上锁,那么就要释放掉自己的锁结构,再把trx_id为T3的锁结构的is_waiting改为false,然后唤醒对应的线程。
行锁升级为表锁
InnoDB行级锁是通过给索引上的索引项加锁来实现的,InnoDB行级锁只有通过索引条件检索数据,才使用行级锁;否则,InnoDB使用表锁。
锁的结构优化
我们前面说了非常简化的锁的结构(只提到了is_waiting,事务信息,type)的时候说过事务在给记录加锁的时候会在内存中就会生成一个锁的结构,那么如果一条sql需要给多给记录加锁应该如何处理呢?总不能有10000条记录就生成10000个锁结构吧。
为了解决这个问题,MySQL的设计提出了多条记录共有一个锁结构的概念,设计者决定只要满足以下条件就可以共有一个锁结构:
- 在一个事务内。
- 在一个页面内。
- 锁类型相同。
- 锁的属性相同。
我们现在聊聊更加详细的锁结构,从中理解MySQL是如何解决加锁多个记录的,如图所示:
- 锁所在的事务信息:这里保存的是一个指针,指向具体的事务信息。
- 索引信息:加锁的记录属于哪一个索引。
- 表锁/行锁信息:该段保存的信息跟锁的类型有关。
(1)表锁:存储表的信息,如是对哪个表加的锁。
(2)行级锁:
*Space ID:记录所在表空间ID。
*Page Number:记录所在页面的页号。
*n_bits:记录后面一堆比特位
区域具体比特的数量。 - type_mode:由一块32比特的区域组成,如下:
(1)lock_mode:LOCK_IS(0), LOCK_IX(1), LOCK_S(2), LOCK_X(3), LOCK_AUTO_INC(4)。
(2)lock_type:LOCK_TABLE(16), LOCK_RECORD(32)。
(3)lock_wait:第九位为1时,is_waiting为true,is_waiting为false。
(4)record_lock_type:LOCK_GAP(512), LOCK_REC_NOT_GAP(1024), LOCK__INSERT_INTENTION(2048)。 - 一堆比特位:如布隆过滤器一样,每一位代表为1,表示要加锁的记录在页中对应的位置,当然MySQL会准备比实际记录行数更多的比特位,主要是怕页中记录的增加。
普通的SELECT
- 读未提交:每次都读取最新的数据,有脏读,不可重复读,幻读的问题。
- 读已提交:每次SELECT都会生成新的READVIEW,有不可重复读,幻读的问题。
- 可重复读:第一次SELECT的时候会生成READVIEW,可以解决大部分幻读的问题。
MVCC怎么解决幻读问题:按照READVIEW的原理,会生产一个活跃事务ID的集合,如果一个插入事务已经拥有了事务ID,那么它必定在活跃事务集合内,不可读,如果是READVIEW产生之后,插入事务才开启执行插入(这个时候才生成事务ID),那么它的事务ID必定大于活跃事务集合的最大ID,所以也是不可读。
MVCC无法解决全部的幻读问题:我们知道在第一次SELECT的时候产生READVIEW,如果一个事务T1执行了一次查询产生了READVIEW,接着有另一个事务T2执行了INSERT,然后提交,我们知道事务T2的ID一定在T1的活跃事务ID的集合中合着大于活跃事务ID的集合的最大ID,假如事务T1再去SELECT肯定读不到,但是假如事务T1先执行一个UPDATE操作,因为事务T2已经提交了,按照隐式锁的概念只要插入事务提交了,那么事务T1就可以UPDATE这条记录,那么事务ID就会变成T1,之后T1在执行SLECT就可以读取到这条记录了 - 可串行化:
(1)autocommit为1时:不需要借助锁,通过MVCC的机制就可以解决全部的问题。
(2)autocommit为0时:普通的SELECT会被转为LOCK IN SHARE MODE的S锁。
语句加锁分析
我们前面说了许多锁的东西,但是始终没有聊到record lock、gap lock和next-key lock这几个锁MySQL是怎么样使用的,我们现在就来说以下语句MySQL是如何操作的:
- LOCK IN SHARE MODE
- FOR UPDATE
- UPDATE
- DELETE
我们先假设以下结构,ID为主键,key为二级索引。
LOCK IN SHARE MODE和FOR UPDATE
这两个语句本质上的原理没有太大的区别,只不过FOR UPDATE加的锁是X型的,LOCK IN SHARE MODE加的锁是S型的,所以我们只分析LOCK IN SHARE MODE的操作。
使用聚簇索引
不同的隔离级别在使用锁和操作上会有很大的差异,现在执行以下语句:
SELECT * FROM TABLE WHERE id > 1 and id <= 15 and col = true;
该语句会形成区间(1, 15]。
READ UNCOMMITTED和READ COMMITTED
- 找到满足区间的第一条数据,也就是id = 3的记录。
- 给id = 3的记录加上S型的record lock。
- 由于读取的是聚簇索引,所以没有索引下推的判断。
- 由于读取的是聚簇索引,所以不需要进行回表的操作。
- 形成区间(1, 15]的条件是id > 1 and id <= 15,很显然id = 3是满足的,存储引擎返回数据给server。
- server进一步进行条件id > 1 and id <= 15 and col = true的判断,id = 3的记录满足该条件,所以把记录返回客户端,锁不会释放。
- 获取id = 3记录的下一条记录。
为id = 8的记录进行加锁分析: - 给id = 8的记录加上S型的record lock。
- 由于读取的是聚簇索引,所以没有索引下推的判断。
- 由于读取的是聚簇索引,所以不需要进行回表的操作。
- 形成区间(1, 15]的条件是id > 1 and id <= 15,很显然id = 8是满足的,存储引擎返回数据给server。
- server进一步进行条件id > 1 and id <= 15 and col = true的判断,id = 8的记录满足该条件,所以把记录返回客户端,锁不会释放。
- 获取id = 8记录的下一条记录。
为id = 15的记录进行加锁分析: - 给id = 15的记录加上S型的record lock。
- 由于读取的是聚簇索引,所以没有索引下推的判断。
- 由于读取的是聚簇索引,所以不需要进行回表的操作。
- 形成区间(1, 15]的条件是id > 1 and id <= 15,很显然id = 15是满足的,存储引擎返回数据给server。
- server进一步进行条件id > 1 and id <= 15 and col = true的判断,id = 15的记录不满足该条件,所以释放掉该记录上加的S型record lock。
- 获取id = 15记录的下一条记录。
为id = 20的记录进行加锁分析: - 给id = 20的记录加上S型的record lock。
- 由于读取的是聚簇索引,所以没有索引下推的判断。
- 由于读取的是聚簇索引,所以不需要进行回表的操作。
- 形成区间(1, 15]的条件是id > 1 and id <= 15,很显然id = 20是不满足的,释放掉S型record lock,并返回“查询完毕”的信息给server。
- server收到“查询完毕”的信息,结束查询。
以上步骤会形成如下加锁图示:
REPEATABLE READ和SERIALIZABLE
- 找到满足区间的第一条数据,也就是id = 3的记录。
- 给id = 3的记录加上S型的next-key lock。
- 由于读取的是聚簇索引,所以没有索引下推的判断。
- 由于读取的是聚簇索引,所以不需要进行回表的操作。
- 形成区间(1, 15]的条件是id > 1 and id <= 15,很显然id = 3是满足的,存储引擎返回数据给server。
- server进一步进行条件id > 1 and id <= 15 and col = true的判断,id = 3的记录满足该条件,所以把记录返回客户端,锁不会释放。
- 获取id = 3记录的下一条记录。
为id = 8的记录进行加锁分析: - 给id = 8的记录加上S型的next-key lock。
- 由于读取的是聚簇索引,所以没有索引下推的判断。
- 由于读取的是聚簇索引,所以不需要进行回表的操作。
- 形成区间(1, 15]的条件是id > 1 and id <= 15,很显然id = 8是满足的,存储引擎返回数据给server。
- server进一步进行条件id > 1 and id <= 15 and col = true的判断,id = 8的记录满足该条件,所以把记录返回客户端,锁不会释放。
- 获取id = 8记录的下一条记录。
为id = 15的记录进行加锁分析: - 给id = 15的记录加上S型的next-key lock。
- 由于读取的是聚簇索引,所以没有索引下推的判断。
- 由于读取的是聚簇索引,所以不需要进行回表的操作。
- 形成区间(1, 15]的条件是id > 1 and id <= 15,很显然id = 15是满足的,存储引擎返回数据给server。
- server进一步进行条件id > 1 and id <= 15 and col = true的判断,id = 15的记录不满足该条件,锁不会释放。
- 获取id = 15记录的下一条记录。
为id = 20的记录进行加锁分析: - 给id = 20的记录加上S型的next-key lock。
- 由于读取的是聚簇索引,所以没有索引下推的判断。
- 由于读取的是聚簇索引,所以不需要进行回表的操作。
- 形成区间(1, 15]的条件是id > 1 and id <= 15,很显然id = 20是不满足的,不释放S型next-key lock,并返回“查询完毕”的信息给server。
- server收到“查询完毕”的信息,结束查询。
以上步骤会形成如下加锁图示:
由于是用next-key lock加的锁,所以区间(1,3),(3,8),(8,15),(15,20)都会被锁上(gap锁)。
使用二级索引
执行以下语句:
SELECT * FROM TABLE WHERE key > 'a' and key <= 'd' and col = true;
该语句会形成区间(‘a’, ‘d’]。
READ UNCOMMITTED和READ COMMITTED
- 读取第一条满足区间(‘a’, ‘d’]的二级索引记录,也就是key = 'b’的二级索引记录。
- 给该二级索引记录加上S型record lock。
- 索引下推,是否满足key > ‘a’ and key <= ‘d’,key = 'b’的二级索引记录明显是满足的。
- 进行回表,拿到id = 8的聚簇索引记录,并给该id = 8的记录加上S型record lock。
- 形成区间(‘a’, ‘d’]的条件是key > ‘a’ and key <= ‘d’,很显然id = 8是满足的,存储引擎返回数据给server。
- server判断其他条件col = true是否满足,id = 8的记录是满足的,因此把数据返回给客户端,锁不会释放。
- 获取key = 'b’的二级索引记录的下一条记录。
为key = 'c’的记录进行加锁分析: - 给key = 'c’的二级索引记录加上S型record lock。
- 索引下推,是否满足key > ‘a’ and key <= ‘d’,key = 'c’的二级索引记录明显是满足的。
- 进行回表,拿到id = 1的聚簇索引记录,并给该id = 1的记录加上S型record lock。
- 形成区间(‘a’, ‘d’]的条件是key > ‘a’ and key <= ‘d’,很显然id = 1是满足的,存储引擎返回数据给server。
- server判断其他条件col = true是否满足,id = 1的记录是满足的,因此把数据返回给客户端,锁不会释放。
- 获取key = 'c’的二级索引记录的下一条记录。
为key = 'd’的记录进行加锁分析: - 给key = 'd’的二级索引记录加上S型record lock。
- 索引下推,是否满足key > ‘a’ and key <= ‘d’,key = 'd’的二级索引记录明显是满足的。
- 进行回表,拿到id = 15的聚簇索引记录,并给该id = 15的记录加上S型record lock。
- 形成区间(‘a’, ‘d’]的条件是key > ‘a’ and key <= ‘d’,很显然id = 15是满足的,存储引擎返回数据给server。
- server判断其他条件col = true是否满足,id = 15的记录是不满足的,因此释放掉聚簇索引和二级索引上的锁。
- 获取key = 'd’的二级索引记录的下一条记录。
为key = 'f’的记录进行加锁分析: - 给key = 'f’的二级索引记录加上S型record lock。
- 索引下推,是否满足key > ‘a’ and key <= ‘d’,key = 'd’的二级索引记录明显是不满足的,直接返回“查询完毕”的信息给server。
- server收到“查询完毕”的信息,结束查询。
以上步骤会形成如下加锁图示:
REPEATABLE READ和SERIALIZABLE
- 读取第一条满足区间(‘a’, ‘d’]的二级索引记录,也就是key = 'b’的二级索引记录。
- 给该二级索引记录加上S型next-key lock。
- 索引下推,是否满足key > ‘a’ and key <= ‘d’,key = 'b’的二级索引记录明显是满足的。
- 进行回表,拿到id = 8的聚簇索引记录,并给该id = 8的记录加上S型record lock。
- 形成区间(‘a’, ‘d’]的条件是key > ‘a’ and key <= ‘d’,很显然id = 8是满足的,存储引擎返回数据给server。
- server判断其他条件col = true是否满足,id = 8的记录是满足的,因此把数据返回给客户端,锁不会释放。
- 获取key = 'b’的二级索引记录的下一条记录。
为key = 'c’的记录进行加锁分析: - 给key = 'c’的二级索引记录加上S型next-key lock。
- 索引下推,是否满足key > ‘a’ and key <= ‘d’,key = 'c’的二级索引记录明显是满足的。
- 进行回表,拿到id = 1的聚簇索引记录,并给该id = 1的记录加上S型record lock。
- 形成区间(‘a’, ‘d’]的条件是key > ‘a’ and key <= ‘d’,很显然id = 1是满足的,存储引擎返回数据给server。
- server判断其他条件col = true是否满足,id = 1的记录是满足的,因此把数据返回给客户端,锁不会释放。
- 获取key = 'c’的二级索引记录的下一条记录。
为key = 'd’的记录进行加锁分析: - 给key = 'd’的二级索引记录加上S型next-key lock。
- 索引下推,是否满足key > ‘a’ and key <= ‘d’,key = 'd’的二级索引记录明显是满足的。
- 进行回表,拿到id = 15的聚簇索引记录,并给该id = 15的记录加上S型record lock。
- 形成区间(‘a’, ‘d’]的条件是key > ‘a’ and key <= ‘d’,很显然id = 15是满足的,存储引擎返回数据给server。
- server判断其他条件col = true是否满足,id = 15的记录是不满足的,锁不会释放。
- 获取key = 'd’的二级索引记录的下一条记录。
为key = 'f’的记录进行加锁分析: - 给key = 'f’的二级索引记录加上S型next-key lock。
- 索引下推,是否满足key > ‘a’ and key <= ‘d’,key = 'd’的二级索引记录明显是不满足的,直接返回“查询完毕”的信息给server。
- server收到“查询完毕”的信息,结束查询。
以上步骤会形成如下加锁图示:
精确匹配
READ UNCOMMITTED和READ COMMITTED下的精确匹配之后给对应的记录加上record lock,REPEATABLE READ和SERIALIZABLE该对应记录的加上record lock,并把记录前后区间加上gap lock。通过这个特性我们要是想锁定区间(20,正无穷)的间隙,可以用id = 21或者以上的条件来锁定。
UPDATE
UPDATE的操作和FOR UPDATE的加锁类似,只是如果是在聚簇索引上加锁,有二级索引的话要获取对应二级索引的锁。
半一致性读
在做UPDATE的时候如果是READ UNCOMMITTED和READ COMMITTED,发现记录被上锁了,会被记录最新的版本读出来,先进行判断,如果不满足直接跳到下一个记录,如果满足在加锁,这样可以减少UPDATE被阻塞。
查看加锁情况
使用information_schema数据库中的表获取锁信息
SELECT * FROMG information_schema.INNODB_TRX;
使用SHOW ENGINE INNODB STATUS获取锁信息
SELECT ENGINE INNODB STATUS;