【MySQL】六、MySQL多版本控制(MVCC)和锁

在前面的文章中,我们介绍了InnoDB的事务和数据一致性问题,也提到了为了解决这些问题MySQL提供了四个隔离级别供我们选择,对于InnoDB默认使用的是可重复度(RR)。而且也通过示例验证了在RR这个隔离级别下,InnoDB是解决了脏读、幻读和不可重复读的问题。

那么它是怎么解决的呢?

首先介绍一个东西,我们通常称为“MVCC”,官网把这个叫”无锁化一致性读“。我们姑且还是按照惯例叫MVCC吧。

【参考资料】

1、MVCC

在介绍mvcc之前需要有一个概念,MySQL的数据行中其实是有一些隐藏列的,比如DB_ROW_ID记录了数据行ID、DB_TRX_ID最后修改当前记录的事务ID、DB_ROLL_PTR记录了数据被改动前的undolog日志指针

至于多版本控制,它的原理是基于快照。而这个快照,按照官网的介绍就是一个叫ReadView的东西,那就先来认识一下这个ReadView吧

1.1、了解ReadView的结构

按照我们的常规思维,ReadView是用来解决事务问题的,那么它里边肯定会记录一些事务相关的信息,那么它的结构是什么样的呢?我们查阅资料发现它里边还真的有几个属性记录了一些事务ID

1.1.1、m_low_limit_id

事务的高水位,当数据的修改事务id大于或等于这个值的时候,则该数据对当前事务不可见。这个值通常就是当前事务的下一个事务,就像我们的自增ID一样,事务ID也是一个自增的过程

这个逻辑很容易理解,你的事务ID都比我大,也就是说数据修改在我之后

1.1.2、m_up_limit_id

数据修改的事务ID所有小于这个值的数据都可见,也称低水位。这个值也是m_ids列表里边的最小值。既然都比当前存活的最小事务ID还小,那指定在当前事务创建之前已经提交了,所以就可见

1.1.3、m_creator_trx_id

当前的事务ID

1.1.4、m_ids

当前存活的事务ID列表,也就是创建当前ReadView的时候那么还没有提交的事务

1.2、可见性判断

上面在说到ReadView的结构的时候>= m_low_limit_id< m_up_limit_id这两种情况基本上比较明确的,大于等于高水位不可见,小于低水位可见

但是如果数据修改的事务ID介于这两者之间怎么办呢?

这就联系到前面提到的数据行的隐藏列DB_TRX_ID了,这个场景规则也很简单:

① 如果数据的修改事务ID在上面提到的当前存活事务ID列表中,则说明在创建当前ReadView的时候修改数据的事务还没提交,则不可见

② 如果数据的修改事务ID不在上面的这个存活事务ID列表中,说明在创建当前ReadView的时候它已经被提交了,则可见

答案就这么水灵灵地出现了吗?没那么简单。前面我还埋了一个伏笔故意没提,那就是ReadView的创建是和隔离级别有关系的:在RC这种隔离级别下,他是每次查询都会创建一个新的ReadView,而在RR这个隔离级别下,只有在事务第一次读取的时候才会创建一个ReadView

所以不妨自己脑补一下这两种创建ReadView的时机会有什么影响

1.3、MVCC是如何解决不可重复读的

1.3.1、RC隔离级别下

按照1.2的判断逻辑,如果是在RC隔离级别下,每次快照读的时候都会创建一个ReadView,那么数据是否可见是用数据行的隐藏字段DB_TRX_ID去跟不同的ReadView里边的那几个字段去比较,显然不同的ReadView就有可能会有不同的比对结果。这是不是就会导致可能我第一次读取的时候生成的ReadView去判断,结果某条数据是不可见的,但是在第二次去读取的时候第二次的ReadView去比对得到的结果却是可见呢?

这种现象叫什么,是不是叫不可重复读?所以RC这种隔离级别下是没有解决不可重复读的问题的

1.3.2、RR隔离级别下

但是如果是在RR这种隔离级别下呢?

它和RC的不同点在于是在事务第一次快照读的时候生成一个ReadView,也就是说不管是第一次读取还是第二次读取,它去和数据的修改事务ID比对的ReadView是同一个。既然是同一个,那么比对的结果自然是前后一样的。换句话说,如果一条数据第一次读取的时候是不可见的,那么同一个事务第二次再读取依然是不可见,反之两次都是可见

这不就解决了数据一致性里边的不可重复读吗?

2、锁(LBCC)

前面通过分析ReadView已经知道RR隔离级别下,InnoDB是如何解决不可重复读的问题的。接下来我们要弄清楚InnoDB是如何在RR级别下解决幻读的问题的

