MVCC 笔记
MVCC为了解决什么问题?
多版本并发控制,针对在并发访问数据库时对于数据版本的控制以及隔离性问题,Mysql使用了MVCC的思路来进行版本控制
MVCC的MYSQL 实现浅析?
Mysql 的 MVCC实现大致是通过隐藏列中的DB_ROLL_PTR字段以及undo log的方式生成数据版本链,在创建事务时生成ReadView来进行版本比对,从而筛选出当前事务可见的数据行
事务并发执行会遇到的问题?
脏写(Dirty Write):一个事务修改了另一个事务未提交过的数据
脏读(Dirty Read):一个事务读取到了另一个事务未提交过的数据
不可重复读(Non-Repeatable Read):一个事务只能读取到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事物都能查到最新的值(在一个事务中的多次查询,可以查询到多个其他事务提交的最新值)
幻读(Phantom):一个事务根据某些条件查询出一些记录之后,另一个事务又向表中插入了符合这些条件的记录,原先的事务用相同的条件再次查询时,能把另外一个事务插入的数据也查询出来
按问题严重性排序:
脏写 > 脏读 > 不可重复读 > 幻读
标准的四种 SQL事务隔离级别(并非Mysql定义)
Read UnCommittd:未提交读
Read Committd:已提交读
Repeatable Read:可重复读
Serializable:可串行化
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
Read UnCommittd | Possible | Possible | Possible |
Read Committd | Not Possible | Possible | Possible |
Repeatable Read | Not Possible | Not Possible | Possible |
serializable | Not Possible | Not Possible | Not Possible |
也就是说:
在Read UnCommittd 隔离级别下,可能发生脏读、不可重复读和幻读问题
在Read Committd 隔离级别下,可能发生不可重复读和幻读问题
在Repeatable Read 隔离级别下,可能会发生幻读(但是在Mysql中,Repeatable Read隔离级别可以处理幻读的问题)
在serializable 隔离级别下,各种问题都不会发生
至于脏写,应为脏写实在太严重了,所以无论哪个隔离级别都不允许脏写的情况发生。
什么是版本链 ? undo日志 ?
undo日志:用于记录事务中未提交的变更记录,主要用于保证事务的原子性,任何对数据的操作都会记录到undo日志中,直到提交事务或者rollback,才会进行清理。
说起版本链,我们得先有行格式的概念,我们大概看一下Compact格式下所看到的一行数据的格式:
在一个正常的行信息中,除了记录了用户的真实记录以外,innoDB还会为每条记录都添加2个隐藏列以及一个可选列
列名 | 是否必须 | 占用空间 | 描述 |
---|---|---|---|
DB_TRX_ID | 是 | 6字节 | 事务ID |
DB_ROLL_PTR | 是 | 7字节 | 回滚指针 |
DB_ROW_ID | 否 | 6字节 | 行id,唯一标识一条记录 |
DB_ROW_ID 在没有自定义主键以及存在非Null的Unique键时才会添加该列
这里来简单阐述一下DB_TRX_ID以及DB_ROLL_PTR在版本链中的作用
DB_TRX_ID:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给该记录的DB_TRX_ID隐藏列,注意事务id是递增的。
DB_ROLL_PTR:每次对某条聚簇索引记录改动时,都会将旧的版本写入到 undo日志中,然后然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
注意insert是不会产生DB_ROLL_PRT的,因为insert时并没有更早的版本存在
了解到这里我们大概就能看到版本链的雏形了,也就是利用了DB_ROLL_PTR来链接上一个版本的数据;
我们以一个hero表为例:
假如我们有一个hero表其中number为1的记录name初始化为刘备,我们执行如下两个语句:
它的版本链大概就是下面这个样子:
每次对该记录更新后,都会将旧值放到 undo 日志中,随着更新次数的增多,所有版本都会被DB_ROLL_PRT属性链接成为一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值,另外,每个版本中还包含生成该版本时对应的事务id;
ReadView 是什么?
ReadView 可以按字面意思理解为读视图,也就是在事务开始时生成的一个快照,ReadView的设计主要是为了解决 "判断版本链中哪个版本是当前事务可见" 的问题
SERIALIZABLE隔离级别采用加锁的方式来访问记录,而READ COMMITTED和 REPEATABLE READ隔离级别在事务的不同阶段会创建ReadView
Read committed 隔离级别下,每次读取数据前都会生产一个ReadView
Repeatable Read 隔离级别下,在第一次读取数据时生产一个ReadView
关于两种隔离级别下产生ReadView时机不同带来的影响,后面描述
ReadView 主要组成结构:
m_ids:表示在生成ReadView时当前系统中活跃的读写事务的事务id列表
min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中最小的值
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
creator_trx_id:表示生成该ReadView的事务的事务id
只有在对表中的记录做改动时(执行Insert、update、delete)才会为事务分配事务id,否则在一个只读事务中,事务id都默认为0
当生成了这个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时生成该版本的事务已经被提交,该版本可以被访问
Read Committd 每次读取数据前都生成一个ReadView
假如现在系统中有两个事务在执行,事务id分别是100、200:
版本链如下:
假设现在有一个使用Read Committd隔离级别的事务开始执行:
那么这个Select1的执行过程如下:
在执行SELECT 语句时会现生成一个ReadView,ReadView的m_ids列表内容为[100,200],min_trx_id为100,max_trx_id为201,creator_trx_id为0
然后从版本链中挑选可见记录,从图中可以看出,最新版本的列name的内容是 '张飞',该版本的事务id为100,在m_ids内,所以不符合可见性要求,根据DB_ROLL_PTR(roll_pointer)找到下一个版本
下一个版本的列name的值为 '关羽',该数据的事务id也为100,在m_ids范围内,不符合可见性要求,继续跳到下一个版本
下一个版本的列name的值为 '刘备',该版本的事务id为80,小于ReadView中的min_trx_id值100,所以这个版本符合可见性要求,最终返回给用户的就是这条name列为 '刘备' 的数据
之后,我们把事务id为100的事务提交一下:
然后再到事务id为200的事务中更新一下hero表中number为1的数据:
此刻表hero中number为1的记录的版本链如下:
然后我们再到刚才使用Read Committd隔离级别的事务中继续查找这个number为1的记录,如下:
其中的Select2的执行过程如下:
在执行SELECT2 语句时又会单独生成一个ReadView,该ReadView的m_ids列表内容是[200](事务id为100的那个事务已经提交了,所以再次生成快照时就没有它了),min_trx_id为200,max_trx_id为201,creator_id为0
然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name值为 '诸葛亮',该版本的事务id为200,在m_ids列表内,所以不符合可见性要求,根据DB_ROLL_PTR(roll_pointer)跳到下一个版本。
下一个版本的列name的值为 '赵云',该版本的事务id为200,在m_ids列表内,所以也不符合要求,继续跳到下一个版本
下一个版本的列name的值为 '张飞',该版本的事务id为100,小于ReadView中min_trx_id的值200,所以符合要求,最后返回给用户的版本就是这条列name为 '张飞' 的记录
可以看到在Read Committd的隔离级别下,出现了不可重复读的场景
在Read Committd隔离级别下,事务在每次查询开始时都会创建一个独立的ReadView,关于Repeatable Read隔离级别下版本链以及执行过程大概类似这里就不阐述了(欢迎讨论),只是在Repeatable Read隔离级别下,在事务中多次读数据时,只会在第一次读取数据时创建ReadView,后面的查询都会复用第一次创建的ReadView,这就保证了前后两次查询到的结果一致,可以尝试使用Repeatable Read隔离级别的特性去看看上面的版本链,select2在Repeatable Read级别下应该返回什么?怎么去理解可重复度?
总结:
从上边的描述中我们可以看出来,所谓的MVCC(Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用Read Committd、Repeatable Read这两种隔离级别的事务在执行普通的SEELCT操作时访问记录的版本链的过程,这样子可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。Read Committd、Repeatable Read这两个隔离级别的一个很大不同就是:生成ReadView的时机不同,Read Committd在每一次进行普通SELECT操作前都会生成一个ReadView,而Repeatable Read只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView就好了。
疑问?
undo log理论上会在事务提交后进行删除,那么版本链如何形成呢?
实际 insert undo 在事务提交之后就可以被释放了,update undo由于还需要支持MVCC,不能立即删除掉,实际在行结构中除了隐藏列还有一个delete mark的标记位,1代表删除,0代表未删除,用来记录数据是否被删除,所以在上面的版本链判断数据时并非是简单的判断事务id,同时还会考虑这个delete_mark标记,同时在mysql中,作者为了减少因为移除数据后的磁盘重新排列的性能问题,还搞了一个所谓的垃圾链表,在这个链表中的记录占用的空间称之为所谓的可重用空间,之后如果有新记录要插入到表中的话,可能会把这些被删除记录占用的存储空间给覆盖掉,当然也并非所有被标记了删除都数据都是覆盖处理,这里就涉及到mysql的后台的purge线程的作用了,后面再去了解