写在正文前面,以下仅MySQL小白进阶
用于记录对MySQL事务隔离级别及MVCC的理解。
读https://www.cnblogs.com/CodeBear/p/12710670.html
及https://blog.csdn.net/qq_38538733/article/details/88902979后总结。
以上博文均来自于掘金小册:MySQL 是怎样运行的:从根儿上理解 MySQL 。
文章目录
建表如下
CREATE TABLE t (
id INT PRIMARY KEY,
name VARCHAR(80)
) Engine=InnoDB CHARSET=utf8mb4;
插入SQL
INSERT INTO t VALUES(1, '鸣人');
隔离级别
之前的笔记:对脏写、脏读、不可重复度、幻读的理解笔记 说过事务具备隔离性,理论上在某个事务对某个数据进行访问时,其他事务应该进行排队,当该事务提交之后,其他事务才可以继续访问这个数据。但是这样子的话对性能影响太大,所以设计数据库的大叔提出了各种隔离级别,来最大限度的提升系统并发处理事务的能力,但是这也是以牺牲一定的隔离性来达到的。
隔离级别包括:
读未提交(READ UNCOMMITTED),
读已提交(READ COMMITTED),
可重复读(REPEATABLE READ),是MySQL默认隔离级别,
串行化(SERIALIZABLE)。
读未提交(READ UNCOMMITTED)
当前隔离级别下,一个事务读到了另一个未提交事务修改过的数据。参考之前笔记中“脏读”。同时也会出现“不可重复读”,“幻读”的问题。所以是十分不安全的隔离级别。
读已提交(READ COMMITTED)
当前隔离级别下,一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值。
参考之前笔记中的“不可重复读”,举例如下
时间节点 | sessionA | sessionB |
---|---|---|
1 | begin | |
2 | SELECT * FROM t WHERE id=1(这里查询结果必然是鸣人) | begin |
3 | begin >> UPDATE t SET name=‘佐助’ WHERE id=1 >> commit | |
4 | SELECT * FROM t WHERE id=1(这里查询结果是佐助) | |
5 | begin >> UPDATE t SET name=‘小樱’ WHERE id=1 >> commit | |
6 | SELECT * FROM t WHERE id=1(这里查询结果是小樱) | |
7 | … |
也就是当前隔离级别下,不会出现“脏读”,但是“不可重复读”和“幻读”的问题依然会出现。
可重复读(REPEATABLE READ)
当前隔离级别下,一个事务只能读到另一个已经提交的事务修改过的数据,且当第一次读过某条记录后,即使其他事务修改了该记录的值并且提交,该事务之后再读该条记录时,读到的仍是第一次读到的值,而不是每次都读到不同的数据。
依然参考之前笔记中的“不可重复读”,举例如下
时间节点 | sessionA | sessionB |
---|---|---|
1 | begin | |
2 | SELECT * FROM t WHERE id=1(这里查询结果必然是鸣人) | begin |
3 | begin >> UPDATE t SET name=‘佐助’ WHERE id=1 >> commit | |
4 | SELECT * FROM t WHERE id=1(这里查询结果依然是鸣人) | |
5 | begin >> UPDATE t SET name=‘小樱’ WHERE id=1 >> commit | |
6 | SELECT * FROM t WHERE id=1(这里查询结果依然是鸣人) | |
7 | … |
也就是在当前隔离级别下,可以避免“脏读”,“不可重复读”两个问题,会有“幻读”问题。但是在MySQL中,此隔离级别解决了“幻读”问题(待继续研究)。
串行化(SERIALIZABLE)
当前隔离级别下,一个事务执行的时候不允许别的事务并发执行,完全串行化的读,只要存在读就禁止写,但可以同时读。
以上3种隔离级别都允许对同一条记录进行读-读、读-写、写-读的并发操作,而当前隔离级别不允许读-写、写-读的并发操作,以此消除了幻读。
依然参考之前笔记中的“不可重复读”,举例如下
时间节点 | sessionA | sessionB |
---|---|---|
1 | begin | |
2 | SELECT * FROM t WHERE id=1(这里查询结果必然是鸣人) | begin |
3 | begin >> UPDATE t SET name=‘佐助’ WHERE id=1 (到这里就等待) | |
4 | SELECT * FROM t WHERE id=1(这里查询结果依然是鸣人) | |
5 | commit | |
6 | 节点2的SQL修改成功 | |
7 | begin | |
8 | SELECT * FROM t WHERE id=1(到这里就等待,因为sessionB还没有提交) | |
9 | commit | |
10 | 节点8的SQL查询结果为佐助 | |
11 | SELECT * FROM t WHERE id=1(这里再次查询结果依然是佐助) | |
12 | … |
总结MVCC前先了解前置概念,版本链和ReadView。
版本链
数据库将数据分为两个部分来存储,一个是数据行的额外信息(待研究),一个是真实的数据记录。
对于使用InnoDB存储引擎的表来说,在真实数据记录里的聚簇索引记录中都包含两至三个隐藏列(我对隐藏列的理解就是字段):
- row_id,非必要。表中有PRIMARY KEY或者NOT NULL UNIQUE KEY时都不会包含row_id列,如果两者都没有,MySQL会自主添加row_id列。
- trx_id(schema_information库中的innodb_trx表??),必要。理解为事务id,每次对某条聚簇索引记录进行改动时,都会把对应的事务id赋值给trx_id列。
只用begin/start transaction是不会正式开启一个事务的,它们只是声明即将启动一个事务,理解为事务id=0,执行select语句也不会。只有执行insert/update/delete语句才能获得事务id。 - roll_pointer,必要。每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
“鸣人”这条记录图示(假设是事务50写入的):
每次对数据进行改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer属性(INSERT操作对应的undo日志没有该属性,因为该记录并没有更早的版本)。所以这里roll_pointer写空。
实际上,roll_pointer并不是空的,理解成空的,问题也不大。(待研究)
当对该数据进行操作时(假设两个事务id分别为100和200):
时间节点 | trx_id=100 | trx_id=200 |
---|---|---|
1 | begin | |
2 | UPDATE t SET name=‘佐助’ WHERE id=1 | begin |
3 | UPDATE t SET name=‘小樱’ WHERE id=1 | |
4 | commit | |
5 | UPDATE t SET name=‘纲手’ WHERE id=1 | |
6 | UPDATE t SET name=‘蛇叔’ WHERE id=1 | |
7 | commit |
这里举例没有在两个事务中同时修改一条数据,因为涉及锁的概念。
那么上图操作产生的版本链如图:
小结:
每次更新后,都会将旧值放到一条undo日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被roll_pointer属性连接成一个链表,这个链表就是版本链,版本链的头节点就是当前记录最新的值。
ReadView
对于READ UNCOMMITTED,直接读取到其他事务还没有提交的数据,也就是读最新的。
对于SERIALIZABLE,是用加锁的方式来访问记录。
而在剩下的READ COMMITTED和REPEATABLE READ这两个事务隔离级别,都要保证读到的数据是其他事务已经提交的,也就是不能无脑把一行数据的最新版本给读出来了,而这就需要用到版本链。
其核心问题就是:需要判断版本链中的哪个版本是当前事务可见的。为此引入ReadView的概念。
ReadView包含四个比较重要的内容:
- m_ids:表示在生成ReadView时,系统中活跃的事务id集合。
- min_trx_id:表示在生成ReadView时,系统中活跃的最小事务id,也就是 m_ids中的最小值。
- max_trx_id:表示在生成ReadView时,系统应该分配给下一个事务的id。
- creator_trx_id:表示生成该ReadView的事务id。
在访问某条记录时,按照下边的步骤判断该记录的某个版本是否可见。
//伪代码仅代表理解
if (被访问版本的trx_id == 当前ReadView的creator_trx_id) {
可以访问;//表明该访问版本就是当前事务生成的,故可以访问
} else {
if (被访问版本的trx_id >= 当前ReadView的min_trx_id && 被访问版本的trx_id <= 当前ReadView的max_trx_id) {
if(in_array(被访问版本的trx_id , 当前ReadView的m_ids)) {
不可以访问;//表明生成该访问版本的事务还是活跃的,故不可以访问
} else {
可以访问;//表明生成该访问版本的事务已经提交,故可以访问
}
} else {
if (被访问版本的trx_id < 当前ReadView的min_trx_id) {
可以访问;//表明该访问版本在当前事务之前就已经提交了,故可以访问
} else {//这里else指 >当前ReadView的max_trx_id
不可以访问;//表明生成该被访问版本的事务在当前事务生成ReadView后,故不可以访问
}
}
}
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本,如果最后一个版本也不可见的话,那么就意味着该条记录对该事务不可见,查询结果就不包含该记录。
READ COMMITTED级别下,每次读取数据前都生成一个ReadView
举例:
时间节点 | trx_id=100 | trx_id=200 | trx_id=0 |
---|---|---|---|
1 | begin | ||
2 | UPDATE t SET name=‘佐助’ WHERE id=1 | begin | |
3 | UPDATE t SET name=‘小樱’ WHERE id=1 | begin | |
4 | SELECT * FROM t WHERE id = 1(这里查询结果为鸣人) | ||
5 | commit | ||
6 | SELECT * FROM t WHERE id = 1(这里查询结果为小樱) | ||
7 | UPDATE t SET name=‘纲手’ WHERE id=1 | ||
8 | UPDATE t SET name=‘蛇叔’ WHERE id=1 | ||
9 | commit | ||
10 | SELECT * FROM t WHERE id = 1(这里查询结果为蛇叔) | ||
11 | … |
分析节点4的trx_id=0的查询过程
- 当前ReadView的m_ids包括[100,200,0](均活跃未提交);
- 查询记录最新版本’小樱’的trx_id=100,100在当前m_ids列表中,根据规则,该记录版本不可见,则根据roll_pointer找下个版本;
- 查询记录版本’佐助’,同上;
- 查询记录版本’鸣人’的trx_id=50,小于m_ids中的最小值,则该版本可见。
分析节点6的trx_id=0的查询过程
- 当前ReadView的m_ids包括[200,0](100已提交,200活跃未提交);
- 查询记录最新版本’小樱’的trx_id=100,小于m_ids中的最小值,则该版本可见。
而这里也就出现了不可重复读的问题,在一个事务中,同一查询结果不一致。
分析节点10的trx_id=0的查询过程
同上节点6。一样出现不可重复读问题。
REPEATABLE READ级别下,在第一次读取数据时生成一个ReadView
依然用上面的流程举例,但结果不同:
时间节点 | trx_id=100 | trx_id=200 | trx_id=0 |
---|---|---|---|
1 | begin | ||
2 | UPDATE t SET name=‘佐助’ WHERE id=1 | begin | |
3 | UPDATE t SET name=‘小樱’ WHERE id=1 | begin | |
4 | SELECT * FROM t WHERE id = 1(这里查询结果为鸣人) | ||
5 | commit | ||
6 | SELECT * FROM t WHERE id = 1(这里查询结果为鸣人) | ||
7 | UPDATE t SET name=‘纲手’ WHERE id=1 | ||
8 | UPDATE t SET name=‘蛇叔’ WHERE id=1 | ||
9 | commit | ||
10 | SELECT * FROM t WHERE id = 1(这里查询结果为鸣人) | ||
11 | … |
分析节点4的trx_id=0的查询过程
同上节点4。
注意,节点4查询时的ReadView的m_ids为[100,200,0]。
分析节点6的trx_id=0的查询过程
- 当前ReadView的m_ids依然是[100,200,0](沿用第一次查询时的ReadView,即使事务100已经提交,这里的区别很重要);
- 查询记录最新版本’小樱’的trx_id=100,100在当前m_ids列表中,根据规则,该记录版本不可见,则根据roll_pointer找下个版本;
- 查询记录版本’佐助’,同上;
- 查询记录版本’鸣人’的trx_id=50,小于m_ids中的最小值,则该版本可见。
由此也就可以发现,当前隔离级别下不可重复读的问题被解决了。
分析节点10的trx_id=0的查询过程
- 当前ReadView的m_ids依然是[100,200,0](沿用第一次查询时的ReadView,即使事务100和200都已经提交了);
- 查询记录最新版本’蛇叔’的trx_id=200,200在当前m_ids列表中,根据规则,该记录版本不可见,则根据roll_pointer找下个版本;
- 查询记录版本’纲手’、‘佐助’、‘小樱’,同上;
- 查询记录版本’鸣人’的trx_id=50,小于m_ids中的最小值,则该版本可见。
同时节点6的查询分析,当前隔离级别下不可重复读的问题被解决了。
MVCC总结
MVCC(Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用READ COMMITTD、REPEATABLE READ这两种隔离级别的事务在执行普通的SEELCT操作时访问记录的版本链的过程,这样子可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。
可以理解为其中多版本就如版本链描述,而并发控制就是ReadView相关。
READ COMMITTD、REPEATABLE READ这两个隔离级别的一个很大不同就是生成ReadView的时机不同,READ COMMITTD在每一次进行普通SELECT操作前都会生成一个ReadView,而REPEATABLE READ只在第一次读取数据时生成一个ReadView,之后的查询操作都沿用这个ReadView。