回忆一下什么是幻读,是不是一个事务在进行范围查询的时候,第一查询的结果和第二次查询的结果不一样?比如我有一个事务A:

select * from coupon_code where type > 2;

事务B这个时候插入了一条type=3的记录:

insert into coupon_code(code, type. status) values('176231512', 3, 1);

上一篇我们通过示例验证了在RR隔离级别下事务A查到的数据始终是一致的,也就是没有出现幻读的问题。它是怎么解决的呢?

答案就是

在InnoDB中,存储引擎的锁操作是加在索引树上的,如果我们加锁的语句通过条件能够定位到对应的记录,那么就会锁住对应的主键索引。接下来我们验证一下:

首先有一张学生表:

CREATE TABLE `stu` (
  `id` int NOT NULL AUTO_INCREMENT,
  `stu_no` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
  `stu_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
  `age` int DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

表中有四条记录:

在这里插入图片描述

如果我们在一个事务里查询:

BEGIN;
SELECT * from stu WHERE id = 2 FOR UPDATE;

在这里插入图片描述

另外一个事务去修改这条记录:

BEGIN;
UPDATE stu SET stu_name = 'HanMeiMei001' WHERE id = 2;

可以看到SQL语句被阻塞了,直到最终锁等待超时:

在这里插入图片描述

在这里插入图片描述

如果我不根据ID条件去修改,而是根据stu_name去修改呢?可以看到效果一样:

在这里插入图片描述

这个时候如果是修改其他的记录呢,不是锁定的ID=2这一条,比如我要修改ID=3的Lucy的名字:

在这里插入图片描述

根据ID=3去修改,可以看到立马执行成功了。如果我根据stu_name或者stu_no去修改呢?试一下

在这里插入图片描述

啊哦,又要等待了,这是为啥呢?

其实这里的原因在于加锁的条件。update或者DELETE的时候会自动去加一个排它锁(后面讲到),像第一次根据ID去update的时候,InnoDB可以定位到具体的某一条记录,所以它锁的是ID=2这一条记录,而如果是通过stu_no或者stu_name去update的时候,因为这两个字段也没有建立索引,所以它无法定位到某一条具体的记录需要全表扫描,这个时候InnoDB就会选择去锁表。但是由于第一个事务一直对ID=2这条记录的排它锁没有释放呢,所以第二个事务的锁表操作一直在等待直到50s后超时

2.1、如何查看当前存在的锁

之前文章介绍过MySQL里有一个库performance_schema记录的就是MySQL运行时的一些信息,其中data_locks表就是记录锁的一些信息:

SELECT * from performance_schema.data_locks

在这里插入图片描述

这里可以看到线程ID、数据库名称、加锁的索引、锁的模式(共享S、排它X、意向共享IS、意向排它IX)等

【参考资料】

2.2、锁的分类

2.2.1、按照占有模式

  • 共享锁

共享锁与共享锁之间可以实现资源共享

  • 排它锁

排它锁与其它锁不兼容,在update或者DELETE的时候自带排它锁

2.2.2、按照锁的对象或粒度(锁的实现方式)

  • 记录锁

通过条件能够查询到指定的某一条记录。记录锁锁定的一定是主键索引树,如果是通过二级索引加锁,最终还是会定位到主键索引的行节点然后锁主键索引。如果通过一个没有索引的字段去加锁,由于定位不到主键索引的对应节点,所以需要进行全表扫描,会触发锁表,就像前面演示的效果

  • 间隙锁

加入加锁的条件是一个区间,这个区间锁死,下面演示一下:

前面提到如果加锁的条件如果没有命中索引(主键索引或者二级索引都可以),也就无法定位到对应的记录ID,也就是说会触发全表锁定。下面的示例主要根据age操作,所以需要先给age加一个索引

ALTER TABLE `test001`.`stu` 
ADD INDEX `idx_age`(`age`) USING BTREE;

第一个事务去查询年龄在(12,16)岁之间的,可以看到只有一条记录

BEGIN;
SELECT * FROM stu WHERE age > 12 and age < 16 FOR UPDATE;

在这里插入图片描述

第二个事务插入一条14岁的LiLy

在这里插入图片描述

又获取锁超时了:

在这里插入图片描述

那么它锁定的是哪个范围呢?下面我们来插入几条数据试一下:

①插入一条11岁的:

在这里插入图片描述

可以看到立即执行成功了

②插入一条12岁的:

在这里插入图片描述

发现锁等待

③插入一条14岁的:

在这里插入图片描述

也是等待

④插入一条15岁的:

在这里插入图片描述

依然是要等待

⑤插入一条16岁的:

在这里插入图片描述

依然是要等待:

⑥插入一条100岁的:

在这里插入图片描述

还是要等待~

分析

前面查看数据发现,数据库里存在的数据年龄有8、11、12、15。InnoDB在加间隙锁的时候会根据已有数据去划分区间,这个时候就会划分出这几个区间:(-∞,8]、(8,11]、(11,12]、(12,15]、(15,+∞)。在第一个事务的加锁条件是(12,16),结合现象我们可以看出,InnoDB锁定的是(12,15]、(15,+∞)这两个区间

正是有了间隙锁,区间被锁定后也就无法插入这个区间内的值,那么是不是就可以解决幻读的问题呢?但是间隙锁只有在RR隔离级别下才存在,在RC隔离级别下没有间隙锁,即使锁一个区间,也只是用作主键、唯一键的检查,所以RC隔离级别下幻读会依然存在,而RR隔离级别却解决了幻读的问题

  • 临键锁

间隙+记录锁,就像上面案例锁定的这两个区间:(12,15]、(15,+∞),其中既包含了不存在数据的两个区间,又包含了存在数据的age=15对应的记录

  • 意向锁

InnoDB支持行锁与表锁共存,意向锁是锁优化的一种方案。比如前面提到如果加锁条件没有命中二级索引,就会触发全表扫描进行锁表。而有了意向锁之后,只需要检查目标表上的意向锁标志就可以判断是否能够加锁

意向共享锁加锁时机:

  • 行数据加共享锁之前,必须先获得对应表的意向共享锁
  • 行数据加排它锁之前,必须先获得对应表的意向排它锁

【参考资料】

假如当一个事务给一条数据加了一个共享锁(in share mode),InnoDB会在这之前先对表加一个意向共享锁,这个时候如果第二个事务也是读操作(in share mode),则也会尝试先去加一个意向共享锁,而意向共享锁与共享锁之间是互相兼容的,也就不用继续锁等待;而如果第二个事务是写操作,并且写条件没有命中二级索引,我们知道它会触发上面说的全表扫描进而尝试锁表(加排它锁),这个时候它首先会判断表上面是否存在其它的锁,这个时候发现已经有其它事务加了一个意向共享锁,而意向共享锁和排它锁之间是不兼容的,所以第二个事务的写操作会直接进行锁等待,而不用再去做全表扫描;而如果第二个事务虽然是写操作,但是它命中了索引,也就是它的目标是给具体的行加排它锁,这个时候InnoDB会在加排它锁之前去获取对应表的意向排它锁,而意向共享锁与意向排它锁是互相兼容的,这个时候也就不需要进行锁等待

以第三种场景为例:

①事务A对一条数据加共享锁

-- 事务A
BEGIN;
SELECT * FROM stu WHERE id = 1 FOR SHARE

在这里插入图片描述

锁信息:

在这里插入图片描述

②事务B去修改ID=3的这条数据

-- 事务B
BEGIN;
update stu set stu_name='Jerry' where id = 3;

在这里插入图片描述

在这里插入图片描述

可以看到,ID=3的这条记录成功被修改

可见意向共享锁和意向排它锁之间是互相兼容的

不同的表级别的锁之间的兼容性如下图:

在这里插入图片描述

X:排它锁

S:共享锁

IX:意向排它锁

IS:意向共享锁

2.3、死锁问题

死锁的现象:你等我,我等你。下面举例说明:

前面提到update、delete都会默认加一个排它锁

在这里插入图片描述

-- 事务A:
BEGIN;
update stu set stu_name='Tom' where id = 1;

在这里插入图片描述

-- 事务B
BEGIN;
update stu set stu_name='Jerry' where id = 2;

在这里插入图片描述

接下来事务A去更新ID=2的数据

-- 事务A
update stu set stu_name='Daul' where id = 2;

在这里插入图片描述

事务B去更新ID=1的数据

-- 事务B
update stu set stu_name='MiMi' where id = 1;

在这里插入图片描述

可以看到,事务B在执行的时候报错了,提示发现了死锁

在MySQL的新版本中,InnoDB是支持死锁自动检测的,所以原则上不会出现两个事务一直在互相等待锁的死锁问题,但是从实践出发,我们还是要尽可能地避免死锁的出现

【参考资料】

MySQL死锁检测机制

  • 尽量减少耗时的事务(减少锁的持有时间)
  • 尽量减少锁定的数据量(减少锁的粒度)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值