🔓

 

按锁粒度分

全局锁

全局锁

表级锁

MySQL里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。

普通表锁

表锁的语法是 lock tables … read/write与FTWRL类似,可以用unlock tables主动释放锁,也可以在客户端断开的时候自动释放。需要注意,lock tables语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。
举个例子, 如果在某个线程A中执行lock tables t1 read, t2 write; 这个语句,则其他线程写t1、读写t2的语句都会被阻塞。同时,线程A在执行unlock tables之前,也只能执行读t1、读写t2的操作。连写t1都不允许,自然也不能访问其他表。
在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。而对于InnoDB这种支持行锁的引擎,一般不使用lock tables命令来控制并发,毕竟锁住整个表的影响面还是太大

MDL锁

MDL锁

页级锁

每次锁定相邻的一组记录,锁定粒度界于表锁和行锁之间,开销和加锁时间界于表 锁和行锁之间,并发度一般。应用在BDB 存储引擎中。

行锁

MySQL的行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,比如MyISAM引擎就不支持行锁。不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同一张表上任何时刻只能有一个更新在执行,这就会影响到业务并发度。InnoDB是支持行锁的,这也是MyISAM被InnoDB替代的重要原因之一。
 
顾名思义,行锁就是针对数据表中行记录的锁。这很好理解,比如事务A更新了一行,而这时候事务B也要更新同一行,则必须等事务A的操作完成后才能进行更新。
 

两阶段锁协议

我先给你举个例子。在下面的操作序列中,事务B的update语句执行时会是什么现象呢?假设字段id是表t的主键。
事务B的update语句会被阻塞,直到事务A执行commit之后,事务B才能继续执行。
notion image
知道了这个答案,你一定知道了事务A持有的两个记录的行锁,都是在commit的时候才释放的。
在InnoDB事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议

两阶段锁应用场景

知道了这个设定,对我们使用事务有什么帮助呢?那就是,如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放
假设你负责实现一个电影票在线交易业务,顾客A要在影院B购买电影票。我们简化一点,这个业务需要涉及到以下操作:
  1. 从顾客A账户余额中扣除电影票价;
  1. 给影院B的账户余额增加这张电影票价;
  1. 记录一条交易日志。
也就是说,要完成这个交易,我们需要update两条记录,并insert一条记录。当然,为了保证交易的原子性,我们要把这三个操作放在一个事务中。那么,你会怎样安排这三个语句在事务中的顺序呢?
试想如果同时有另外一个顾客C要在影院B买票,那么这两个事务冲突的部分就是语句2了。因为它们要更新同一个影院账户的余额,需要修改同一行数据。
根据两阶段锁协议,不论你怎样安排语句顺序,所有的操作需要的行锁都是在事务提交的时候才释放的。所以,如果你把语句2安排在最后,比如按照3、1、2这样的顺序,那么影院账户余额这一行的锁时间就最少。这就最大程度地减少了事务之间的锁等待,提升了并发度。

间隙锁(Gap 锁)

顾名思义,间隙锁,锁的就是两个值之间的空隙。比如文章开头的表t,初始化插入了6个记录,这就产生了7个间隙。
表t主键索引上的行锁和间隙锁
表t主键索引上的行锁和间隙锁
这样,当你执行 select * from t where d=5 for update的时候,就不止是给数据库中已有的6个记录加上了行锁,还同时加了7个间隙锁。这样就确保了无法再插入新的记录。
也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。
现在你知道了,数据行是可以加上锁的实体,数据行之间的间隙,也是可以加上锁的实体。但是间隙锁跟我们之前碰到过的锁都不太一样。
比如行锁,分成读锁和写锁。下图就是这两种类型行锁的冲突关系。
两种行锁间的冲突关系
两种行锁间的冲突关系
也就是说,跟行锁有冲突关系的是“另外一个行锁”。
但是间隙锁不一样,跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作。间隙锁之间都不存在冲突关系
这句话不太好理解,我给你举个例子
间隙锁之间不互锁
间隙锁之间不互锁
这里session B并不会被堵住。因为表t里并没有c=7这个记录,因此session A加的是间隙锁(5,10)。而session B也是在这个间隙加的间隙锁。它们有共同的目标,即:保护这个间隙,不允许插入值。但,它们之间是不冲突的

临键锁(next-key lock)

间隙锁和行锁合称next-key lock,每个next-key lock是前开后闭区间当next-key lock加在某索引上,则该记录和它前面的区间都被锁定。也就是说,我们的表t初始化以后,如果用select * from t for update要把整个表所有记录锁起来,就形成了7个next-key lock,分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。
你可能会问说,这个supremum从哪儿来的呢?
这是因为+∞是开区间。实现上,InnoDB给每个索引加了一个不存在的最大值supremum,这样才符合我们前面说的“都是前开后闭区间”。

加锁策略

