背景
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文章中已经介绍过版本链的概念,案例对应的版本链结构(近似表示)如下图所示:
因mysql可以根据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和可见性原则,就可以查询到这条幻影记录了。