问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

MySQL的MVCC机制详解

创作时间:
作者:
@小白创作中心

MySQL的MVCC机制详解

引用
CSDN
1.
https://m.blog.csdn.net/Sheng_Q/article/details/145807305

MySQL提供了读未提交、读已提交、可重复读、串行化四种隔离级别,默认的隔离界别为可重复读。其中,不可重复度场景下,每次直接读取最新记录(即使事务未提交);串行化对于所有的读写都加锁,因此,对二者不做过多介绍,MVCC只适用于可重复度和读已提交。

MySQL的并发事务中存在三种冲突:读-读、读-写、写-写。其中读-读没有冲突,可以同时进行;写-写通过数据库锁实现冲突分离;MVCC解决的是读-写冲突,并尽可能提高数据库性能。

MVCC的实现由UNDO日志、版本链、READVIEW三部分组成,因此本文先对这三部分进行介绍,然后再结合案例介绍MVCC机制;最后介绍MVCC遗留的幻读问题。

本文中较大篇幅围绕案例进行,案例使用的表如下:

CREATE TABLE `t_student` (
    `id` INT(10) NOT NULL COMMENT '学号,唯一ID',
    `name` VARCHAR(50) NOT NULL COMMENT '姓名',
    PRIMARY KEY (`id`) USING BTREE
)
ENGINE=InnoDB
;

1.当前读和快照读

当前读也叫做锁定读,数据读取时需要加锁(行锁和间隙锁),以保证读取的数据不会被其他事务修改,从而读取的是当前最新的数据。

MySQL中以下两种SELECT查询语句会触发当前读:

select ... for update
select ... lock in share mode

当前读会阻塞其他事务对行记录的写操作,一般用于金融交易等对一致性要求较高的场景。

除了上述两种SELECT查询之外的其他查询SQL都是快照读。

MySQL的默认隔离级别为可重复读,在这种隔离界别下,第一次查询时生成一个数据库快照,后续查询时读取的是同一份快照。

2.版本链

创建三个事务,分别对t_student表按如下时间线顺序和操作进行修改:

time
事务100
事务200
事务300
1
START TRANSACTION;
2
INSERT INTO t_student (id, name) VALUES (1, 'sy');
3
COMMIT;
4
START TRANSACTION;
5
START TRANSACTION;
6
UPDATE t_student SET NAME = 'sy201' WHERE id = 1;
7
UPDATE t_student SET NAME = 'sy202' WHERE id = 1;
8
COMMIT;
9
UPDATE t_student SET NAME = 'sy301' WHERE id = 1;
10
UPDATE t_student SET NAME = 'sy302' WHERE id = 1;
11
COMMIT;

在UNDO文章中已经介绍过版本链的概念,案例对应的版本链结构(近似表示)如下图所示:

简单回顾一下图中涉及两个重要的隐藏列: db_trx_id表示当前记录由哪个事务创建或者最后改动; db_trx_ptx指向undo日志。实际上,每条被修改的记录都通过db_trx_ptx指向上一步的Undo日志,undo日志中又包含了上上步Undo日志指针…… 由此形成了该记录对应的版本链。

3.ReadView和可见性原则

ReadView结合可见性原则可以实现读已提交,解决并发脏读问题。

ReadView:

ReadView由四个属性组成:

  1. m_ids: 当前活跃的事务ID;
  2. min_trx_id: m_ids中的最小值;
  3. max_trx_id: 下一个待分配的事务ID;
  4. creator_trx_id:当前事务ID;

可见性原则:

由于事务ID是依次递增的,即越早提交的事务,事务ID越小。在前文提到过,事务ID只有在执行修改时才会分配,而不是事务启动时分配。如一个事务全局只会读数据,则该事物不会分配事务ID。

基于上述两点,以下可见性原则保证了读取的数据均为已提交数据。

Note: 版本链上的事务ID使用trx_id表示。

  1. trx_id 等于creator_trx_id,表示当前事务在访问自己修改过的记录,可见;
  2. trx_id 小于min_trx_id, 表示记录对应的事务已经被提交,可见;
  3. trx_id 大于max_trx_id, 表示记录对应的事务未被提交,不可见;
  4. 其他情况(处于min_tx_id和max_trx_id之间),如果事务已提交(trx_id 不在m_ids集合中)——可见,事务活跃(trx_id 在m_ids集合中),则不可见。

读已提交和可重复度通过创建ReadView的策略不同进行区分: ReadView本身保证了读取的记录已经被提交,因此读已提交隔离级别下,每次读取都重新创建一个ReadView; 可重复读只在第一次查询时建立ReadView, 后续查询都基于同一个ReadView进行,保证了每次读取的数据相同。

4.MVCC

查询时事务将根据ReadView信息沿着版本链的顺序进行,直到查询满足可见性原则的记录或者返回空。

结合案例进行理解。

