在前面的文章中,我们介绍了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死锁检测机制
- 尽量减少耗时的事务(减少锁的持有时间)
- 尽量减少锁定的数据量(减少锁的粒度)