Typography

一只奶牛猫

MySQL锁机制详解:从行锁到表锁的完整指南

发布于 # database

在上一章节中我们学习了数据库的事务及其事务的隔离级别,但是数据库是怎样隔离事务的呢?这时候就牵连到了数据库锁。当插入数据时,就锁定表,这叫做”锁表”;当更新数据时,就锁定行,这叫做”锁行”。

锁在数据网络传输中是一个非常重要的概念,当多个用户对数据库进行操作时,会带来数据不一致的情况,所以,锁主要是在多用户情况下保证数据库数据完整性和一致性。

当然,数据库中的锁远不止于上面提到的两种。通常提及数据库锁,想必大家优先想到的,必然是乐观锁,数据库乐观锁可以帮助我们解决很多问题,但数据库中还有很多其它的锁,总结一下大概有如下:悲观锁、乐观锁、表锁、行锁、临间锁、间隙锁、记录锁、共享锁、排他锁、意向共享锁、意向排他锁。

上面一共提到了11种锁,如果给它们进行分类,大抵可以按如下划分:

乐观锁和悲观锁这个不用再多说了,相信大家也都是知道的。Mysql中的锁机制基本上都是采用的悲观锁来实现的。我们先来看一下”行锁”。

行锁

顾名思义,行锁就是一锁锁一行或者多行记录,mysql的行锁是基于索引加载的,所以行锁是要加在索引响应的行上,即命中索引,如下图所示:

如上图所示,数据库表中有一个主键索引和一个普通索引,Sql语句基于索引查询,命中两条记录。此时行锁一锁就锁定两条记录,当其他事务访问数据库同一张表时,被锁定的记录不能被访问,其他的记录都可以访问到。

行锁的特征:锁冲突概率低,并发性高,但是会有死锁的情况出现。

我们使用代码演示一下,看看行锁的表现:我们还是使用上一篇文章中使用的数据库,打开两个窗口,我们在窗口A中根据id更新一条记录,然后在窗口B中也执行相同的SQL语句看看

可以看到,窗口A先修改了id为3的用户信息后,还没有提交事务,此时窗口B再更新同一条记录,然后就提示Lock wait timeout exceeded; try restarting transaction ,由于窗口A迟迟没有提交事务,导致锁一直没有释放,就出现了锁冲突,而窗口B一直在等待锁,所以出现了超过锁定超时的警告了。

但是,此时我们如果去更新id为3它旁边的记录看看会出现怎样的情况,我们新打开一个窗口更新id为2的记录看看。

可以看到,在窗口B中更新id为3的记录报错,但是在窗口C中我们可以更新id为2的记录,这说明此时锁定了id为3的记录但是并没有锁定它旁边的记录。

表锁

顾名思义,表锁就是一锁锁一整张表,在表被锁定期间,其他事务不能对该表进行操作,必须等当前表的锁被释放后才能进行操作。表锁响应的是非索引字段,即全表扫描,全表扫描时锁定整张表,sql语句可以通过执行计划看出扫描了多少条记录。

由于表锁每次都是锁一整张表,所以表锁的锁冲突几率特别高,表锁不会出现死锁的情况。

和上面一样,我们通过代码演示一下,看看表锁的表现,我们打开两个窗口,在窗口A中更新一条记录,条件为非索引字段,不提交事务,然后在窗口B中任意再更新一条记录,我们看看会出现怎样的现象:

上面,我们分别验证了一下mysq的行锁和表锁,我们可以看到,当更新数据库数据时,如果没有触发索引,则会锁表,锁表后再对表做任何变更操作都会导致锁冲突,所以表锁的锁冲突概率较高。

在mysql中,行锁又衍生了其他几种算法锁,分别是 记录锁、间隙锁、临键锁;我们依次来看看这三种锁,什么是记录锁呢?

记录锁

上面我们找到行锁是命中索引,一锁锁的是一张表的一条记录或者是多条记录,记录锁是在行锁上衍生的锁,我们来看看你记录锁的特征:

记录锁:记录锁锁的是表中的某一条记录,记录锁的出现条件必须是精准命中索引并且索引是唯一索引,如主键id,就像我们上面描述行锁时使用的sql语句图,在这里就挺适用的。

