MySQL中的锁

Posted 刘俊岐.

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了MySQL中的锁相关的知识,希望对你有一定的参考价值。

粗略的分,我们可以将mysql中的锁分成三类,分别是:

  1. 全局锁
  2. 表级锁
  3. 行级锁

以上这三种锁都非常好理解,下面我依次给大家介绍下

全局锁

顾名思义,全局锁就是对整个数据库的实例加锁,在MySQL中有这样一个可以加全局锁的方法:FLUSH TABLES WITH READ LOCK(FTWRL)

这句语句的作用是将当前数据库实例中所有的表都处于只读状态,我们用到这个这个语句的场景是在我们备份数据的过程中,下面我们举一个例子。

假设我们有两个表分别是表a和表b,分别记录了一个用户的钱包余额以及已购商品列表,当我们需要备份数据的时候,如果不加锁,当我们将表a备份完成后某一个用户进行了购买操作,这时表a会扣减相应的商品余额,表b会增加相应的商品,然后我们再备份表b。然后我们就会发现我们备份出来的表a中这个用户的余额没有减少但是他的已购商品缺增加了,如果我们后续用这个备份来恢复数据的话肯定就出大事了!

所以最原始的解决办法就是通过

FLUSH TABLES WITH READ LOCK

加全局锁,这样备份出来的数据就是安全的了。

但是我们同时可以看到,我们在备份数据的时候,所有的表的更新操作都被阻塞了,即使是在从库,也会造成很大的主从延迟,这显然是不合理的。我们可以想一下为什么会造成这样的情况,我们可以看到,最根本的原因就是我们在备份的时候没拿到两个表中的一致性视图,而我在之前的文章中提到过,我们可以通过事务来拿到一致性的视图,这样就引出了MySQL中另一种备份方法。

MySQL官方有一个自带的逻辑备份工具mysqldump。在msyqldump中提供了-single-transaction参数就提供了一致性视图的能力。在使用这种方法备份的时候,导数据之前会启动一个事务来确保拿到一致性视图。得益于之前说过的MVCC的支持,会使数据在备份的时候可以同步更新。

当然FTWRL并不是完全没有必要,因为-single-transaction只适用于所有的表使用事务引擎的库

表级锁

在MySQL中,表级锁又分为两种,分别是:

  1. 表锁
  2. 元数据锁

表锁

我们使用表锁一般的语法是

lock tables ...  with read/write lock;

我们可以通过unlock tables 语句 或断开会话主动释放锁,当我们使用上述语句锁住一个表后,这个锁也会影响当前线程

元数据锁

另一类的表级锁是元数据锁(MDL),我们不需要显示指定,当我们访问一个表后这个锁就会自动的加上,其作用就是保证我们在读一个表时其他线程不对该表的表结构进行更改。

当我们在对一个表进行增删改查操作的时候,加MDL读锁

当我们对一个表的表结构进行修改的时候,加MDL写锁

这里说一个场景

sessionAsessionBsessionCsessionD
begin;
SELECT * from t;
begin;
SELECT * from t;
alter table t add f int;
SELECT * from t;

这样的操作会让sessionD锁住!原因是sessionC的MDL读锁一直获取不到,之后所有要申请MDL锁的会话全部阻塞!!
后续给大家介绍online DDL,可以防止这种情况。

行锁

顾名思义,行锁就是锁住记录所在的那一行,具体的加解锁时间是在需要的时候(执行相关的sql语句)加上,在事务提交的时候释放。而在使用大量行锁的时候我们偶尔会发现整个数据库的执行效率很低,这时候我们就需要考虑是否有死锁发生。

死锁

先举一个死锁的例子

事务A事务B
begin;
update k set k = k+1 where id =1;
begin;
update k set k = k+1 where id =2;
update k set k = k+1 where id =2;
update k set k = k+1 where id =1;

这就是一个典型的死锁的例子,我们有什么办法来防止死锁吗?

  1. 设置innodb_lock_wait_timeout参数,通过最大锁等待时间来让发生死锁的事务进行回滚。
  2. 设置 innodb_deadlock_detect参数为on,让MySQL自动检测死锁并回滚。

