标签:公众号文章mysql
本文是用来系统阐述在MySQL中,不一样语句在各类条件下的加锁状况,并非解释各类锁是什么(或者说加锁的本质是什么),你们若是不理解什么是MVCC
、ReadView
、正经记录锁
、gap锁
、next-key锁
、插入意向锁
这些概念的,能够参考MySQL
的官方文档,或者直接参照《MySQL是怎样运行的:从根儿上理解MySQL》这本小册(里边有比官方文档更贴心,更详细的解释,文章中涉及到的全部概念均在小册中有详细解释,若是阅读过程当中有疑惑能够提到咱们的小册群中来。有兴趣的同窗能够阅读小册:《MySQL是怎样运行的:从根儿上理解MySQL》小册连接):sql
另外,本文是讲述MySQL在READ UNCOMMITTED/READ COMMITTED
隔离级别下的加锁状况,其余隔离级别下,以及关于INSERT语句的加锁状况请关注公众号“咱们都是小青蛙”,输入“锁”查看剩余文章。bash
创建一个存储三国英雄的hero
表:微信
CREATE TABLE hero (
number INT,
name VARCHAR(100),
country varchar(100),
PRIMARY KEY (number),
KEY idx_name (name)
) Engine=InnoDB CHARSET=utf8;
复制代码
而后向这个表里插入几条记录:并发
INSERT INTO hero VALUES
(1, 'l刘备', '蜀'),
(3, 'z诸葛亮', '蜀'),
(8, 'c曹操', '魏'),
(15, 'x荀彧', '魏'),
(20, 's孙权', '吴');
复制代码
而后如今hero
表就有了两个索引(一个二级索引,一个聚簇索引),示意图以下:svn
其实啊,“XXX语句该加什么锁”自己就是个伪命题,一条语句须要加的锁受到不少条件制约,比方说:性能
事务的隔离级别优化
语句执行时使用的索引(好比聚簇索引、惟一二级索引、普通二级索引)ui
查询条件(比方说=
、=<
、>=
等等)spa
具体执行的语句类型
在继续详细分析语句的加锁过程前,你们必定要有一个全局概念:加锁
只是解决并发事务执行过程当中引发的脏写
、脏读
、不可重复读
、幻读
这些问题的一种解决方案(MVCC
算是一种解决脏读
、不可重复读
、幻读
这些问题的一种解决方案),必定要意识到加锁
的出发点是为了解决这些问题,不一样情景下要解决的问题不同,才致使加的锁不同,千万不要为了加锁而加锁,容易把本身绕进去。固然,有时候由于MySQL
具体的实现而致使一些情景下的加锁有些不太好理解,这就得咱们死记硬背了~
咱们这里把语句分为3种大类:普通的SELECT
语句、锁定读的语句、INSERT
语句,咱们分别看一下。
普通的SELECT
语句在:
READ UNCOMMITTED
隔离级别下,不加锁,直接读取记录的最新版本,可能发生脏读
、不可重复读
和幻读
问题。
READ COMMITTED
隔离级别下,不加锁,在每次执行普通的SELECT
语句时都会生成一个ReadView
,这样解决了脏读
问题,但没有解决不可重复读
和幻读
问题。
REPEATABLE READ
隔离级别下,不加锁,只在第一次执行普通的SELECT
语句时生成一个ReadView
,这样把脏读
、不可重复读
和幻读
问题都解决了。
不过这里有一个小插曲:
# 事务T1,REPEATABLE READ隔离级别下
mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT * FROM hero WHERE number = 30;
Empty set (0.01 sec)
# 此时事务T2执行了:INSERT INTO hero VALUES(30, 'g关羽', '魏'); 并提交
mysql> UPDATE hero SET country = '蜀' WHERE number = 30;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> SELECT * FROM hero WHERE number = 30;
+--------+---------+---------+
| number | name | country |
+--------+---------+---------+
| 30 | g关羽 | 蜀 |
+--------+---------+---------+
1 row in set (0.01 sec)
复制代码
在REPEATABLE READ
隔离级别下,T1
第一次执行普通的SELECT
语句时生成了一个ReadView
,以后T2
向hero
表中新插入了一条记录便提交了,ReadView
并不能阻止T1
执行UPDATE
或者DELETE
语句来对改动这个新插入的记录(由于T2
已经提交,改动该记录并不会形成阻塞),可是这样一来这条新记录的trx_id
隐藏列就变成了T1
的事务id
,以后T1
中再使用普通的SELECT
语句去查询这条记录时就能够看到这条记录了,也就把这条记录返回给客户端了。由于这个特殊现象的存在,你也能够认为InnoDB
中的MVCC
并不能完彻底全的禁止幻读。
SERIALIZABLE
隔离级别下,须要分为两种状况讨论:
在系统变量autocommit=0
时,也就是禁用自动提交时,普通的SELECT
语句会被转为SELECT ... LOCK IN SHARE MODE
这样的语句,也就是在读取记录前须要先得到记录的S锁
,具体的加锁状况和REPEATABLE READ
隔离级别下同样,咱们后边再分析。
在系统变量autocommit=1
时,也就是启用自动提交时,普通的SELECT
语句并不加锁,只是利用MVCC
来生成一个ReadView
去读取记录。
为啥不加锁呢?由于启用自动提交意味着一个事务中只包含一条语句,一条语句也就没有啥不可重复读
、幻读
这样的问题了。
咱们把下边四种语句放到一块儿讨论:
语句一:SELECT ... LOCK IN SHARE MODE;
语句二:SELECT ... FOR UPDATE;
语句三:UPDATE ...
语句四:DELETE ...
咱们说语句一
和语句二
是MySQL
中规定的两种锁定读
的语法格式,而语句三
和语句四
因为在执行过程须要首先定位到被改动的记录并给记录加锁,也能够被认为是一种锁定读
。
在READ UNCOMMITTED
下语句的加锁方式和READ COMMITTED
隔离级别下语句的加锁方式基本一致,因此就放到一起说了。值得注意的是,采用加锁
方式解决并发事务带来的问题时,其实脏读
和不可重复读
在任何一个隔离级别下都不会发生(由于读-写
操做须要排队进行)。
使用SELECT ... LOCK IN SHARE MODE
来为记录加锁,比方说:
SELECT * FROM hero WHERE number = 8 LOCK IN SHARE MODE;
复制代码
这个语句执行时只须要访问一下聚簇索引中number
值为8
的记录,因此只须要给它加一个S型正经记录锁
就行了,如图所示:
使用SELECT ... FOR UPDATE
来为记录加锁,比方说:
SELECT * FROM hero WHERE number = 8 FOR UPDATE;
复制代码
这个语句执行时只须要访问一下聚簇索引中number
值为8
的记录,因此只须要给它加一个X型正经记录锁
就行了,如图所示:
小贴士: 为了区分S锁和X锁,咱们以后在示意图中就把加了S锁的记录染成蓝色,把加了X锁的记录染成紫色。
使用UPDATE ...
来为记录加锁,比方说:
UPDATE hero SET country = '汉' WHERE number = 8;
复制代码
这条UPDATE
语句并无更新二级索引列,加锁方式和上边所说的SELECT ... FOR UPDATE
语句一致。
若是UPDATE
语句中更新了二级索引列,比方说:
UPDATE hero SET name = 'cao曹操' WHERE number = 8;
复制代码
该语句的实际执行步骤是首先更新对应的number
值为8
的聚簇索引记录,再更新对应的二级索引记录,因此加锁的步骤就是:
为number
值为8
的聚簇索引记录加上X型正经记录锁
(该记录对应的)。
为该聚簇索引记录对应的idx_name
二级索引记录(也就是name
值为'c曹操'
,number
值为8
的那条二级索引记录)加上X型正经记录锁
。
画个图就是这样:
小贴士: 咱们用带圆圈的数字来表示为各条记录加锁的顺序。
使用DELETE ...
来为记录加锁,比方说:
DELETE FROM hero WHERE number = 8;
复制代码
咱们平时所说的“DELETE表中的一条记录”其实意味着对聚簇索引和全部的二级索引中对应的记录作DELETE
操做,本例子中就是要先把number
值为8
的聚簇索引记录执行DELETE
操做,而后把对应的idx_name
二级索引记录删除,因此加锁的步骤和上边更新带有二级索引列的UPDATE
语句一致,就不画图了。
使用SELECT ... LOCK IN SHARE MODE
来为记录加锁,比方说:
SELECT * FROM hero WHERE number <= 8 LOCK IN SHARE MODE;
复制代码
这个语句看起来十分简单,但它的执行过程仍是有一丢丢小复杂的:
先到聚簇索引中定位到知足number <= 8
的第一条记录,也就是number
值为1
的记录,而后为其加锁。
判断一下该记录是否符合索引条件下推
中的条件。
咱们前边介绍过一个称之为索引条件下推
( Index Condition Pushdown
,简称ICP
)的功能,也就是把查询中与被使用索引有关的查询条件下推到存储引擎中判断,而不是返回到server
层再判断。不过须要注意的是,索引条件下推
只是为了减小回表次数,也就是减小读取完整的聚簇索引记录的次数,从而减小IO
操做。而对于聚簇索引
而言不须要回表,它自己就包含着所有的列,也起不到减小IO
操做的做用,因此设计InnoDB
的大叔们规定这个索引条件下推
特性只适用于二级索引
。也就是说在本例中与被使用索引有关的条件是:number <= 8
,而number
列又是聚簇索引列,因此本例中并无符合索引条件下推
的查询条件,天然也就不须要判断该记录是否符合索引条件下推
中的条件。
判断一下该记录是否符合范围查询的边界条件
由于在本例中是利用主键number
进行范围查询,设计InnoDB
的大叔规定每从聚簇索引中取出一条记录时都要判断一下该记录是否符合范围查询的边界条件,也就是number <= 8
这个条件。若是符合的话将其返回给server层
继续处理,不然的话须要释放掉在该记录上加的锁,并给server层
返回一个查询完毕的信息。
对于number
值为1
的记录是符合这个条件的,因此会将其返回到server层
继续处理。
将该记录返回到server层
继续判断。
server层
若是收到存储引擎层提供的查询完毕的信息,就结束查询,不然继续判断那些没有进行索引条件下推
的条件,在本例中就是继续判断number <= 8
这个条件是否成立。噫,不是在第3步中已经判断过了么,怎么在这又判断一回?是的,设计InnoDB
的大叔采用的策略就是这么简单粗暴,把凡是没有通过索引条件下推
的条件都须要放到server
层再判断一遍。若是该记录符合剩余的条件(没有进行索引条件下推
的条件),那么就把它发送给客户端,否则的话须要释放掉在该记录上加的锁。
而后刚刚查询获得的这条记录(也就是number
值为1
的记录)组成的单向链表继续向后查找,获得了number
值为3
的记录,而后重复第2
,3
,4
、5
这几个步骤。
小贴士: 上述步骤是在MySQL 5.7.21这个版本中验证的,不保证其余版本有无出入。
可是这个过程有个问题,就是当找到number
值为8
的那条记录的时候,还得向后找一条记录(也就是number
值为15
的记录),在存储引擎读取这条记录的时候,也就是上述的第1
步中,就得为这条记录加锁,而后在第3步时,判断该记录不符合number <= 8
这个条件,又要释放掉这条记录的锁,这个过程致使number
值为15
的记录先被加锁,而后把锁释放掉,过程就是这样:
这个过程有意思的一点就是,若是你先在事务T1
中执行:
# 事务T1
BEGIN;
SELECT * FROM hero WHERE number <= 8 LOCK IN SHARE MODE;
复制代码
而后再到事务T2
中执行:
# 事务T2
BEGIN;
SELECT * FROM hero WHERE number = 15 FOR UPDATE;
复制代码
是没有问题的,由于在T2
执行时,事务T1
已经释放掉了number
值为15
的记录的锁,可是若是你先执行T2
,再执行T1
,因为T2
已经持有了number
值为15
的记录的锁,事务T1
将由于获取不到这个锁而等待。
咱们再看一个使用主键进行范围查询的例子:
SELECT * FROM hero WHERE number >= 8 LOCK IN SHARE MODE;
复制代码
这个语句的执行过程其实和咱们举的上一个例子相似。也是先到聚簇索引中定位到知足number >= 8
这个条件的第一条记录,也就是number
值为8
的记录,而后就能够沿着由记录组成的单向链表一路向后找,每找到一条记录,就会为其加上锁,而后判断该记录符不符合范围查询的边界条件,不过这里的边界条件比较特殊:number >= 8
,只要记录不小于8就算符合边界条件,因此判断和没判断是同样同样的。最后把这条记录返回给server层
,server层
再判断number >= 8
这个条件是否成立,若是成立的话就发送给客户端,不然的话就结束查询。不过InnoDB
存储引擎找到索引中的最后一条记录,也就是Supremum
伪记录以后,在存储引擎内部就能够当即判断这是一条伪记录,没必要要返回给server层
处理,也不必给它也加上锁(也就是说在第1步中就压根儿没给这条记录加锁)。整个过程会给number
值为8
、15
、20
这三条记录加上S型正经记录锁
,画个图表示一下就是这样:
使用SELECT ... FOR UPDATE
语句来为记录加锁:
和SELECT ... FOR UPDATE
语句相似,只不过加的是X型正经记录锁
。
使用UPDATE ...
来为记录加锁,比方说:
UPDATE hero SET country = '汉' WHERE number >= 8;
复制代码
这条UPDATE
语句并无更新二级索引列,加锁方式和上边所说的SELECT ... FOR UPDATE
语句一致。
若是UPDATE
语句中更新了二级索引列,比方说:
UPDATE hero SET name = 'cao曹操' WHERE number >= 8;
复制代码
这时候会首先更新聚簇索引记录,再更新对应的二级索引记录,因此加锁的步骤就是:
为number
值为8
的聚簇索引记录加上X型正经记录锁
。
而后为上一步中的记录索引记录对应的idx_name
二级索引记录加上X型正经记录锁
。
为number
值为15
的聚簇索引记录加上X型正经记录锁
。
而后为上一步中的记录索引记录对应的idx_name
二级索引记录加上X型正经记录锁
。
为number
值为20
的聚簇索引记录加上X型正经记录锁
。
而后为上一步中的记录索引记录对应的idx_name
二级索引记录加上X型正经记录锁
。
画个图就是这样:
若是是下边这个语句:
UPDATE hero SET country = '汉' WHERE number <= 8;
复制代码
则会对number
值为1
、3
、8
聚簇索引记录以及它们对应的二级索引记录加X型正经记录锁
,加锁顺序和上边语句中的加锁顺序相似,都是先对一条聚簇索引记录加锁后,再给对应的二级索引记录加锁。以后会继续对number
值为15
的聚簇索引记录加锁,可是随后InnoDB
存储引擎判断它不符合边界条件,随即会释放掉该聚簇索引记录上的锁(注意这个过程当中没有对number
值为15
的聚簇索引记录对应的二级索引记录加锁)。具体示意图就不画了。
使用DELETE ...
来为记录加锁,比方说:
DELETE FROM hero WHERE number >= 8;
复制代码
和
DELETE FROM hero WHERE number <= 8;
复制代码
这两个语句的加锁状况和更新带有二级索引列的UPDATE
语句一致,就不画图了。
小贴士: 在READ UNCOMMITTED和READ COMMITTED隔离级别下,使用普通的二级索引和惟一二级索引进行加锁的过程是同样的,因此咱们也就不分开讨论了。
使用SELECT ... LOCK IN SHARE MODE
来为记录加锁,比方说:
SELECT * FROM hero WHERE name = 'c曹操' LOCK IN SHARE MODE;
复制代码
这个语句的执行过程是先经过二级索引idx_name
定位到知足name = 'c曹操'
条件的二级索引记录,而后进行回表操做。因此先要对二级索引记录加S型正经记录锁
,而后再给对应的聚簇索引记录加S型正经记录锁
,示意图以下:
先对name
列为'c曹操'
二级索引记录进行加锁。
再对相应的聚簇索引记录进行加锁。
小贴士: 咱们知道idx_name是一个普通的二级索引,到idx_name索引中定位到知足name= 'c曹操'这个条件的第一条记录后,就能够沿着这条记录一路向后找。但是从咱们上边的描述中能够看出来,并无对下一条二级索引记录进行加锁,这是为何呢?这是由于设计InnoDB的大叔对等值匹配的条件有特殊处理,他们规定在InnoDB存储引擎层查找到当前记录的下一条记录时,在对其加锁前就直接判断该记录是否知足等值匹配的条件,若是不知足直接返回(也就是不加锁了),不然的话须要将其加锁后再返回给server层。因此这里也就不须要对下一条二级索引记录进行加锁了。
如今要介绍一个很是有趣的事情,咱们假设上边这个语句在事务T1
中运行,而后事务T2
中运行下边一个咱们以前介绍过的语句:
UPDATE hero SET name = '曹操' WHERE number = 8;
复制代码
这两个语句都是要对number
值为8
的聚簇索引记录和对应的二级索引记录加锁,可是不一样点是加锁的顺序不同。这个UPDATE
语句是先对聚簇索引记录进行加锁,后对二级索引记录进行加锁,若是在不一样事务中运行上述两个语句,可能发生一种贼奇妙的事情 ——
事务T1
持有了聚簇索引记录的锁,事务T2
持有了二级索引记录的锁。
事务T1
在等待获取二级索引记录上的锁,事务T2
在等待获取聚簇索引记录上的锁。
两个事务都分别持有一个锁,并且都在等待对方已经持有的那个锁,这种状况就是所谓的死锁
,两个事务都没法运行下去,必须选择一个进行回滚,对性能影响比较大。
使用SELECT ... FOR UPDATE
语句时,好比:
SELECT * FROM hero WHERE name = 'c曹操' FOR UPDATE;
复制代码
这种状况下与SELECT ... LOCK IN SHARE MODE
语句的加锁状况相似,都是给访问到的二级索引记录和对应的聚簇索引记录加锁,只不过加的是X型正经记录锁
罢了。
使用UPDATE ...
来为记录加锁,比方说:
与更新二级索引记录的SELECT ... FOR UPDATE
的加锁状况相似,不过若是被更新的列中还有别的二级索引列的话,对应的二级索引记录也会被加锁。
使用DELETE ...
来为记录加锁,比方说:
与SELECT ... FOR UPDATE
的加锁状况相似,不过若是表中还有别的二级索引列的话,对应的二级索引记录也会被加锁。
使用SELECT ... LOCK IN SHARE MODE
来为记录加锁,比方说:
SELECT * FROM hero FORCE INDEX(idx_name) WHERE name >= 'c曹操' LOCK IN SHARE MODE;
复制代码
小贴士: 由于优化器会计算使用二级索引进行查询的成本,在成本较大时可能选择以全表扫描的方式来执行查询,因此咱们这里使用FORCE INDEX(idx_name)来强制使用二级索引idx_name来执行查询。
这个语句的执行过程实际上是先到二级索引中定位到知足name >= 'c曹操'
的第一条记录,也就是name
值为c曹操
的记录,而后就能够沿着这条记录的链表一路向后找,从二级索引idx_name
的示意图中能够看出,全部的用户记录都知足name >= 'c曹操'
的这个条件,因此全部的二级索引记录都会被加S型正经记录锁
,它们对应的聚簇索引记录也会被加S型正经记录锁
。不过须要注意一下加锁顺序,对一条二级索引记录加锁完后,会接着对它相应的聚簇索引记录加锁,完后才会对下一条二级索引记录进行加锁,以此类推~ 画个图表示一下就是这样:
再来看下边这个语句:
SELECT * FROM hero FORCE INDEX(idx_name) WHERE name <= 'c曹操' LOCK IN SHARE MODE;
复制代码
这个语句的加锁状况就有点儿有趣了。前边说在使用number <= 8
这个条件的语句中,须要把number
值为15
的记录也加一个锁,以后又判断它不符合边界条件而把锁释放掉。而对于查询条件name <= 'c曹操'
的语句来讲,执行该语句须要使用到二级索引,而与二级索引相关的条件是可使用索引条件下推
这个特性的。设计InnoDB
的大叔规定,若是一条记录不符合索引条件下推
中的条件的话,直接跳到下一条记录(这个过程根本不将其返回到server层
),若是这已是最后一条记录,那么直接向server层
报告查询完毕。可是这里头有个问题呀:先对一条记录加了锁,而后再判断该记录是否是符合索引条件下推的条件,若是不符合直接跳到下一条记录或者直接向server层报告查询完毕,这个过程当中并无把那条被加锁的记录上的锁释放掉呀!!!。本例中使用的查询条件是name <= 'c曹操'
,在为name
值为'c曹操'
的二级索引记录以及它对应的聚簇索引加锁以后,会接着二级索引中的下一条记录,也就是name
值为'l刘备'
的那条二级索引记录,因为该记录不符合索引条件下推
的条件,并且是范围查询的最后一条记录,会直接向server层
报告查询完毕,重点是这个过程当中并不会释放name
值为'l刘备'
的二级索引记录上的锁,也就致使了语句执行完毕时的加锁状况以下所示:
这样子会形成一个尴尬状况,假如T1
执行了上述语句而且还没有提交,T2
再执行这个语句:
SELECT * FROM hero WHERE name = 'l刘备' FOR UPDATE;
复制代码
T2
中的语句须要获取name
值为l刘备
的二级索引记录上的X型正经记录锁
,而T1
中仍然持有name
值为l刘备
的二级索引记录上的S型正经记录锁
,这就形成了T2
获取不到锁而进入等待状态。
小贴士: 为啥不能释放不符合索引条件下推中的条件的二级索引记录上的锁呢?这个问题我也没想明白,人家就是这么规定的,若是有明白的小伙伴能够加我微信 xiaohaizi4919 来讨论一下哈~ 再强调一下,我使用的MySQL版本是5.7.21,不保证其余版本中的加锁情景是否彻底一致。
使用SELECT ... FOR UPDATE
语句时:
和SELECT ... FOR UPDATE
语句相似,只不过加的是X型正经记录锁
。
使用UPDATE ...
来为记录加锁,比方说:
UPDATE hero SET country = '汉' WHERE name >= 'c曹操';
复制代码
小贴士: FORCE INDEX只对SELECT语句起做用,UPDATE语句虽然支持该语法,但实质上不起做用,DELETE语句压根儿不支持该语法。
假设该语句执行时使用了idx_name
二级索引来进行锁定读
,那么它的加锁方式和上边所说的SELECT ... FOR UPDATE
语句一致。若是有其余二级索引列也被更新,那么也会为对应的二级索引记录进行加锁,就不赘述了。不过还有一个有趣的状况,比方说:
UPDATE hero SET country = '汉' WHERE name <= 'c曹操';
复制代码
咱们前边说的索引条件下推
这个特性只适用于SELECT
语句,也就是说UPDATE
语句中没法使用,那么这个语句就会为name
值为'c曹操'
和'l刘备'
的二级索引记录以及它们对应的聚簇索引进行加锁,以后在判断边界条件时发现name
值为'l刘备'
的二级索引记录不符合name <= 'c曹操'
条件,再把该二级索引记录和对应的聚簇索引记录上的锁释放掉。这个过程以下图所示:
使用DELETE ...
来为记录加锁,比方说:
DELETE FROM hero WHERE name >= 'c曹操';
复制代码
和
DELETE FROM hero WHERE name <= 'c曹操';
复制代码
若是这两个语句采用二级索引来进行锁定读
,那么它们的加锁状况和更新带有二级索引列的UPDATE
语句一致,就不画图了。
比方说:
SELECT * FROM hero WHERE country = '魏' LOCK IN SHARE MODE;
复制代码
因为country
列上未建索引,因此只能采用全表扫描的方式来执行这条查询语句,存储引擎每读取一条聚簇索引记录,就会为这条记录加锁一个S型正常记录锁
,而后返回给server层
,若是server层
判断country = '魏'
这个条件是否成立,若是成立则将其发送给客户端,不然会释放掉该记录上的锁,画个图就像这样:
使用SELECT ... FOR UPDATE
进行加锁的状况与上边相似,只不过加的是X型正经记录锁
,就不赘述了。
对于UPDATE ...
和DELETE ...
的语句来讲,在遍历聚簇索引中的记录,都会为该聚簇索引记录加上X型正经记录锁
,而后:
若是该聚簇索引记录不知足条件,直接把该记录上的锁释放掉。
若是该聚簇索引记录知足条件,则会对相应的二级索引记录加上X型正经记录锁
(DELETE
语句会对全部二级索引列加锁,UPDATE
语句只会为更新的二级索引列对应的二级索引记录加锁)。
其余隔离级别下,以及关于INSERT语句的加锁状况请关注公众号“咱们都是小青蛙”,输入“锁”查看剩余文章。
觉着有帮助的话帮着转发一下哈~