MySQL多版本并发控制MVCC底层原理解析
1 事务并发中遇到的问题
1.1 脏读
当一个事务读取到了另外一个事务修改但未提交的数据,被称为脏读。
1.2 不可重复读
当事务内相同的记录被检索两次,且两次得到的结果不同时,此现象称为不可重复读。
1.3 幻读
当一个事务同样的查询条件查询两次(多次),查出的条数不一致称为幻读。
2 隔离级别
我们上边介绍了几种并发事务执行过程中可能遇到的一些问题,这些问题也有轻重缓急之分,我们给这些问题按照严重性来排一下序:
脏读 > 不可重复读 > 幻读
SQL 标准中规定,针对不同的隔离级别,并发事务可以发生不同严重程度的
问题,具体情况如下:
- READ UNCOMMITTED:未提交读。
- READ COMMITTED:已提交读。
- REPEATABLE READ:可重复读。
- SERIALIZABLE:可串行化
SQL 标准中规定,针对不同的隔离级别,并发事务可以发生不同严重程度的问题,具体情况如下:
- READ UNCOMMITTED 隔离级别下,可能发生脏读、不可重复读和幻读问题。
- READ COMMITTED 隔离级别下,可能发生不可重复读和幻读问题,但是不可以发生脏读问题。
- REPEATABLE READ 隔离级别下,可能发生幻读问题,但是不可以发生脏读和不可重复读的问题。
- SERIALIZABLE 隔离级别下,各种问题都不可以发生。
3 版本链
我们知道,对于使用 InnoDB 存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列(row_id 并不是必要的,我们创建的表中有主键或者非 NULL的 UNIQUE 键时都不会包含 row_id 列):
trx_id
: 每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务 id 赋值给 trx_id 隐藏列。
roll_pointer
: 每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo 日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
假设插入该记录的事务 id 为 80的记录,那么此刻该条记录的示意图如下所示:
假设之后两个事务 id 分别为 100、200 的事务对这条记录进行 UPDATE 操作,操作流程如下:
Trx 100:
UPDATE t_people SET name = '关羽' WHERE number = 1; UPDATE t_people SET name = '张飞' WHERE number = 1;
Trx 200:
UPDATE t_people SET name = '赵云' WHERE number = 1; UPDATE t_people SET name = '诸葛亮' WHERE number = 1;
每次对记录进行改动,都会记录一条 undo 日志,每条 undo 日志也都有一个 roll_pointer 属性(INSERT 操作对应的 undo 日志没有该属性,因为该记录并没有更早的版本),可以将这些 undo 日志都连起来,串成一个链表,所以现在的情况就像下图一样:
对该记录每次更新后,都会将旧值放到一条 undo 日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务 id。于是可以利用这个记录的版本链来控制并发事务访问相同记录的行为,那么这种机制就被称之为多版本并发控制(Mulit-Version Concurrency Control MVCC)
。
4 ReadView
4.1 ReadView 定义
InnoDB 提出了一个 ReadView 的概念,这个 ReadView 中主要包含 4个比较重要的内容:
- (1) m_ids:表示在生成 ReadView 时当前系统中 活跃 的读写事务的事务 id 列表。
- (2) min_trx_id: 表示在生成 ReadView 时当前系统中活跃的读写事务中最小的事务 id,也就是 m_ids 中的最小值。
- (3) max_trx_id:表示生成 ReadView 时系统中应该分配给下一个事务的 id 值。max_trx_id 并不是 m_ids 中的最大值,事务 id 是递增分配的。比方说现在有 id 为 1,2,3 这三个事务,之后 id 为 3 的事务提交了。那么一个新的读事务在生成 ReadView 时,m_ids 就包括 1 和 2,min_trx_id 的值就是 1,max_trx_id的值就是 4。
- (4) creator_trx_id:表示生成该 ReadView 的事务的事务 id。
4.2 访问控制
有了这个 ReadView,这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:
- (1) 如果被访问版本的 trx_id 属性值与 ReadView 中的 creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
- (2) 如果被访问版本的 trx_id 属性值小于 ReadView 中的 min_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。
- (3) 如果被访问版本的 trx_id 属性值大于或等于 ReadView 中的 max_trx_id值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。
- (4) 如果被访问版本的 trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id之间(min_trx_id < trx_id < max_trx_id),那就需要判断一下 trx_id 属性值是不是在m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
- (5) 如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。
4.3 再谈隔离
对于使用 READ UNCOMMITTED 隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了。
对于使用 SERIALIZABLE 隔离级别的事务来说,InnoDB 使用加锁的方式来访问记录。
在 MySQL 中,READ COMMITTED 和 REPEATABLE READ 隔离级别的的一个非常大的区别就是它们生成 ReadView 的时机不同。
4.3.1 READ COMMITTED(读已提交)
读已提交,每次读取数据前都生成一个 ReadView。
假设现在有一个使用 READ COMMITTED 隔离级别的事务开始执行:
详解查询:
#使用 READ COMMITTED 隔离级别的事务 #Transaction 100、200未提交,得到的列 name 的值为 刘备 SELECT name FROM t_people WHERE number = 1;
这个 SELECET 的执行过程如下:
- (1) 在执行 SELECT 语句时会先生成一个 ReadView,ReadView 的 m_ids 列表的内容就是[100, 200],min_trx_id 为 100,max_trx_id 为 201,creator_trx_id 为 0。
- (2) 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的内容是诸葛亮,该版本的 trx_id 值为 200,在 m_ids 列表内,所以不符合可见性要求。(如果被访问版本的 trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id之间,就需要判断一下 trx_id 属性值是不是在m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问 ),根据 roll_pointer 跳到下一个版本。
- (3) 诸葛亮下一个版本的列name的内容是赵云,该版本的trx_id值也为200,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
- (4) 赵云下一个版本的列name的内容是张飞,该版本的trx_id值也为100,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
- (5) 张飞下一个版本的列name的内容是关羽,该版本的trx_id值也为100,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
- (6) 关羽下一个版本是刘备,该版本的trx_id值为80,小于ReadView 中的 min_trx_id 值,所以这个版本是符合要求的。
不可重复读: 100事务、200事务开启读取到name都为刘备。当100事务提交时,由于是读已提交事务隔离级别,每次读取都会创建ReadView,200事务读取时,创建生成的ReadView m_ids 为 [200],这时根据读取规则读取到的name就为张飞。
# 使用 READ COMMITTED 隔离级别的事务 BEGIN; # SELECE1:Transaction 100、200 均未提交,得到name值为刘备 SELECT name FROM t_people WHERE number = 1; # SELECE2:Transaction 100 提交,Transaction 200 未提交 #Transaction 200 事务查询,得到name值为张飞,发生不可重复读。 SELECT name FROM teacher WHERE number = 1;
4.3.2 REPEATABLE READ(可重读)
可重读,在第一次读取数据时生成一个 ReadView。
解决不可重复读: 100事务、200事务开启,创建ReadView,m_ids 为[100,200],读取到name都为刘备。当100事务提交时,由于是可重读事务隔离级别,只创建一次ReadView,m_ids 仍然是[100,200],这时根据读取规则读取到的name仍然是刘备。
5 幻读
当一个事务同样的查询条件查询两次(多次),查出的条数不一致称为幻读。
在 REPEATABLE READ 隔离级别下的事务 T1 先根据某个搜索条件读取到多条记录,然后事务 T2 插入一条符合相应搜索条件的记录并提交,然后事务 T1 再根据相同搜索条件执行查询。结果会是什么?按照 ReadView 中的比较规则,不管事务 T2 比事务 T1 是否先开启,事务 T1 都是看不到 T2 的提交的。但是,在 REPEATABLE READ 隔离级别下 InnoDB 中的 MVCC 可以很大程度地避免幻读现象,而不是完全禁止幻读。
#SELECT:快照读。update:当前读。 REPEATABLE READ 可以解决快照读幻读问题。解决不了当前读幻读的问题。
案例:
1 执行begin,执行select *。
2 开启另一个窗口 执行insert、select、commit。
3 回到原窗口执行查询
4 执行 update 、提交
5 查找
6 总结
从上边的描述中我们可以看出来,所谓的 MVCC(Multi-Version ConcurrencyControl ,多版本并发控制)指的就是在使用 READ COMMITTD、REPEATABLE READ这两种隔离级别的事务在执行普通的 SELECT 操作时访问记录的版本链的过程,这样子可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。
READ COMMITTD、REPEATABLE READ 这两个隔离级别的一个很大不同就是,生成 ReadView 的时机不同,READ COMMITTD 在每一次进行普通 SELECT 操作前都会生成一个 ReadView,而 REPEATABLE READ 只在第一次进行普通 SELECT 操作前生成一个 ReadView,之后的查询操作都重复使用这个 ReadView 就好了,从而基本上可以避免幻读现象。