MySQL的MVCC
MySQL的MVCC
一、什么是MVCC
MVCC是Multi-Version Concurrency Control的简称,即多版本并发控制。MVCC是现代数据库引擎实现中常用的处理读写冲突的手段,目的在于提高数据库高并发场景下的吞吐性能。如此一来不同的事务在并发过程中,select操作可以不加锁而是通过MVCC机制读取指定的版本历史记录,并通过一些手段保证读取的记录值符合事务所处的隔离级别,从而解决并发场景下的读写冲突。
下面举一个多版本读的例子,例如两个事务A和B按照如下顺序进行更新和读取操作
transaction A | transaction B |
---|---|
select x from table; return 10 | |
begin transaction | |
update table set x=20 | |
begin transaction | |
select x from table ; return rs1 | |
commit | |
select x from table ; return rs2 | |
commit |
在事务A提交前后,事务B读取到的X的值是什么呢?答案是:事务B在不同的隔离级别下,读取到的值不一样。
- 如果事务B的隔离级别是读未提交,那么两次读取均读取到X的最新值,即20。
- 如果事务B的隔离级别是读已提交,那么第一次读取到的是旧值10,第二次因为事务A已经提交,则读取到新值20。
- 如果事务B的隔离级别是可重复读或者串行,则两次均读到旧值10,不论事务A是否已经提交
二、为什么需要MVCC
InnoDB相比MyISAM有两大特点,一是支持事务二是支持行级锁,事务的引入带来了一些新的挑战。相对于串行处理来说,并发事务处理能大大增加数据库资源的利用率,提高数据库系统的事务吞吐量,从而可以支持更多的用户。但并发事务处理也会带来一些问题,主要包括一下几种情况:
- 更新丢失:当两个或多个事务选择同一行,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,就会发生丢失更新的问题-- 最后的更新覆盖了其他事务所做的更新。如何避免这个问题呢?最好在一个事务对数据进行更改但还未提交时,其他事务不能访问修改同一个数据。
- 脏读:一个事务正在对一条记录做修改,在这个事务提交前,这时,另一个事务夜来读取同一条记录并且读到了修改后尚未提交的数据,并依据此做了进一步的处理。这种现象就被形象的叫做脏读
- 不可重复读:指在一个事务内,多次读同一数据,前后读取的结果不一致。
- 幻读:幻读是指当事务不是独立执行时发生的一种现象,例如事务A对表中的一个数据进行了修改,这种修改涉及到表中的全部数据行。同时事务B也修改了这个表中的数据,这种修改是向表中插入一行新数据。那么就会发生操作事务A的用户发现表中还存在没有修改的数据行,就好像发生了幻觉一样。
以上是并发事务过程中会存在的问题,解决更新丢失可以交给应用,但是后三者需要数据库提供事务间的隔离机制来解决。实现隔离机制的方法主要有两种:
- 加读写锁
- 一致性快照读,即MVCC
本质上,隔离级别是一种在并发性能和并发产生的副作用间的妥协,通常数据库均倾向于后者采用 weak isolation。
三、InnoDB MVCC实现原理
InnoDB中MVCC的实现方式为:每一行记录都有两个隐藏列:DATA_TRX_ID、DATA_ROLL_PTR(如果没有主键,则还会多一个隐藏的主键列)。
DATA_TRX_ID:记录最近更新这条记录的事务ID,大小为6字节
DATA_ROLL_PTR:表示指向该行回滚段(rollback segment)的指针,大小为7个字节,InnoDB便是通过这个指针找到之前版本的数据。该行记录上所有旧版本,在undo log中通过链表的形式组织。
DB_ROW_ID:行标识(隐藏自增ID),大小为6字节,如果表没有主键,InnoDB会自动生成一个隐藏主键,因此会出现这个列。另外,每条记录的头信息(record header)里都有一个专门的bit(deleted_flag)来表示当前记录是否已经被删除。
undo log
MySQL中有六种日志文件。分别是重做日志(redo log)、回滚日志(undo log)、二进制日志(bin log)、错误日志(error log)、慢查询日志(slow query log)、一般查询日志(general log)。其中redo log、undo log、bin log与事务操作息息相关。
undo log是将用户上一步做的操作对程序造成的改动恢复到改动之前,和redo log的区别是redo log是指重新实现这种改动。
3.1版本链
在多个事务并行操作某行数据的情况下,不同事务对该行数据的update会产生多个版本,然后通过回滚指针组织成一条 undo log链,下面我们通过一个简单的例子来看一下 undo log 链是如何组织的,DATA_TRX_ID和DATA_ROLL_PTR两个参数在其中又起到什么样的作用。
还以上文MVCC的例子,事务A对值X进行更新之后,该行即产生一个新版本和旧版本。假设之前插入该行的事务ID为100,事务A的ID为200,该行的隐藏主键为1。
事务A的操作过程为:
- 对DB_ROW_ID=1的这行记录加排他锁
- 把该行原本的值拷贝到undo log中,DB_TRX_ID和DB_ROLL_PTR都不动
- 修改该行的值这时产生一个新版本,更新DATA_TRX_ID为修改记录的事务ID,将DATA_ROLL_PRT指向刚刚拷贝到undo log链中的旧版本记录,这样就能通过DATA_ROLL_PTR找到这条记录的历史版本。如果对同一行记录执行连续的update,undo log会组成一个链表,遍历这个链表可以看到这条记录的变迁
- 记录redo log,包括undo log中的修改
那么insert和delete会怎么做呢?其实相比update这两者很简单,insert会产生一条新记录,它的DATA_TRX_ID为当前插入记录的事务ID;delete某条记录时可看成是一种特殊的update,其实是软删,真正执行删除操作会在commit时,DATA_TRX_ID则记录下删除该记录的事务ID。
这里还有一个问题就是,当前事务在启动时看到的内容是哪个版本的?这里就需要视图 read-view了。而且不同时刻启动的事务会有不同的read-view。
3.2如何实现一致性读 ReadView
在读未提交隔离级别下,直接读取新版本的记录了;在串行化隔离级别下,通过加锁互斥来访问数据,因此不需要MVCC的帮助。因此MVCC运行在读提交、可重复读 这两个隔离级别下,当InnoDB隔离级别设置为二者其一时,就会用到版本链。
那现在的问题就是哪些版本是对当前启动的事务可见?为了解决这个问题,于是有了ReadView(可读视图)的概念
3.2.1可重复读隔离级别下ReadView的生成
在可重复读隔离级别下,如果是通过begin启动的事务,那么当该事务执行第一个sql语句时,生成ReadView,即会将当前系统中的所有活跃的事务拷贝到一个列表生成ReadView。
下图事务A第一条select语句在事务B更新数据前,因此生成的ReadView在事务A过程中不发生变化,即使事务B在事务A之前提交,但是事务A第二条查询语句依旧无法读到事务B的修改。
transaction A | transaction B |
---|---|
可重复读隔离级别 | |
set tx_isolation=repeatable-read | 可重复读隔离级别 |
set tx_isolation=repeatable-read | |
begin transaction | begin transaction |
select x from table ; return 10 | |
这个时候生成ReadView | |
update table set x=20 | |
commit | |
select x from table; return 10 | |
无法读到x=20 | |
commit |
下图中,事务A的第一条sql语句在事务B的修改提交之后,因此可以读到事务B的修改。但是注意,如果事务A的第一条select语句查询时,事务B还没有提交,那么事务A也查不到事务B的修改。
transaction A | transaction B |
---|---|
可重复读隔离级别 | |
set tx_isolation=repeatable-read | 可重复读隔离级别 |
set tx_isolation=repeatable-read | |
begin transaction | begin transaction |
update table set x=20 | |
commit | |
select x from table ; return 20 | |
这个时候读到了事务B的修改x=20 | |
commit |
3.2.2读提交隔离级别下ReadView的生成
在读提交隔离级别下,每个sql语句(select查询语句)开始时,都会重新将当前系统中的活跃事务拷贝一个列表生成ReadView。二者的区别就在于生成ReadView的时间点不同,一个是事务之后第一个查询语句开始;一个是事务中每条select语句开始。
3.3 ReadView列表
ReadView中当前活跃的事务ID列表,称为m_ids,其中最小值为up_limit_id,最大值为low_limit_id,事务ID是事务开启时InnoDB分配的,其大小决定了事务开启的先后顺序,因此我们可以通过ID的大小关系来决定版本记录的可见性,其判断流程如下:
- 如果被访问版本的trx_id小于m_ids中的最小值up_limit_id,说明生成该版本的事务在ReadView生成之间已经提交了,所以该版本可以被当前事务访问。
- 如果被访问版本的trx_id大于m_ids中的最大值low_limit_id,说明生成该版本的事务在ReadView生成之后才生成,所以该版本不可以被当前事务访问。
- 如果被访问版本的trx_id在m_ids列表中最大值和最小值之间,那就需要判断一下trx_id是不是在m_ids列表中。如果在,说明创建ReadView时生成该版本所属事务还是活跃的,因此该版本不可以被访问,需要查找undo log链找到上一个版本,然后根据该版本的data_trx_id在从头计算一次可见性;如果不在说明创建ReadView时生成该版本的事务已经提交,该版本可以被访问
- 此时经过一系列的判断我们已经得到了这条记录相对ReadView来说的可见结果。此时,如果这条记录的delete_flag为true,说明这条记录已经被删除,不返回。否则说明这条记录可以安全返回给客户端。
四、举个例子
4.1 读提交下的MVCC判断流程
我们现在回看刚刚的查询过程,为什么事务B在读提交隔离级别下,两次查询X值不同。读提交隔离级别下 ReadView是在语句颗粒度上生成的。
当事务A未提交时,事务B进行查询,假设事务B的事务ID为300,此时生成ReadView的m_ids为[200,300],而最新版本的trx_id为200,处于m_ids中,则该版本不可被访问,查询版本链得到上一条记录的trx_id为100,小于m_ids的最小值200,因此可以被访问,此时事务B就查询到值10而非20。
待事务A提交之后,事务B进行查询,此时生成的ReadView的m_ids为[300],而最新的版本记录中trx_id为200,小于m_ids的最小值300,因此可以被访问到,此时事务B就查询到20。
4.2 可重复读下的MVCC判断流程
如果在可重复读隔离级别下,为什么事务B前后两次均查询到10呢?可重复读下生成ReadView是事务开始时,m_ids为[200,300],后面不发生变化,因此即使事务A提交了,trx_id为200的记录依旧处于m_ids中,不能被访问,只能访问版本链中的记录10。
五、写在最后
读提交、可重复读两种隔离级别的事务在执行普通的读操作时,通过访问版本链的方法,使得事务间的读写操作得以并发执行,从而提升系统性能。读提交、可重复读这两个隔离级别的一个很大不同就是生成ReadView的时间点不同,读提交在每一次select语句前都会生成一个ReadView,事务期间会更新,因此在其他事务提交前后所得到的m_ids列表可能发生变化,使得先前不可见的版本后续又突然可见了;而可重复读只在事务的第一个select语句时生成一个ReadView,事务操作期间不更新。