图中id是唯一索引,此时锁的就是一条记录,命中索引为唯一索引,此时使用的锁就是记录锁了。相信学习完行锁后,再学习记录锁就简单很多了吧。

间隙锁

间隙锁又称之为区间锁,每次锁定都是锁定一个区间,隶属行锁。既然间隙锁隶属行锁,那么,间隙锁的触发条件必然是命中索引的,当我们查询数据用范围查询而不是相等条件查询时,查询条件命中索引,并且没有查询到符合条件的记录,此时就会将查询条件中的范围数据进行锁定(即使是范围库中不存在的数据也会被锁定),我们通过代码演示一下:

首先,我们打开两个窗口,在窗口A中我们根据id做一个范围更改操作,不提交事务,然后在范围B中插入一条记录,该记录的id值位于窗口A中的条件范围内,我们看看运行效果:

如上所示,程序报错:Lock wait timeout exceeded; try restarting transaction 。这就是间隙锁的作用。间隙锁只会出现在可重复读的事务隔离级别中,mysql5.7默认就是可重复读。间隙锁锁的是一个区间范围,查询命中索引但是没有匹配到相关记录时,锁定的是查询的这个区间范围,上述代码中,所锁定的区间就是 (1,3]这个区间,不包含1,但是包含3,并且不包含4,也就是说这里是一个左开右闭的区间。

如果我们将mysql数据库隔离级别修改为不可重复读,然后再运行一下上面代码,看看会是怎样的呢,我们来验证一下间隙锁只会出现在可重复读的事务隔离级别中:

设置事务隔离级别为不可重复读
set session transaction isolation level read committed;
查看当前事务级别
SELECT @@tx_isolation

我们修改数据库隔离级别后,然后将上面的代码流程再走一遍看看:

可以看到,修改了数据库隔离级别后,再次测试间隙锁,发现间隙锁没有生效。我们可以通过rollback回滚事务。

临键锁

学习完间隙锁后我们再来看看什么是临键锁,mysql的行锁默认就是使用的临键锁,临键锁是由记录锁和间隙锁共同实现的,上面我们学习间隙锁时,间隙锁的触发条件是命中索引,范围查询没有匹配到相关记录。而临键锁恰好相反,临键锁的触发条件也是查询条件命中索引,不过,临键锁有匹配到数据库记录

上面我们知道,间隙锁所锁定的区间是一个左开右闭的集合,而临键锁锁定是当前记录的区间和下一个记录的区间,我们一起来看看:

从上图我们可以看到,数据库中只有三条数据1、5、7,当修改范围为1~8时,则锁定的区间为(1,+∞),锁定额不单是查询范围,并且还锁定了当前范围的下一个范围区间,此时,查询的区间8,在数据库中是一个不存在的记录值,并且,如果此时的查询条件是小于或等于8,也是一样的锁定8到后面的区间。

如果查询的结尾是一个存在的值,此时又会怎样呢?现在数据库有三条数据id分别是1、5、7,我们查询条件改为大于1小于7再看看。

此时,我们可以看到,由于7在数据库中是已知的记录,所以此时的锁定后,只锁定了(1,7],7之后的数据都没有被锁定。我们还是可以正常插入id为8的数据及其后面的数据。

所以,临键锁锁定区间和查询范围后匹配值很重要,如果后匹配值存在,则只锁定查询区间,否则锁定查询区间和后匹配值与它的下一个值的区间。

但是,为什么会出现这种情况呢?为什么临键锁后匹配会这样呢?在这里,我们不妨看看mysql的索引是怎么实现的,前面文章中有提到树结构,mysql的索引是基于B+树实现的,每个树节点上都有多个元素,即关键字数,当我们的索引树上只有1、5、7时,我们查询1~8,这个时候由于树节点关键字中并没有8,所以就把8到正无穷的区间范围都给锁定了。

那么,如果我们数据库中id有1、5、7、10,此时我们再模糊匹配id为1~8的时候,由于关键字中并没有8,所以找比8大的,也就找到了10,根据左开右闭原则,此时10也是被锁定的,但是id为11的记录还是可以正常进行插入的。这里我没有测试,感兴趣的朋友可以下去自己尝试一下。我们的锁都是基于索引的,而mysql中索引的底层是使用的B+树,我们了解了B+树的特性后,就更容易理解很多遇到锁的问题了。

2018年12月18日 21:55:07