我们可以考虑一下上述的两种方法。innodb_lock_wait_timeout的默认值是50s,这显然是不合理的,我们不可能让发生死锁的事务等50s再回滚,但是如果我们调的过小比如1s也会误伤到正常锁等待的语句。而第二个方法开启MySQL的死锁检测肯定会消耗一定资源,我们可以试想一下,如果一个事务要对一行上锁的时候,需要循环检测自己本身所依赖的数据有没有被别人锁住,然后继续循环检查,一直到判断出是否出现了循环等待。如果有100个线程并发,每个线程都检查一下其他的线程的锁,这样就是要检查10000次,这是很耗费资源的。

行锁的种类

间隙锁(Gap lock)

到底什么是幻读,幻读究竟能造成什么问题

临键锁(next-key lock)

到底什么是幻读,幻读究竟能造成什么问题

插入意向锁(Insert Intention Locks)

插入意向锁是一种间隙锁,专门针对的是数据行的插入操作,多个事务插入相同的索引间隙时,只要不是插入到相同的位置,则不需要进行锁等待。

假设有索引记录的值分别是4和7,单独的事务分别尝试插入5和6,在获得插入行的排它锁之前,每个事务都是用插入意图锁来锁定4和7之间的空间,但是不会相互阻塞。因为行级别是没有冲突的。

记录锁(Record Lock)

顾名思义,记录锁就是锁定某行或某些行,那么如何判断一个SQL语句是锁定了哪些行呢?我曾经看到过一个大佬总结出来的规则,现在把他摘抄过来。

我总结的加锁规则里面,包含了两个“原则”、两个“优化”和一个“bug”。

  1. 原则 1:加锁的基本单位是 next-key lock。希望你还记得,next-key lock 是前开后闭区间。
  2. 原则 2:查找过程中访问到的对象才会加锁。
  3. 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
  4. 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
  5. 一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

以下表为例,说一下如何应用这些规则。

CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `c` int(11) DEFAULT NULL,
  `d` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `c` (`c`)
) ENGINE=InnoDB;
 
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);

说在最前面:next-key lock是gap lock 和 record lock 的组合(在加锁行为上也是先加gap lock 后加record lock)。gap lock和插入记录的行为(也就是插入意向锁)冲突。但是和record lock不冲突。

案例一
sessionAsessionBsessionC
begin;
update t set d =d+1 where id =7;
insert into t values(8,8,8);
blocked
update t set d =d+1 where id =10;
Query OK

根据原则1,加锁的范围是(5,10],根据优化2,加锁的范围变成了(5,10)。所以sessionB会被block,而sessionC则不会。

案例二
sessionAsessionBsessionC
begin;
select id from t where c = 5 lock in share mode;
update t set d =d+1 where id =5;
Query OK
insert into t values(7,7,7);
blocked

我们来分析下这个案例:

  1. 首先根据原则1,加锁的范围是(0,5]。
  2. 又因为c是普通索引,需要向右遍历到第一个不满足条件的记录,那么现在会向右遍历到10,然后又会对(5,10]加锁。
  3. 根据优化2,(5,10]会退化为(5,10)。
  4. 这时候根据原则2,只有访问到的对象才加锁,现在的情况是只访问了c这个索引,并没有访问主键索引,所以只对c这个索引加锁,主键索引不加锁。这也就解释了sessionB不会block。
  5. 最后锁的范围就是(0,10)

但是sessionC不一样,sessionA的间隙锁会将其锁住。

需要注意的是,lock in share mode 只锁住覆盖索引,而for update表示接下来要进行数据的更新,会同时顺便将主键索引满足条件的行加上锁。

案例三
select * from t where id=10 for update;
select * from t where id>=10 and id<11 for update;

这两条语句锁住的是哪些行呢?

在逻辑上,这两条语句时等价的,但是在加锁规则上是不一样的,我们按照上述方法来进行分析。

首先分析第一条语句:

  1. 首先,按照原则1,加锁的范围是(5,10]。
  2. 根据优化1,next-key lock退化为行锁,锁住的范围就会变成id=10这一行。

