(二)并发篇:锁是如何解决脏读,不可重复读,幻读的?
-
全局锁介绍
-
表级别锁介绍(表锁、MDL锁、意向锁)
-
行级别锁介绍及案例统计(间隙锁、记录锁、临键锁)
-
死锁分析和统计
前文回顾
在并发篇一中,我们提到了 事务的特性**,以及事务的并发问题和隔离级别 ,以及快照读和当前读的区别 ;重点解释了快照读是如何避免安全问题的(通过MVCC) ;接下来,本章将围绕着当前读的安全问题应该如何避免!也就是加锁的机制
从粒度上划分锁,共有全局锁(锁数据库),表级别锁(锁表),行级别锁(只锁行数据) 。其中,表级别锁有写锁,读锁,MDL锁,意向锁,插入自增锁 等;行级别的锁有间隙锁,记录锁,临键锁、插入意向锁等。下面将从粗到细的粒度开始聊起。
全局锁
顾名思义,当执行全局锁时,整个数据库都会被锁住
执行命令: flush tables with read lock; 整个数据库都只处于只读状态,所有的增删改操作都会被阻塞,如下:在执行完锁库后,左边的客户端执行update发生阻塞。
表级别锁
表级别的锁分为普通的表锁 ,(读锁,写锁);MDL锁 (元数据锁,分为读锁和写锁);意向锁(分为意向共享(IS)锁和意向独占(IX)锁)
如果是第一次接触的话,要先理解什么是读锁,什么是写锁?为什么好好的锁要这么分,还有独占锁和共享锁又是什么,乱七八糟的。
其实在并发当中,读和写是一个很重要的概念;我们对数据库的数据操作无非就两件事,一是读,二是改 ,如果整个数据库都不存在写操作 的话,那根本不会有什么并发的问题(数据都至始至终都没有变,何来脏读,不可重复读,幻读?)
有了上面的概念,我们将锁区分为读锁和写锁。具体区分如下:
- 因为读与读之间不会影响 到数据的安全性。MySQL让读锁和读锁之间可以共享,即读读共享
- 在读的时候如果有写操作乱入 ,则有可能会影响到数据的安全性。因此,MySQL让读锁和写锁互斥,有读无写,有写无读,读写互斥
- 如果两个事务并发在写同一条数据 ,也有可能造成并发问题。因此,写锁和写锁之间也不能共存,即写写互斥
普通表锁
加锁形式为, 读(S) :lock tables ... read; 写(X):lock tables ... write;
普通的表锁粒度大,完全不建议使用。一旦加了读锁,即使是本线程要执行写操作,也会提示失败,如下:
释放锁的命令为: unlock tables;
元数据锁(MDL)
元数据锁是一种隐式锁 ,不会显示的调用 ,对表做操作的时候会自动上锁,也分为MDL读锁和MDL写锁,满足读写互斥,读读共享,写写互斥。
MDL写锁:对表执行DDL语句时会加上(修改表结构)
MDL读锁:对表执行DML语句时会加上(普通的增删改查)
下面举一个MDL锁的例子,它也有可能会把自己弄出死锁来
一、在T2和T3时刻,两个不同的Session分别执行了一条普通的select语句 ,分别加上了MDL读锁 ,Session A的事务没有提交 ,读锁保留 ;而Session B已提交,读锁释放。
二、在T4时刻,Session C执行了一条DDL语句,给该表加上了MDL写锁 ,但由于前面已经有MDL读锁 ,又有读写互斥 的特点。此刻Session C将发生阻塞
三、在T5时刻,新的Session D执行了一条MDL语句,给该表又加上了MDL读锁 ,但前面已经有Session C加的写锁 ,读写互斥。此刻Session D也会被阻塞
此刻死锁的事情即发生了!后续任何一个Session无论执行的是DML 语句还是DDL 语句,前面有写锁 ,这个表将完全不可读写!直到Session A将锁释放
意向锁
意向锁分为意向读锁和意向写锁 ,也称意向共享(IS)锁 和意向独占(IX)锁 。不是像其他锁那样,起到占有资源的作用,下面听我说来:
对于意向锁,我们重点关注它的加锁过程
一、 执行增删改 操作的时候,会先对表加上意向独占锁 ,紧接着对表中的记录加独占锁(给记录上行锁)
二、 执行普通的select语句时,不会加意向锁 。但执行select ... for update 时,会对表加上意向独占锁 ,紧接着对行记录上独占锁 ;执行select ... lock in share mode时。
意向锁的特点:
- 意向锁不会和行锁发生冲突 ,并且意向锁之间也可以共享 ,意向锁只会和普通的表级锁发生冲突。
- 意向共享锁和普通表读锁共享,和普通表写锁互斥,满足IS、S共享,IS、X互斥。
- 意向独占锁和普通表的读锁和写锁都互斥,满足IX、S互斥,IX、X互斥
意向锁存在的意义是啥?
从他的加锁过程 来看,先上意向锁,再上实锁。 ,起到一个 "此路有人" 的作用
如果有意向锁的存在 ,在表锁即将 对一张表上锁时,他就不用去遍历这张表是否有存在行锁 ,直接看他有没有意向锁 就完事了,节约 不必要的遍历开销 。起到快速判断表里是否有锁记录的作用。
如果没有意向锁的话,在表锁要做上锁操作的时候,需要去遍历全表,存在不必要的开销。
插入自增锁(INC-AUTO)
插入自增锁的作用域 :在表的主键是自增的条件下,执行insert操作
要理解插入自增锁,我们需要仔细剖析下insert操作:
- 当执行一条普通的insert语句(不带主键),表需要为这条数据赋予主键(为当前表的主键+1)
- 表需要先去执行申请主键的操作 ,在执行该操作,需要对其加上插入自增锁
- 在加上插入自增锁后,表无法再为其他数据申请主键
- 申请主键成功,释放锁。(不会等到语句执行完才释放,成功申请主键后立马释放)
如果是执行insert ... select这样的语句;插入自增锁会等到语句执行完才会释放,无法做到申请成功后立即释放
如果insert ... select也做到了申请主键后释放呢?情景如下:
一、在T1时刻,Session A往表t中插入了4条数据
二、在T2时刻,Session B参考表t,拷贝了一张表t2
三、在T3时刻,Session B执行insert ... select语句将表t的数据拷贝到表t2。与此同时,Session A往表t2又插入一条数据。
- 表t2刚拷贝了两条数据(1,1,1)(2,2,2)
- Session A往表t2插入了一条数据(3,5,5)
- 表t2继续拷贝完剩下的数据(4,3,3)(5,4,4)
后续在做主从复制的时候,如果binlog_format = statement,由于binlog只会记录某张表的更新的sql,按照如下记录,并执行,肯定会造成主从数据不一致的问题
sql
insert into t2 select c, d from t;
insert into t2 values (null, 5, 5);
因此,才要求在执行insert ... select时需要等执行完语句才释放插入自增锁
但其实MySQL也提供了可配置项,控制释放插入自增锁的时机,即:
当innodb_autoinc_lock_mode = 0 时,所有的insert语句都只能在执行完语句后释放锁(性能最差)
当innodb_autoinc_lock_mode = 1 时,普通的insert语句在申请完主键后释放锁 ,而insert ... select语句需要等语句执行完后才会释放锁(MySQL默认配置)
当innodb_autoinc_lock_mode = 2 时,所有的insert语句在执行完语句后会释放锁(性能最好,但有数据不一致风险)
如果为了追求insert语句的性能 ,可以将主从备份的binlog_format设置为row,将完整的数据写到binlog中 ,而非只写sql(statement);再将innodb_autoinc_lock_mode设置为2。
行级别锁
行锁才是我们这次的重点~~ 行锁又可细分分为记录锁、间隙锁、临键锁(记录锁+间隙锁)、插入意向锁 ,在使用InnoDB引擎时,加锁的时机呢 ?这些锁又该怎么区分 呢?加锁有没有什么规则?
什么时候会加行锁?
-
情况一:执行增删改语句的时候,update insert delete;普通的select不会加锁
-
情况二:执行select ... for update 查询语句 会加独占锁;执行select ... lock in share mode查询语句会加共享锁
记录锁、间隙锁、临键锁的特点
行级别锁的加锁对象是索引 ,如果一张表里没有索引 的话,就没有行锁 的说法。直接升级为表锁(锁全表)
记录锁 : 一把记录锁只会锁住表里的一条行数据
间隙锁 : 锁住两条记录之间的间隙 ,(不会锁已存在表里的数据),防止幻读,往两条数据之间插入数据。
临键锁 :锁住一段间隙 + 行数据 ,(连续的)为 (.., ..] ,左开右闭的原则
插入意向锁 : 在执行insert语句时,会先判断当前位置是否有被上锁,有则生成插入意向锁,处于等待、阻塞状态
加行锁的规则
行锁只会锁住有访问的索引 ,并且上锁的基本单位为临键锁 ,但粒度有时会较大, 因此MySQL还存在以下优化原则:
- 如果是主键、唯一键的等值查询,临键锁会退化为记录锁
- 如果是索引的等值查询,从左到右遍历时,如果最后一个值不满足条件,会退化为间隙锁
与此同时,在MySQL5.7版本中,还存在一个bug,(后续版本已经被修复)
- 唯一索引的范围查询会访问到不满足条件的第一个值为止
MySQL加行锁案例详解
本文的重点来了! 会详细剖析在各种情况下,MySQL是如何对数据上行锁的,以下为后续案例的原始数据,场景如下:
- MySQL的版本:5.7
- MySQL的隔离级别:重复读(只有重复读才存在间隙锁和记录锁)
- user表,表中有id(主键),name,age(普通索引),male四个字段
初始数据如下:
案例如下:
一、非索引查询
执行的sql语句如下:
sql
select * from user where name = "test" for update;
按照加锁规则 ,执行顺序如下:
- name为非索引字段 ,查找的时候必然是全表扫描 ,为了保证不出现并发问题 ,会把所有的记录都锁住 ,等于行锁直接退化为表锁。
- 关于该表的增删改操作,均会阻塞,直到上锁的事务提交。
加锁的范围如下图:
二、主键索引的等值查询
执行的sql语句如下:
sql
select * from user where id = 5 for update;
按照加锁规则 ,该sql的执行顺序如下:
- id为主键,要筛选出id = 5的行数据,会访问主键索引树,找到id = 5的行数据
- 加锁的单位为临键锁 ,因此会加上(1, 5]临键锁
- 根据优化规则,该查询为主键等值查询 ,并且id = 5的记录存在 ,因此临键锁会退化为记录锁 ,最终加锁的范围为id = 5。
假设存在多个事务并发操作,如下图:
- 在T2时刻,由上文分析,我们已知Session A会给id = 5的记录加上锁
- 在T3时刻,Session B执行的关于id = 5的update语句,会发生阻塞
- 在T4时刻,Session C执行的关于id = 3的update语句,不会阻塞 。表明临键锁退化为记录锁
紧接着,执行另一条主键等值查询的sql语句如下:
sql
select * from user where id = 7 for update;
id = 7这条记录不存在 ,该sql的执行顺序如下:
- 由于id = 7的记录不存在。于是,落到了(5, 10)这个区间上。
- 由于加锁的单位是临键锁,会向右遍历,找到右边第第一个不符合条件的值,即id = 10,加上(5, 10]的临键锁
- 根据优化规则,索引等值查询的向右遍历,id = 10不符合条件,因此临键锁会退化为间隙锁 ,最终加锁的范围为 (5, 10)区间
假设存在多个事务并发操作,如下图:
- 在T2时刻,由上文分析,我们已知Session A会给id ∈ (5, 10)的记录加上间隙锁
- 在T3时刻,Session B执行的insert id = 7的语句,由于别的事务在这个区间上锁了,会发生阻塞
- 在T4时刻,Session C执行的关于id = 10的update语句,不会阻塞 。表明临键锁退化为间隙锁
三、主键索引的范围查询
执行两条sql语句如下,虽然返回的结果相同 ,但加的锁却完全不同!
sql
select * from user where id = 10 for update;
select * from user where id >=10 and id < 11 for update;
按照加锁规则,sql(1) 为值存在的主键索引等值查询,因此只会锁住id = 10这条记录
对于sql(2)来说,属于主键索引的范围查询 ,加锁的执行顺序如下:
- 由于id >= 10,会先进行等值查询 ,找到id = 10的行数据位置
- 会给id = 10加上临键锁,即(5, 10],但又由于优化规则,会退化为id = 10的记录锁
- 由于是范围查询 ,因此会向右遍历,找到id = 15这条记录 ,加上(10, 15]的临键锁。由于id = 15已经超过筛选范围,结束遍历。
- 最终,该sql的上锁范围为:[10, 15]
假设存在多个事务并发操作,如下图:
- 在T2时刻,由上文分析,我们已知Session A的加锁范围为:id ∈ [10, 15]
- 在T3时刻,Session B执行的insert id = 8的语句,不会被阻塞,但 insert id = 12的语句,由于已经有Session A加的间隙锁,被阻塞住
- 在T4时刻,Session C执行的update id = 15的语句,由于别的事务在id = 15的主键上加锁,会发生阻塞
四、普通索引的等值查询
执行两条sql语句如下,查询语句相同,但是加的锁不同,sql(1)是S锁,sql(2)是X锁
sql
select id from user where age = 5 lock in share mode;
select id from user where age = 5 for update;
对于sql(1)、(2)来说,属于普通索引的等值查询 ,加锁的执行顺序如下:
- 索引的等值查询,会先走age的普通索引树 ,找到age = 5的位置 ,加上临键锁(1, 5]。
- 由于是普通索引,值不具备唯一性,因此会向右遍历,判断是否还有age = 5的数据。
- 找到age = 10的数据,不符合条件。但访问到了,还是会加上临键锁(5, 10] 。由于是索引的等值查询遍历,会退化为间隙锁(5, 10)。
- 只有访问到的对象才会加锁,sql(1)、(2)都用到了覆盖索引,不会回表去用主键索引。
- 对于sql(1)来说,上的是S锁,不会去锁id = 5的数据(不会访问);
- 对于sql(2)来说,上的是X锁,会顺便去锁数据对应的主键,即id = 5。
- 最终,sql(1)的上锁范围为:age ∈ (1, 10) ;sql(2)的上锁范围为:age ∈ (1, 10),id = 5
假设存在多个事务并发操作,如下图:
- 在T2时刻,由上文分析,我们已知Session A的加锁范围为:age ∈ (1, 10)
- 在T3时刻,Session B执行的update id = 5的语句,不会被阻塞 (如果是X锁,就会阻塞)
- 在T4时刻,Session C执行的insert id = 8的语句,由于别的事务在(5, 10)的区间上加了间隙锁,会发生阻塞
五、普通索引的范围查询
执行的sql语句如下:
sql
select * from user where age >= 10 and age < 11 for update;
对于上面的sql来说,属于普通索引的范围查询 ,加锁的执行顺序如下:
- 由于是age >= 10,会先找到age = 10的记录所在位置。通过普通索引树找到age = 10的记录,并加上临键锁(5, 10] 。不同于主键索引的等值查询,这里的普通索引的等值查询并不会让临键锁退化
- 由于是范围查询,因此会向右遍历,找到age < 11的记录。于是找到了age = 15,加上临键锁(10, 15]
- 由于age = 15已经不符合筛选条件,结束遍历 。之后需要回表来补全 除了id和age以外的行数据,因此id = 10的主键记录会被加上记录锁
- 最终,sql的上锁范围为:age ∈ (5, 15], id = 10
假设存在多个事务并发操作,如下图:
- 在T2时刻,由上文分析,我们已知Session A的加锁范围为:age ∈ (5, 15], id = 10
- 在T3和T4时刻,Session B、C执行的update age = 8, 15的语句,由于Session A在(5, 15]的区间上加了锁,会发生阻塞
- 在T4时刻,Session C执行的update id = 10的语句,由于Session A已经给id = 10上了记录锁,会发生阻塞
六、普通索引(存在等值)的等值查询
执行的sql语句如下:
sql
select * from user where age = 10 for update;
对于上面的sql来说,仍属于普通索引的等值查询,但表中有多条age = 10的记录,(临时补充一条id = 20, age = 10的数据),加锁的执行顺序如下:
- 由于是等值查询,会先找到age = 10的记录所在位置。通过普通索引树找到(age = 10, id = 10) 的记录,并加上临键锁(5, 10]。
- 由于是普通列(非唯一)的数据不具备唯一性,右边可能还会有相同的值,需要继续向右遍历查找。
- 向右遍历,找到 (age = 20, id = 20),加上间隙锁
- 继续向右遍历,找到age = 15,不符合条件,由于是索引的等值查询遍历,因此从age的(10, 15]临键锁退化为(10, 15)间隙锁。
- 通过回表补齐数据,访问了两条age = 10的数据,因此主键id = 10, 20均会被加上记录锁。
- 最终,sql的上锁范围为:age ∈ (5, 15), id = 10, 20
假设存在多个事务并发操作,如下图:
- 在T2时刻,由上文分析,我们已知Session A的加锁范围为:age ∈ (5, 15), id = 10, 20
- 在T3时刻,Session B执行的update age = 12的语句,由于Session A在(5, 15)的区间上加了锁,会发生阻塞
- 在T4时刻,Session C执行的update age = 15的语句,由于Session A发生过锁退化,不会发生阻塞
七、limit语句的加锁
在上述的基础上,加了limit限制条件,执行的sql语句如下:
sql
select * from user where age = 10 limit 2 for update;
除开limit来看sql,仍属于普通索引的等值查询,加锁的执行顺序如下:
- 由于是等值查询,会先找到age = 10的记录所在位置。通过普通索引树找到(age = 10, id = 10) 的记录,并加上临键锁(5, 10]。
- 由于是普通列(非唯一)的数据不具备唯一性,右边可能还会有相同的值,需要继续向右遍历查找。
- 向右遍历,找到 (age = 20, id = 20),加上间隙锁
- 由于有limit 2的限制 ,最多只要两条数据即可,因此不会再继续查找下去。(如果是limit 3,就还会继续遍历下去)
- 通过回表补齐数据,访问了两条age = 10的数据,因此主键id = 10, 20均会被加上记录锁。
- 最终,sql的上锁范围为:age ∈ (5, 10], id = 10, 20
假设存在多个事务并发操作,如下图:
- 在T2时刻,由上文分析,我们已知Session A的加锁范围为:age ∈ (5, 10], id = 10, 20
- 在T3时刻,Session B执行的insert age = 12的语句,由于Session A没有访问到age = 15,因此在区间(10, 15)没有上锁,不会发生阻塞
- 在T4时刻,Session C执行的insert age = 8的语句,由于Session A在(5, 10)上锁,会发生阻塞
八、order by语句的加锁
加了order by条件,执行的sql语句如下:
sql
select * from user where id > 9 and id < 12 order by id desc for update;
select * from user where id > 9 and id < 12 order by id asc for update;
同样的查询结果,排序顺序不同,则加的锁范围也会发生变化,上锁的顺序如下:
- 对于sql(1),其为降序排序 ,因此需要从id < 12开始寻址 ,需要找到id = 12的左侧数据,但不存在,落到了间隙(10, 15)上,分析结果同案例二 ,会加上间隙锁(10, 15)。
- 向左遍历,找到id = 10的数据,加上临键锁(5, 10] ,由于id = 10仍然满足 id > 9 ,因此会继续遍历,找到id = 5,加上加上临键锁(1, 5]。(此刻不满足条件,结束遍历)
- 对于sql(2),其为升序排序 ,与sql(1)不同,此刻需要从id > 9开始寻址,需要找到id = 9的右侧数据,会落到间隙(5, 10)上
- 向右遍历,找到id = 10的数据,加上临键锁(5, 10],继续向右遍历,找到id = 15,加上临键锁(10, 15]。(此刻不满足条件,结束遍历)
- 最终,sql(1)的上锁范围为:id ∈ (1, 15] ,sql(2)的上锁范围为:id ∈ (5, 15]
假设sql(1)存在多个事务并发操作,如下图:
- 在T2时刻,由上文分析,我们已知Session A的加锁范围为:id ∈ (1, 15]
- 在T3时刻,Session B执行的update id = 8的语句,由于Session A访问到了id = 10,因此在区间(5, 10)上了锁,发生阻塞
- 在T4时刻,Session C执行的insert id = 3的语句,由于Session A访问到了id = 5,因此在区间(1, 5)上了锁,发生阻塞
假设sql(2)存在多个事务并发操作,如下图:
- 在T2时刻,由上文分析,我们已知Session A的加锁范围为:id ∈ (5, 15]
- 不同的是,Session C执行的insert id = 3的语句,由于Session A没有访问到id = 5,因此没有在区间(1, 5)上锁,未发生阻塞
九、MySQL5.7的加锁BUG案例
执行的sql语句如下:
sql
select * from user where id > 10 and id <= 15 for update;
唯一索引的范围查询会访问到不满足条件的第一个值为止,这句话该如何理解呢?
先看看正常分析,上述的sql,上锁的顺序如下:
- 该sql为主键索引的范围查询 ,从id > 10开始寻址,需要找到id = 10的左侧数据(不包含id = 10,不会访问到),落到了间隙(10, 15)上
- 向右遍历,找到id = 15的数据,加上临键锁(10, 15] ,由于由于主键索引存在唯一性,id = 15的右边已经不会有满足筛选条件的值 ,但 15 ∈ (10, 15]。由于BUG的存在,会继续遍历 ,找到id = 20,加上加上临键锁(15, 20]。(此刻不满足条件,结束遍历)
- 最终,sql的上锁范围为:id ∈ (10, 20]
假设sql存在多个事务并发操作,如下图:
- 在T2时刻,由上文分析,由于存在BUG,Session A的加锁范围为:id ∈ (10, 20]
- 在T3和T4时刻,Session B和C分别执行的update id = 20,insert id = 16的语句,由于Session A访问到了id = 20,因此在区间(15, 20]上了锁,都发生了阻塞
十、锁范围突然变化
在同一个事务内,原先加锁的范围可能是会受到其他事务干扰的,如下:
存在两个Session并发执行,执行顺序如下:
- 在T2时刻,由上文分析的可知,Session A的加锁范围为:id ∈ (10, 20]
- 在T3时刻,Session B执行了delete id = 10的语句,由于Session A上的锁没有到10,因此不会阻塞
- 在删除id = 10后,id = 10的左右两个间隙,(5, 10)和(10, 15)将会合并成(5, 15)。因此,Session A的加锁范围升级为:id ∈ (5, 20]
- 在T4时刻,Session B执行了insert id = 10的语句,由于Session A的锁范围发生了变化,因此会发生阻塞
由update语句导致的变化,再看下面的例子:
- 在T2时刻,Session A执行的是普通索引的范围查询,由于是S锁,且又是覆盖索引查询,因此主键索引树不会加锁 。最终加锁的范围是:age ∈ (5, +∞)
- 在T3时刻,Session B执行的是update语句,update语句在底层被拆成(先插入新的,再删除旧的) 。因此,先插入(id = 5,age = 1),再删除(id = 5,age = 5)。删除age = 5的数据后。Session A的上锁范围会扩大到(1, +∞)。
- 在T4时刻,Session B再执行相反的update语句,等于先插入(id = 5,age = 5),再删除(id = 5,age = 1)。自然在插入的时候,age = 5会被区间(1, +∞)挡住。
十一、由间隙锁引起的死锁案例
首先我们要清楚一点,间隙锁的目的 是什么? 间隙锁是为了防止别的事务在某个区间插入数据的 ,锁的是区间 ,不是具体的数据 ,主打一个预防 的作用。因此,不同事务间的间隙锁是不互斥 的。(ps: 记录锁互斥)
由于间隙锁不互斥的特点,有可能会引发一些死锁的情况。存在两个Session并发执行,执行顺序如下:
- 在T2时刻,Session A执行的是普通索引的等值查询 ,由上文我们可知,会在age索引上(5, 10]的临键锁,(10, 15)的间隙锁
- 在T3时刻,Session B执行了update age = 10的sql,执行update的语句其实分为几步:
- 执行update age = 10,需要先做当前读 ,即同样需要在age索引上(5, 10]的临键锁,(10, 15)的间隙锁
- 上临键锁的操作非原子性,需要分为两步:1. 先上(5, 10)间隙锁;2. 上age = 10的记录锁
- 在上间隙锁的时候,由于间隙锁不具备互斥性 ,可顺利加上。而记录锁互斥,此刻会被阻塞住。间隙锁成功,记录锁失败
- 在T4时刻,Session A执行了insert age = 8的语句 ,由于Session B已经在区间(5, 10)上了间隙锁,因此会发生阻塞
Session A被Session B的间隙锁(5, 10)困住;Session B被Session A的记录锁age = 10困住。死锁产生!
十二、加锁导致的慢查询案例
沿用原有的user表,数据一共就五条,在什么场景下,会导致慢查询?
场景如下,假设有两个事务并发执行
- 在T1时刻,Session A开启事务,并获取Read View(MVCC相关概念)
- 在T2时刻,Session B对id = 1这条数据做了100W次修改 ,新增了100W个版本。
- 在T3时刻,Session A执行普通的select id = 1的语句 ,为快照读 ,需要通过Read View去找到可见的id = 1的版本 。而Session B执行的100W次操作都是不可见 的,需要查找100W次才能找到符合版本的历史数据!
- 在T4时刻,Session A直接执行当前读(直接看数据的最新版本记录),秒出结果!
插入时加锁详解
MySQL在执行insert语句时,是不会加显示锁的。只有在发生冲突了,才会加上显示锁(S或X)。当执行一条insert语句时,存在两种情况插入失败
- 插入的位置有间隙锁,为了防止幻读出现,阻塞。
- 出现主键冲突或者唯一键冲突
具体的插入失败案例如下:
插入时被间隙锁挡住
- 在T2时刻,Session A执行的是普通索引的范围查询,user表中的age上锁区间为:(10, +∞)
- 在T4时刻,Session B执行了insert age = 12的语句 ,会检查各个字段的对应区间是否有上锁,发现了age = 12对应的区间(10, 15)被上了间隙锁。因此,Session B则会在这里加上插入意向锁,处于等待状态
存在主键冲突-1
- 在T2时刻,Session A执行insert id = 10的语句 ,由于数据库已有id = 10的数据,因此插入失败,主键冲突。此时发生了冲突,插入锁升级,需要给id = 10加上S型的临键锁 ,区间(5, 10]会被锁住。如果一个事务执行insert失败,则会给对应的记录加上S型锁,但不会阻塞本事务。
- 在T4时刻,Session B执行insert id = 8的语句,由于Session A已在区间(5, 10]上了锁,因此会发生阻塞。
在发生阻塞时,MySQL5.7版本 可通过如下命令查看当前事务的锁信息
csharp
select * from information_schema.innodb_locks\G;
可以看到,Session A持有(5, 10]的S型临键锁,而Session B被(5, 10)的间隙锁挡住
存在主键冲突-2
- 在T2时刻,Session A执行insert id = 30的语句,数据库没有id = 30的数据,成功插入。上了隐式锁。此刻事务没有提交,隐式锁保留。
- 在T4时刻,Session B同样执行insert id = 30的语句 ,由于Session A已经插入,尚未提交,则原来的隐式锁会直接升级成X型锁 。而Session B想获得S型锁,必然造成锁冲突(X锁和S锁互斥),阻塞。
可以看到,Session A持有(25, 30]的X型临键锁(发生了锁升级),而Session B需要申请id = 30的S型锁
死锁的检验
普通事务阻塞时:
当事务发生阻塞时,在MySQL5.7版本,可用如下两条sql来具体分析:
csharp
select * from information_schema.innodb_locks\G;
select * from information_schema.innodb_locks_waits\G;
对于sql(1),执行结果如下:
对于sql(2),执行结果如下:
并且,当执行的sql发生了阻塞现象,通过如下命令查看连接中的线程
ini
show processlist;
执行结果如下:
如上图情况,如果碰到线程阻塞的情况,可以直接通过暴力方法: kill query thread_id ,来直接强制终止该sql。也可以直接通过:kill thread_id 直接杀死线程
MySQL8.0中,不同于5.7版本,将原先的information_schema库中的innodb_lock_waits和innodb_locks表做了废除,用以下来替代:
csharp
select * from performance_schema.data_lock_waits;
select * from performance_schema.data_locks;
在通过上述两条sql,也可查询到阻塞时加的锁信息,具体信息大同小异
出现死锁情况时:
如果我们平日里在使用数据库时出现了死锁,此刻MySQL对于死锁是有特殊处理的,会自动开启死锁检验 。如果系统检测出了死锁,会自动回滚死锁中的某个事务
由innodb_deadlock_detect决定,默认为ON,可自行修改
虽然MySQL会帮我们处理掉死锁的情况,但我们也需要了解一些分析死锁的基本问题,才能主动避免掉死锁。
当出现两个线程互相等待锁时,即死锁情况。可通过如下命令查看锁情况
ini
show engine innodb status\G;
该命令的作用是:查看innodb引擎当前的状态 。如果发生死锁现象,可通过LATEST DETECTED DEADLOCK这一列查看具体的死锁信息
针对上述的死锁案例,我们知道Session A和Session B互相被对方的锁阻挡,新开了一个Session C,来查看系统检测到的死锁情况,流程图如下:
LATEST DETECTED DEADLOCK的结果如下,Session B和C的事务情况分别如下:
从结果可看出,详细记录了导致线程阻塞的sql,锁信息,锁类型,具体值
最后是处理结果,如下:系统回滚了事务(1),即Session B,原因是此刻的Session A占有的锁资源更多,回滚成本更大。因此系统选择回滚成本小的事务