前言
本博客主要是对《MySQL是怎样运行的》一书的内容进行整理
MVCC本身与undo log强相关,在正式介绍之前需要对undo log和redo log的概念进行汇总:
InnoDB事务日志(redo log 和 undo log)详解
概括而言,redo log本身为对执行指令的记录,包含了生成undo log的内容
同时redo log需要持久化,undo log并不需要
第21章 事务隔离级别和MVCC
我们希望让不同的事务“隔离”地执行,互不干涉。这也就是事务的隔离性。
串行执行过于严格,会严重降低系统吞吐量和资源利用率,增加事务的等待时间,因此需要采用其他机制来保证并发执行的事务的执行结果与串行执行的结果一样。
两个并发的事务在执行过程中访问相同数据的情况有四种:读——读,读——写,写——读,——写——写。只有在至少一个事务对数据进行写操作时,才可能带来一致性问题。
这一致性通常通过加锁来搞定,不过即使是可串行化执行,性能上也会有一定的损失。因此我们可以选择牺牲一部分隔离性来换取性能上的提升。
事务并发执行时遇到的一致性问题
脏写(Dirty Write)
如果一个事务修改了另一个未提交事务修改过的数据,就意味着发生了脏写现象。
示意图如下:
如上图,Session A
和Session B
各开启了一个事务,Session B
中的事务先将number
列为1
的记录的name
列更新为'关羽'
,然后Session A
中的事务接着又把这条number
列为1
的记录的name
列更新为张飞
。如果之后Session B
中的事务进行了回滚,那么Session A
中的更新也将不复存在,这种现象就称之为脏写
。这时Session A
中的事务就很懵逼,我明明把数据更新了,最后也提交事务了,怎么到最后说自己啥也没干呢?
脏读(Dirty Read)
如果一个事务读到了另一个未提交事务修改过的数据,就意味着发生了脏读现象。
示意图如下:
如上图,Session A
和Session B
各开启了一个事务,Session B
中的事务先将number
列为1
的记录的name
列更新为'关羽'
,然后Session A
中的事务再去查询这条number
为1
的记录,如果读到列name
的值为'关羽'
,而Session B
中的事务稍后进行了回滚,那么Session A
中的事务相当于读到了一个不存在的数据,这种现象就称之为脏读
。
不可重复读(Non-Repeatable Read)
如果一个事务执行过程中能读取到另一个已提交事务修改过的数据,并且其他事务每次对数据进行修改后该事务都能读取到最新值,每次读取到的值不同,就被称为不可重复读。
示意图如下:
如上图,我们在Session B
中提交了几个隐式事务(注意是隐式事务,意味着语句结束事务就提交了),这些事务都修改了number
列为1
的记录的列name
的值,每次事务提交之后,如果Session A
中的事务都可以查看到最新的值,这种现象也被称之为不可重复读
。
幻读(Phantom)
如果一个事务先根据某些搜索条件查询出一些记录,在该事务未提交时,另一个事物写入了一些符合那些搜索条件的记录(这里的写入可以指insert、delete、update等操作),导致再次查询时查询到的记录个数不同,即被称为幻读。
示意图如下:
如上图,Session A
中的事务先根据条件number > 0
这个条件查询表hero
,得到了name
列值为'刘备'
的记录;之后Session B
中提交了一个隐式事务,该事务向表hero
中插入了一条新记录;之后Session A
中的事务再根据相同的条件number > 0
查询表hero
,得到的结果集中包含Session B
中的事务新插入的那条记录,这种现象也被称之为幻读
。
* 使用delete删除对应的记录,导致读取的记录变少,这种情况不属于幻读
概念梳理示意图
概念 | 含义 |
脏写 | 两个事务执行过程中可以写同一个数据,导致roll back发生问题 |
脏读 | 一个事务可以读取到还未提交的事务的数据 |
不可重复读 | 一个事务执行过程中会有别的事务不断提交,该事务可以可以不断读取到不同的别的事务修改过的值 |
幻读 | 一个事务执行过程中别的事务会插入数据,导致同一个搜索条件前后两次得到的记录条数不同。 |
SQL标准中的4种隔离级别
从严重性而言,
脏写>脏读>不可重复读>幻读
前文所说的,“舍弃一部分隔离性来换取一部分性能”在这里就体现为:设立一些隔离级别,隔离级别越低,就越可能发生越严重的问题。
在SQL标准中有4个隔离级别,如下所示:
隔离级别 | 脏读 | 不可重复读 | 幻读 |
未提交读 | 可能 | 可能 | 可能 |
已提交读 | 不可能 | 可能 | 可能 |
可重复读 | 不可能 | 不可能 | 可能 |
可串行化 | 不可能 | 不可能 | 不可能 |
从上到下,隔离性越来越好,性能越来越差。
* 脏写问题太过严重,任何隔离级别都不会发生脏写
值得注意的是,MySQL的默认隔离级别为,可重复读
MVCC原理
版本链
对于InnoDB存储引擎的表来说,它的聚簇索引记录中都包含下面这两个必要的隐藏列:
* trx_id:一个事务每次对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列(事务的事务id由表通过全局变量机制进行分配)
* roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志汇总。这个隐藏列就相当于一个指针,可以通过它找到该记录修改前的信息。
补充问题:是否可以在两个事务中交叉更新同一条记录呢?
答:不可以。InnoDB使用锁来保证不会出现脏写现象。一个事务想要更新一个记录时,需要等待领域给事务提交,把关于该条记录的锁给释放后才可以继续更新。
每队记录进行一次改动,都会记录一条undo日志。每条undo日志也都有一个roll_pointer属性,通过这个属性可以将这些undo日志串成一个链表,如下图所示:
同一条记录的所有undo日志(旧值版本)都会被roll_pointer属性连接成一个链表,这个链表称之为版本链。 版本链的头结点就是当前记录的最新值。另外,每个版本中还包含生成该版本时对应的事务id。之后可以通过这个记录的版本链来控制并发事务访问相同记录时的行为,我们把这种机制称之为多版本并发控制(Multi-Version Concurrency Control,MVCC)
ReadView
对于使用Read Uncommitted隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了。
对于使用Seriallizable隔离级别的事务来说,使用加锁的方式来访问记录。
对于使用“读已提交”和“可重复读”隔离级别的事务来说,都必须保证读到已经提交过的事务修改过的版本。也就是说,如果另一个事务已经修改了记录但是尚未提交,则不能直接读取最新版本的记录。
核心问题:需要判断版本链中的哪个版本是当前事务可见的。
为了解决这个问题,提出了ReadView(一致性视图)的概念,该概念里包含了下述几个重要参数:
* m_ids:在生成ReadView时,当前系统中活跃的读写事务的事务id表
* min_trx_id:在生成ReadView时,当前系统中活跃的读写事务中最小的事务id;即m_ids中的最小值。
* max_trx_id:在生成ReadView时,系统应该分配给下一个事务的事务id值。
* creator_trx_id:生成该ReadView的事务的事务id。
有了这个ReadView后,在访问某条记录时,只需要按照下面的步骤来判断记录的某个版本是否可见。(读已提交)
* 如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问
* 如果被访问版本的trx_id属性值小于ReadView中的min_trx_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问
* 如果被访问版本的trx_id属性值大于或等于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问
* 如果被访问版本的trx_id属性值在ReadView的min_trx_id和max_trx_id之间,则需要判断trx_id属性值是否在m_ids列表中。如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问。如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。
顺着版本链不断找记录,直到版本链的最后一个版本。如果记录的最后一个版本也不可见,就意味着该条记录对当前事务完全不可见,查询结果就不应该包括该条记录。
读已提交和可重复读的核心区别
读已提交保证只能读已经提交的事务的数据
可重复读除了保证只读已经提交的事务的数据,还要保证判断已提交事务的时机,不读自己事务执行过程中新提交的事务所更新的数据。
也因此,二者的ReadView创建时机也不同。
读已提交:每次读取数据前都生成一个ReadView
可重复读:在第一次读取数据时会生成一个ReadView,之后的查询就不会重复生成ReadView了,保证了信息的一致性。
二级索引与MVCC
只有在聚簇索引记录中才有trx_id和roll_pointer隐藏列。如果某个查询语句是使用二级索引来执行查询的,该如何判断可见性呢?
判断二级索引可见性的过程大致分为下面两步
步骤1:二级索引页面的Page Header部分有一个名为PAGE_MAX_TRX_ID的属性,表示修改该二级索引页面的最大事务id。当select语句访问某个二级索引目录时,首先会看一下对应的ReadView的min_trx_id是否大于该页面的max_trx_id属性。如果是,说明该页面中的所有记录都对该ReadView可见,否则就得执行步骤2,回表后检查。
步骤2:回表后按照之前的流程,对聚簇索引版本链进行查找,寻找可见的最新记录。
关于purge
insert undo日志在事务提交之后就可以释放掉了,而update undo日志由于还需要支持MVCC,因此不能立即删除掉。
为了支持MVCC,delete mark操作仅仅是在记录上打一个删除标记,并没有真正将记录删除。
问题:打了删除标记的记录不应该一直存在,一直存在会很浪费空间。我们应该在合适的时候把update undo日志以及仅仅被标记为删除的记录彻底删除掉,这个删除操作就称为purge。
update undo日志和被标记为删除的记录只是为了支持MVCC而存在的。只要系统中最早产生的那个ReadView不再访问它们,它们的使命就结束了,就可以被删除了。
具体执行逻辑为:
InnoDB把当前系统所有的ReadView按照创建时间连成了一个链表。当执行purge操作时,把系统中最早生成的ReadView给取出来。如果当前系统中不存在ReadView就临时创建一个。然后从各个回滚daunt的history链表中取出事务no值较小的各组undo日志。如果一组undo日志的事务no值小于当前系统最早生成的ReadView事务值时,就以为着该组undo日志没用,可以删除并释放空间了。如果该组undo日志包含delete mark,则需要将相应标记对应的记录给彻底删除。
* 如果某个事务使用可重复读,而且该事务一直没提交,那么最早生成的ReadView就一直不会释放,系统中的update undo日志和打了删除标记的记录就会越来越多,影响系统性能。
这个purge操作会通过专门的后台线程定时处理(应该并不是每完成一个事务就执行一次)。