(二)并发篇:锁是如何解决脏读,不可重复读,幻读的?
1. 全局锁介绍
2. 表级别锁介绍(表锁、MDL锁、意向锁)
3. 行级别锁介绍及案例统计(间隙锁、记录锁、临键锁)
4. 死锁分析和统计
前文回顾
在并发篇一中,我们提到了事务的特性**,以及事务的并发问题和隔离级别,以及快照读和当前读的区别;重点解释了快照读是如何避免安全问题的(通过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,按照如下记录,并执行,肯定会造成主从数据不一致的问题
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语句如下:
select * from user where name = "test" for update;
按照加锁规则,执行顺序如下:
加锁的范围如下图:
二、主键索引的等值查询
执行的sql语句如下:
select * from user where id = 5 for update;
按照加锁规则,该sql的执行顺序如下:
假设存在多个事务并发操作,如下图:
紧接着,执行另一条主键等值查询的sql语句如下:
select * from user where id = 7 for update;
id = 7这条记录不存在,该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)来说,属于主键索引的范围查询,加锁的执行顺序如下:
假设存在多个事务并发操作,如下图:
四、普通索引的等值查询
执行两条sql语句如下,查询语句相同,但是加的锁不同,sql(1)是S锁,sql(2)是X锁
select id from user where age = 5 lock in share mode;
select id from user where age = 5 for update;
对于sql(1)、(2)来说,属于普通索引的等值查询,加锁的执行顺序如下:
- 对于sql(1)来说,上的是S锁,不会去锁id = 5的数据(不会访问);
- 对于sql(2)来说,上的是X锁,会顺便去锁数据对应的主键,即id = 5。
假设存在多个事务并发操作,如下图:
五、普通索引的范围查询
执行的sql语句如下:
select * from user where age >= 10 and age < 11 for update;
对于上面的sql来说,属于普通索引的范围查询,加锁的执行顺序如下:
假设存在多个事务并发操作,如下图:
六、普通索引(存在等值)的等值查询
执行的sql语句如下:
select * from user where age = 10 for update;
对于上面的sql来说,仍属于普通索引的等值查询,但表中有多条age = 10的记录,(临时补充一条id = 20, age = 10的数据),加锁的执行顺序如下:
假设存在多个事务并发操作,如下图:
七、limit语句的加锁
在上述的基础上,加了limit限制条件,执行的sql语句如下:
select * from user where age = 10 limit 2 for update;
除开limit来看sql,仍属于普通索引的等值查询,加锁的执行顺序如下:
假设存在多个事务并发操作,如下图:
八、order by语句的加锁
加了order by条件,执行的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)存在多个事务并发操作,如下图:
假设sql(2)存在多个事务并发操作,如下图:
九、MySQL5.7的加锁BUG案例
执行的sql语句如下:
select * from user where id > 10 and id <= 15 for update;
唯一索引的范围查询会访问到不满足条件的第一个值为止,这句话该如何理解呢?
先看看正常分析,上述的sql,上锁的顺序如下:
假设sql存在多个事务并发操作,如下图:
十、锁范围突然变化
在同一个事务内,原先加锁的范围可能是会受到其他事务干扰的,如下:
存在两个Session并发执行,执行顺序如下:
由update语句导致的变化,再看下面的例子:
十一、由间隙锁引起的死锁案例
首先我们要清楚一点,间隙锁的目的是什么? 间隙锁是为了防止别的事务在某个区间插入数据的,锁的是区间,不是具体的数据,主打一个预防的作用。因此,不同事务间的间隙锁是不互斥的。(ps: 记录锁互斥)
由于间隙锁不互斥的特点,有可能会引发一些死锁的情况。存在两个Session并发执行,执行顺序如下:
- 执行update age = 10,需要先做当前读,即同样需要在age索引上(5, 10]的临键锁,(10, 15)的间隙锁
- 上临键锁的操作非原子性,需要分为两步:1. 先上(5, 10)间隙锁;2. 上age = 10的记录锁
- 在上间隙锁的时候,由于间隙锁不具备互斥性,可顺利加上。而记录锁互斥,此刻会被阻塞住。间隙锁成功,记录锁失败
Session A被Session B的间隙锁(5, 10)困住;Session B被Session A的记录锁age = 10困住。死锁产生!
十二、加锁导致的慢查询案例
沿用原有的user表,数据一共就五条,在什么场景下,会导致慢查询?
场景如下,假设有两个事务并发执行
插入时加锁详解
MySQL在执行insert语句时,是不会加显示锁的。只有在发生冲突了,才会加上显示锁(S或X)。当执行一条insert语句时,存在两种情况插入失败
- 插入的位置有间隙锁,为了防止幻读出现,阻塞。
- 出现主键冲突或者唯一键冲突
具体的插入失败案例如下:
插入时被间隙锁挡住
存在主键冲突-1
在发生阻塞时,MySQL5.7版本可通过如下命令查看当前事务的锁信息
select * from information_schema.innodb_locksG;
可以看到,Session A持有(5, 10]的S型临键锁,而Session B被(5, 10)的间隙锁挡住
存在主键冲突-2
可以看到,Session A持有(25, 30]的X型临键锁(发生了锁升级),而Session B需要申请id = 30的S型锁
死锁的检验
普通事务阻塞时:
当事务发生阻塞时,在MySQL5.7版本,可用如下两条sql来具体分析:
select * from information_schema.innodb_locksG;
select * from information_schema.innodb_locks_waitsG;
对于sql(1),执行结果如下:
对于sql(2),执行结果如下:
并且,当执行的sql发生了阻塞现象,通过如下命令查看连接中的线程
show processlist;
执行结果如下:
如上图情况,如果碰到线程阻塞的情况,可以直接通过暴力方法: kill query thread_id,来直接强制终止该sql。也可以直接通过:kill thread_id 直接杀死线程
MySQL8.0中,不同于5.7版本,将原先的information_schema库中的innodb_lock_waits和innodb_locks表做了废除,用以下来替代:
select * from performance_schema.data_lock_waits;
select * from performance_schema.data_locks;
在通过上述两条sql,也可查询到阻塞时加的锁信息,具体信息大同小异
出现死锁情况时:
如果我们平日里在使用数据库时出现了死锁,此刻MySQL对于死锁是有特殊处理的,会自动开启死锁检验。如果系统检测出了死锁,会自动回滚死锁中的某个事务
由innodb_deadlock_detect决定,默认为ON,可自行修改
虽然MySQL会帮我们处理掉死锁的情况,但我们也需要了解一些分析死锁的基本问题,才能主动避免掉死锁。
当出现两个线程互相等待锁时,即死锁情况。可通过如下命令查看锁情况
show engine innodb statusG;
该命令的作用是:查看innodb引擎当前的状态。如果发生死锁现象,可通过LATEST DETECTED DEADLOCK这一列查看具体的死锁信息
针对上述的死锁案例,我们知道Session A和Session B互相被对方的锁阻挡,新开了一个Session C,来查看系统检测到的死锁情况,流程图如下:
LATEST DETECTED DEADLOCK的结果如下,Session B和C的事务情况分别如下:
从结果可看出,详细记录了导致线程阻塞的sql,锁信息,锁类型,具体值
最后是处理结果,如下:系统回滚了事务(1),即Session B,原因是此刻的Session A占有的锁资源更多,回滚成本更大。因此系统选择回滚成本小的事务