time
事务100
事务200
事务300
1
START TRANSACTION;
2
INSERT INTO t_student (id, name) VALUES (1, 'sy');
3
COMMIT;
4
START TRANSACTION;
5
START TRANSACTION;
6
UPDATE t_student SET NAME = 'sy201' WHERE id = 1; … update other line …
7
UPDATE t_student SET NAME = 'sy202' WHERE id = 1;
8
SELECT NAME FROM t_student WHERE id = 1;
9
SELECT NAME FROM t_student WHERE id = 1;
10
COMMIT;
11
COMMIT;

在time=NO.8和NO.9时刻,id=1记录的版本链如下所示:

case 1:站在事务200的角度,time=NO.8时刻第一次执行版本查询,创建ReadView(用ReadView200表示):

  1. m_ids: 200;
  2. min_trx_id: 200;
  3. max_trx_id: 301;
  4. creator_trx_id: 200;

使用ReadView200与第一条记录按照可见性原则进行匹配:

记录的db_trx_id等于creator_trx_id,表明该记录是当前事务创建的,因此可见。

返回的结果是sy202.

case 2:站在事务300的角度,time=NO.9时刻第一次执行版本查询,创建ReadView(用ReadView300表示):

  1. m_ids: {200,300};
  2. min_trx_id: 200;
  3. max_trx_id: 301;
  4. creator_trx_id: 300;

使用ReadView300与第一条记录按照可见性原则进行匹配:

记录的db_trx_id(200)不等于creator_trx_id(300), 且db_trx_id(200)事务还处于活跃状态,不可见;
沿着版本链读取下一条记录: 记录的db_trx_id(200),仍不可见;
沿着版本链继续读取下一条记录: db_trx_id(100)小于min_trx_id,表示事务已提交,可见,返回sy.

特别注意:ReadView是在第一次查询时创建, 而不是开启事务时创建

对上述案例进行简单变更(事务3的查询时机修改一下):

time
事务100
事务200
事务300
1
START TRANSACTION;
2
INSERT INTO t_student (id, name) VALUES (1, 'sy');
3
COMMIT;
4
START TRANSACTION;
5
START TRANSACTION;
6
UPDATE t_student SET NAME = 'sy201' WHERE id = 1; … update other line …
7
UPDATE t_student SET NAME = 'sy202' WHERE id = 1;
8
SELECT NAME FROM t_student WHERE id = 1;
9
COMMIT;
10
SELECT NAME FROM t_student WHERE id = 1;
11
COMMIT;

case 2:站在事务300的角度,time=NO.10时刻第一次执行版本查询,创建ReadView(用ReadView300表示):

  1. m_ids: {300};
  2. min_trx_id: 300;
  3. max_trx_id: 301;
  4. creator_trx_id: 300;

此时,根据可见性规则,事务200的修改已对事务300可见,即查询的结果为sy202.

5.幻读问题

幻读的概念: 一个事务在读取某个范围内的记录后,另一个并发事务插入了新的记录到这个范围内,导致第一个事务再次读取该范围时,出现了之前未读到的记录,就像产生了“幻影”一样。

事务中完全使用当前读可以避免幻读问题,当前读通过加锁实现: 行锁保证了当前记录不会被修改,间隙锁保证了范围查询时,范围内不会插入新记录。

MySQL虽然通过MVCC解决了大部分幻读问题,但特殊场景下仍存在幻读问题:一个事务A读取某个范围内的记录后,另一个并发事务B插入了新的记录到这个范围内并提交; 事务A修改这条记录,事务A再次读取相同范围时,会多查出这条记录。以下通过案例进行细致介绍。

t_student表中初始状态只有1条记录:

mysql> select * from t_student;
+----+-------+
| id | name  |
+----+-------+
|  1 | sy    |
+----+-------+

创建两个事务,分别对t_student表按如下时间线顺序和操作进行修改:

time
事务100
事务200
1
START TRANSACTION;
2
SELECT * FROM t_student WHERE id < 10;
3
START TRANSACTION;
4
INSERT INTO t_student(id,name) VALUES (2, 'mf');
5
INSERT INTO t_student(id,name) VALUES (3, 'lj');
6
COMMIT;
7
SELECT * FROM t_student WHERE id < 10;
8
UPDATE t_student SET NAME = 'mf100' WHERE id = 2;
9
SELECT * FROM t_student WHERE id < 10;

对应的执行结果如下所示:

NO.7范围查询与NO.2的查询结果一致,此时没有幻读问题;NO.9的查询与NO.1的不一致,多查出了其他事务插入的数据,出现幻读。

原因分析:

MySQL本质上不能保证两个事务完全彻底的独立,仅通过MVCC机制实现了读的隔离, 在一定程度上保证了读写分离和并发安全性; 上图NO.8的修改操作显示有一条记录被改动,对比NO.7的查询可知:事务100无法读取,但可以修改已被提交的记录。

此时,id=2的记录对应的db_trx_id记录的是最后修改的事务ID,即100;事务100再次查询该范围时,根据ReadView和可见性原则,就可以查询到这条幻影记录了。

© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号