然后再分析第二条语句:

  1. id>=10可以拆分成id=10以及id>10&id<11这两个条件判断
    1. 对于id=10,锁住的范围是id=10这一行。
    2. 对于id>10&id<11,这是个范围查询,根据第五点,要查找到第一个不满足条件的值(也就是15),然后加next-key lock也就是锁住了(10,15]。
  2. 最终,这个语句加锁的范围是 [10,15]。
案例四
sessionAsessionBsessionC
begin;
select * from t where c>=10 and c<11 for update;
insert into t values(8,8,8);
blocked
update t set d =d+1 where id =10;
blocked

同样,按上面规则分析。

  1. 因为c是索引,但是不是唯一索引,所以c=10会锁住(5,10]这个范围。
  2. 对于c>10 and c<11 需要锁住(10,15]这个范围,因为InnoDB会遍历到15这个记录才知道不需要继续扫描了。
案例五
sessionAsessionBsessionC
begin;
select * from t where id>10 and id<=15 for update;
update t set d =d+1 where id =20;
blocked
insert into t values(16,16,16);
blocked

首先分析sessionA的语句。

  1. id>10 and id<15 按照原则1会首先锁住(10,15]这个范围。
  2. 但是按照第五点的bug来说。需要查询到第一个不满足条件的值为止,所以还需要加上(15,20]这个范围的锁。
  3. 所以最后的锁的范围是(10,20]。

之所以成为这是bug的原因是因为本身id是唯一索引,应该遍历到15就该结束了,不应该再继续遍历继续加gap lock。

案例六
sessionAsessionB
begin;
select * from t where c=10 lock in share mode
update t set d =d+1 where c =10;
blocked
insert into t values(8,8,8);
Deadlock found when trying to get lock; try restarting transaction

我们看下这个例子:

  1. 当我们执行第一个语句的时候,按照上述规则,锁住的是(5,10]以及(10,15)这个范围。
  2. 在我们执行第二个语句的时候,与第一个语句一样同样锁住的范围是(5,10]以及(10,15)这个范围。会加锁失败,而等待。
  3. 这时insert into t values(8,8,8);这条语句因为(5,10]的间隙锁的存在而插入失败,发生死锁。

你可能有些疑问,为什么sessionB的update语句会等待呢?之前不是说过间隙锁和间隙锁是不冲突的嘛?而且sessionB的update语句加锁没有成功呀,为啥死锁了????

答案是这样的,sessionB的update加锁逻辑是:

  1. 按照规则1,先加入了(5,10]的next -key lock 然后再加了(10,15]这个next-key lock,根据优化2,会退化为(10,15)。
  2. 但是我们注意,next-key lock是间隙锁和行锁的组合。在加这个(10,15)的next-key lock的时候,我们要先加(5,10)的间隙锁。这个是可以成功的,但是再加10这个行锁的时候会锁冲突。

这也就好解释后来死锁的原因了。

案例七
sessionAsessionB
begin;
select * from t where c>=15 and c<=20 order by c desc lock in share mode;
insert into t values(6,6,6);
blocked

首先我们分析下session的查询语句的锁范围。

  1. 这次略有不同,因为order by c desc,所以需要倒着对索引树c进行搜索。首先定位到c=20这一行。为了确认这一行是否是最后一个c=20的一行,所以,需要向右搜索,所以锁住的范围是(15,20] , (20,25)。
  2. 然后一直向左遍历,遍历到c=10不符合条件,所以会锁住(5,10],(10,15]这个范围。
  3. 在扫描过程中,c=20、c=15、c=10 这三行都存在值,由于是 select *,所以会在主键 id 上加三个行锁。

以上是关于MySQL中的锁的主要内容,如果未能解决你的问题,请参考以下文章

Mysql中的锁机制详解

mysql间隙锁

mysql间隙锁

详解 MySql InnoDB 中的三种行锁(记录锁间隙锁与临键锁)

MySQL_InnoDB的锁和事务模型

mysql 解决可提交读、可重复读、幻读