若没有特殊说明,默认是可重复读隔离级别。
包含了两个“原则”、两个“优化”和一个“bug”。
  1. 原则1:加锁的基本单位是next-key lock。next-key lock是左开右闭区间。
  1. 原则2:查找过程中访问到的对象才会加锁。
  1. 优化1:索引上的等值查询,给唯一索引加锁的时候,next-key lock退化为行锁。
  1. 优化2:索引上的等值查询,向右遍历时最后一个值不满足等值条件的时候,next-key lock退化为间隙锁。
  1. 一个bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

间隙锁死锁问题

间隙锁和next-key lock的引入,帮我们解决了幻读的问题,但同时也带来了一些“困扰”。
业务逻辑这样的:任意锁住一行,如果这一行不存在的话就插入,如果存在这一行就更新它的数据,代码如下:
begin; select * from t where id=N for update; /*如果行不存在*/ insert into t values(N,N,N); /*如果行存在*/ update t set d=N set id=N; commit;
可能你会说,这个不是insert … on duplicate key update 就能解决吗?但其实在有多个唯一键的时候,这个方法是不能满足这位提问同学的需求的。
现在,我们就只讨论这个逻辑。这个逻辑一旦有并发,就会碰到死锁。你一定也觉得奇怪,这个逻辑每次操作前用for update锁起来,已经是最严格的模式了,怎么还会有死锁呢?
这里,我用两个session来模拟并发,并假设N=9。
间隙锁导致的死锁
间隙锁导致的死锁
 
你看到了,其实都不需要用到后面的update语句,就已经形成死锁了。我们按语句执行顺序来分析一下:
  1. session A 执行select … for update语句,由于id=9这一行并不存在,因此会加上间隙锁(5,10);
  1. session B 执行select … for update语句,同样会加上间隙锁(5,10),间隙锁之间不会冲突,因此这个语句可以执行成功;
  1. session B 试图插入一行(9,9,9),被session A的间隙锁挡住了,只好进入等待;
  1. session A试图插入一行(9,9,9),被session B的间隙锁挡住了。
至此,两个session进入互相等待状态,形成死锁。当然,InnoDB的死锁检测马上就发现了这对死锁关系,让session A的insert语句报错返回了。
间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的
你可能会说,为了解决幻读的问题,我们引入了这么一大串内容,有没有更简单一点的处理方法呢。
以上分析的问题都是在可重复读隔离级别下的,间隙锁是在可重复读隔离级别下才会生效的所以,你如果把隔离级别设置为读提交的话,就没有间隙锁了。但同时,你要解决可能出现的数据和日志不一致问题,需要把binlog格式设置为row。这,也是现在不少公司使用的配置组合。
SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCK_WAITS;

间隙锁案例

间隙锁案例

操作性能分

从操作的性能可分为乐观锁和悲观锁。

乐观锁

乐观锁:一般的实现方式是对记录数据版本进行比对,在数据更新提交的时候才会进行冲突检测,如果发现冲突了,则提示错误信息。

悲观锁

悲观锁:在对一条数据修改的时候,为了避免同时被其他人修改,在修改数据之前先锁定,再修改的控制方式。共享锁和排他锁是悲观锁的不同实现,但都属于悲观锁范畴

操作的类型分

读锁

写锁

从操作的类型可分为读锁和写锁。
读锁(S锁):共享锁,针对同一份数据,多个读操作可以同时进行而不会互相影响。
写锁(X锁):排他锁,当前写操作没有完成前,它会阻断其他写锁和读锁。
IS锁、IX锁:意向读锁、意向写锁,属于表级锁,S和X主要针对行级锁。在对表记录添加S或X锁之 前,会先对表添加IS或IX锁。
S锁:事务A对记录添加了S锁,可以对记录进行读操作,不能做修改,其他事务可以对该记录追加S锁,但是不能追加X锁,需要追加X锁,需要等记录的S锁全部释放。
X锁:事务A对记录添加了X锁,可以对记录进行读和修改操作,其他事务不能对记录做读和修改操作。

意向锁作用

由于表锁和行锁虽然锁定范围不同,但是会相互冲突。所以当你要加表锁时,势必要先遍历该表的所有记录,判断是否加有排他锁。这种遍历检查的方式显然是一种低效的方式,MySQL 引入了意向锁,来检测表锁和行锁的冲突。
意向锁也是表级锁,也可分为读意向锁(IS 锁)和写意向锁(IX 锁)。当事务要在记录上加上读锁或写锁时,要首先在表上加上意向锁。这样判断表中是否有记录加锁就很简单了,只要看下表上是否有意向锁就行
意向锁之间是不会产生冲突的,也不和 AUTO_INC 表锁冲突,它只会阻塞表级读锁或表级写锁,另外,意向锁也不会和行锁冲突,行锁只会和行锁冲突。

锁问题排查