MVCC多版本并发控制实现原理

InnoDB多版本并发控制

InnoDB 是一个数据多版本的存储引擎。它保留有关已更改行的旧版本的信息,以支持并发性和回滚等事务性特性。此信息存储在系统表空间中,或在undo 表空间中称为回滚段的数据结构中。InnoDB使用回滚段中的信息来执行事务回滚中所需的撤消操作,它还会使用这些信息来构建具有一致性读的行的早期版本(即多版本快照读)。

InnoDB默认会为每行数据添加三个隐式字段

  • 一个6字节的DB_TRX_ID字段表示插入或更新该行的最后一个事务的事务标识符。此外,删除在内部被视为一个更新,其中行中的一个特殊位被设置为将其标记为已删除。

  • 一个7字节的DB_ROLL_PTR字段,称为回滚指针。回滚指针指向undo log中的回滚段记录。如果行被更新,则undo log日志将记录更新行之前重建行内容所需的信息。(实际就是通过此指针,将最新行记录和旧的版本记录链接在一起,成为一个链表,MVCC机制的快照读用到了此链表)

  • 一个6字节的DB_ROW_ID字段包含一个行ID,它随着插入新行而单调地增加。如果InnoDB自动生成一个聚簇索引,则该索引包含行ID值。否则,DB_ROW_ID列将不会出现在任何索引中。

回滚段中的undo log分为insert日志和update日志。Insert undo logs仅在事务回滚时需要,一旦事务提交,可以立刻丢弃。只有在没有事务为InnoDB分配了一个快照,在快照读中可能需要Update undo logs中的信息来构建数据库行的早期版本时,它们才能被丢弃。 (即当前非最新的update undo log没有任何事务用它来构建快照读视图时可以丢弃)。

建议定期提交事务,包括只有快照读的事务,否则,InnoDB不能丢弃update undo log中的旧记录数据, rollback segment可能会越来越大,从而填充增大它所在的表空间。

回滚段的undo log记录的物理大小通常小于相应的插入或更新的行,你可以使用此信息来计算回滚段所需的空间。

在InnoDB多版本控制方案中,当你使用sql语句删除行时,不会立即从数据库中物理删除该行。InnoDB只有在丢弃为删除写入的update undo log记录时,才会物理删除相应的行及其索引记录 。这个删除操作称为清除,它的清除速度非常快,通常与执行删除的sql语句相同。

如果你在表中以几乎相同的速率插入和删除行,清除线程可能会开始滞后,并且因“dead”行表越来越大,使得所有行都被磁盘绑定,速度非常慢。在这种情况下,请限制新的行操作,并通过调整innodb_max_purge_lag系统变量来为清除线程分配更多的资源 。

多版本并发控制和辅助索引

InnoDB多版本并发控制(mvcc)对辅助索引的处理不同于聚簇索引。聚簇索引中的记录将就地更新,它们隐藏的系统列指向undo log日志项,可以从中重建早期版本的记录。 不同于聚簇索引,辅助索引记录不包含隐藏的系统列,也不进行就地更新。

当辅助索引列更新时,将对旧的辅助索引记录进行删除标记,插入新记录,并最终清除有删除标记的记录。 当辅助索引记录被标记删除或辅助索引页被新的事务更新时,innodb将在聚簇索引中查找数据库记录。在聚簇索引中,检查记录的DB_TRX_ID,如果在启动读取事务后修改了记录,则从undo log中检索记录的正确版本。

如果辅助索引记录被标记需要删除或辅助索引页被新的事务更新,InnoDB在聚簇索引结构中查找记录,而不是从索引结构中返回值。

但是,如果启用了索引条件下推(icp)优化,并且其中的where条件只能使用索引中的字段进行评估,mysql服务器仍然将这部分的where条件下推到存储引擎,在那里使用索引对其进行评估。如果没有找到匹配的记录,则会避免进行聚簇索引查找 。如果找到匹配的记录,即使是在有删除标记的记录中,InnoDB也会在聚簇索引中查找该记录。

MVCC实现原理小结

通过上面官方的概述,我们知道,MVCC是为了提高数据库读写并发而采取的一种数据多版本控制机制。数据库并发除了读写并发还有写写并发,写写并发只能通过程序控制,悲观锁或乐观锁等机制去灵活解决。MVCC只解决读写并发,使读写能并发执行,从而提高数据库事务的执行效率。可以想象,如果数据库读写串行,其效率是极低的,但能绝对的保证数据安全可靠,串行化的事务隔离级别在现实中也是有应用场景的。

当前读和快照读

InnoDB数据库读取数据可以分为当前读和快照读。

  • 当前读:像select lock in share mode(共享锁), select for update ; update, insert ,delete(排他锁)这些操作都是一种当前读,它读取的是记录的最新版本,读取时会对读取的记录进行加锁。

  • 快照读:不加锁的读操作就是快照读。MVCC读写并发执行的核心原理就是快照读,MVCC只工作在事务隔离级别为可重复读(RR)和读已提交(RC)两种隔离级别下。

隐式字段

数据库每行记录中都包含了几个隐式字段以帮助实现MVCC。

  • 一个6字节的DB_TRX_ID字段表示插入或更新该行的最后一个事务的事务标识符。此外,删除在内部被视为一个更新,其中行中的一个特殊位被设置为将其标记为已删除。

  • 一个7字节的DB_ROLL_PTR字段,称为回滚指针。回滚指针指向undo log中的回滚段记录。如果行被更新,则undo log日志将记录更新行之前重建行内容所需的信息。(实际就是通过此指针,将最新行记录和旧的版本记录链接在一起,成为一个链表,MVCC机制的快照读用到了此链表)

  • 一个6字节的DB_ROW_ID字段包含一个行ID,它随着插入新行而单调地增加。如果InnoDB自动生成一个聚簇索引,则该索引包含行ID值。否则,DB_ROW_ID列将不会出现在任何索引中。

undo日志

回滚段中的undo log分为insert日志和update日志。Insert undo logs仅在事务回滚时需要,一旦事务提交,可以立刻丢弃。mvcc的实现主要是利用update日志。update日志是实现mvcc的关键,结合隐式字段实现了快照读。下面举个例子了解下update日志链是如何形成的。

假设事务1在course表中插入了一条新记录,数据如下所示:

course_no course_name teacher_no DB_TRX_ID DB_ROLL_PTR DB_ROW_ID
201 历史 001 1 null 1

事务2在事务1提交后,修改teacher_no的值为002。事务2首先对当前行添加排他锁,然后把该行数据拷贝到undo log中,最后将当前行的teacher_no的值修改为002,修改隐式字段DB_TRX_ID的值为2(默认递增),修改DB_ROLL_PTR的值为旧记录在undo log中的地址,假设是0x123456789,也就是上一个版本数据的地址。最后,提交事务。假设undo log还没清理,此时数据与undo log通过回滚指针形成一个链。

事务2

course_no course_name teacher_no DB_TRX_ID DB_ROLL_PTR DB_ROW_ID
201 历史 002 2 0x123456789 1

undo log

course_no course_name teacher_no DB_TRX_ID DB_ROLL_PTR DB_ROW_ID
201 历史 001 1 null 1

如下,通过回滚指针可以找到旧记录,为数据的多版本读取提供了路径。

MVCC多版本并发控制实现原理_第1张图片

假设又来了一个更新事务,把teacher_no的值修改为003,此时DB_TRX_ID为2的记录将会链接在undo log的链表头,最初的记录在链尾,以此类推

事务3

course_no course_name teacher_no DB_TRX_ID DB_ROLL_PTR DB_ROW_ID
201 历史 003 3 0x15875644 1

undo log

course_no course_name teacher_no DB_TRX_ID DB_ROLL_PTR DB_ROW_ID
201 历史 003 2 0x123456789 1
201 历史 001 1 null 1

这样一来,在读写并发时,我们就可以实现数据多版本读了。(注意,undo log的记录会被purge线程统一清除,速度是比较快的,基本可以认为当修改事务提交后,只要当前没有生成任何读视图使用到undo log的记录,purge线程会直接清理)

Read View(读视图)

Read View读视图,即快照读产生的一个数据视图,只有处在此视图中的版本数据才能被事务读取。这个Read View的主要作用是用来做可见性判断的,即,同一行数据有多个版本,那么事务能读取哪个版本的数据由此读视图决定。Read View的可见性算法其实就是MVCC多版本并发控制的精髓了。

上面我们知道了一行数据在读写并发时可能出现版本链,那么不同的事务能看到数据版本可能就是不同的,能看到哪些数据则Read View决定,所以Read View的可见性算法我们有必要了解一下,才能明白事务能看到哪些数据的依据。

Read View源码大致如下:

class ReadView {
​
    /**
     * 创建这个快照的事务ID
     */
    trx_id_t    m_creator_trx_id;
​
    /**
     * 生成这个快照时处于活跃状态的事务ID的列表,
     * 是个已经排好序的列表
     */
    ids_t       m_ids;
​
    /** 
     * 高水位线:id大于等于 m_low_limit_id 的事务都不可见。
     * 在生成快照时,它被赋值为“下一个待分配的事务ID”(会大于所有已分配的事务ID)。
     */
    trx_id_t    m_low_limit_id;
​
    /**
     * 低水位线:
     * 它是活跃事务ID列表的最小值,在生成快照时,小于m_up_limit_id的事务都已经提交(或者回滚)。
     */
    trx_id_t    m_up_limit_id;
​
​
    // 判断事务是否可见的方法
    bool changes_visible(){}
​
    // 关闭快照的方法
    void close(){}
    // ...
}

可见性函数(下面的id可以理解为版本链中的数据的隐式事务ID)

bool changes_visible(){
​
        /**
         * 可见的情况:
         *  1. 小于低水位线,即创建快照时,该事务已经提交(或回滚)
         *  2. 事务ID是当前事务。
         */
        if (id < m_up_limit_id || id == m_creator_trx_id) {
            return(true);
        }
​
        if (id >= m_low_limit_id) { /* 高于水位线不可见,即创建快照时,该事务还没有提交 */
            return(false);
​
        } else if (m_ids.empty()) { /* 创建快照时,没有其它活跃的读写事务时,可见 */
​
            return(true);
        }
​
        /**
         * 执行到这一步,说明事务ID在低水位和高水位之间,即 id ∈ [m_up_limit_id, m_low_limit_id)
         * 需要判断是否属于在活跃事务列表m_ids中,
         * 如果在,说明创建快照时,该事务处于活跃状态(未提交),修改对当前事务不可见。
         */
​
        // 获取活跃事务ID列表,并使用二分查找判断事务ID是否在 m_ids中
        const ids_t::value_type*    p = m_ids.data();
        return(!std::binary_search(p, p + m_ids.size(), id));
}

通过上面源码及注释,我们即可了解Read View的可见性算法。Read View主要有4个核心属性,m_creator_trx_id(视图创建事务ID)、m_ids(创建视图时正在活跃的事务ID列表)、m_low_limit_id(下一个待分配的事务ID,当前最大事务+1)、m_up_limit_id(活跃事务ID列表的最小值)。一个事务对某版本的数据是否可见取决于这个读视图。当前事务会尝试从最新版本开始读取数据,但是是否可见则由读视图的下面的算法决定,顺着链读取,直到读取到一行可见数据为止。下面根据上面的源码片断总结下其可见性算法的判断流程:

  1. 如果DB_TRX_ID等于当前事务ID,说明这行数据是当前事务创建的,自然是可见的

  2. 如果DB_TRX_ID

  3. 如果DB_TRX_ID >= m_low_limit_id,说明当前数据版本由读视图生成后,新的事务所提交,对于当前读视图来说就是不可见的

  4. 如果创建视图时,没有其它活跃的读写事务时,可见

  5. 如果都不是上面的情况,说明当前版本事务数据在最小到最大事务之间,即m_low_limit_id=m_up_limit_id,此时分两种情况,一是DB_TRX_ID 就是活跃列表中的一个事务,即生成读视图时该事务处于活跃状态(未提交),修改对当前事务不可见。如果不在,说明这个事务在Read View生成之前就已经Commit了,所以可见。

流程模拟

了解完可见性算法后,我们可以模拟几个事务和一些数据版本,来判断一下不同事务所能看到的数据版本,以加深理解。假设有如下4个事务,它们的事务ID分别 是1、2、3、4。其事务状态如表格

事务1 事务2 事务3 事务4
事务开始 事务开始 事务开始 事务开始
... .. ... 修改已提交
数据修改进行中 快照读 数据修改进行中
... ... ...

可以看出,事务2在进行快照读,事务1和3对数据进行修改但未提交,事务4也修改了数据,但提交了数据。其版本链数据可能如下(DB_TRX_ID的值代表上面的事务)

最新记录

course_no course_name teacher_no DB_TRX_ID DB_ROLL_PTR DB_ROW_ID
201 历史 003 4 0x15875644 1

undo log记录

course_no course_name teacher_no DB_TRX_ID DB_ROLL_PTR DB_ROW_ID
201 历史 003 3 0x123456789 1
201 历史 001 1 0x89243 1

事务2并没对数据进行修改,只是进行了修改,那么在版本链上有三条数据,那么事务2生成的快照能看到哪条数据呢?根据上面的可见性算法,我们先计算出m_low_limit_id的值为5(最大事务ID+1,4+1),m_ids活跃事务ID列表为1,3,那么m_up_limit_id的值就是1了。有了这些数据就可以根据可见性算法得出事务2可见的数据版本了。首先是拿到最新记录的DB_TRX_ID的值,即4。

  • 4不是当前事务(2),比m_up_limit_id(1)最小事务要大,进行下一个判断

  • 4

  • 活跃的事务列表不为空,进行下一个判断

  • 4不包含在活跃事务列表,证明当前记录是在读视图提交之前提交的,因此此版本数据可见

上面是最新记录即可见的情况,如果出现最新记录不可见,即在最大事务之后提交的数据,此时会根据回滚指针找到下一条数据,按以上可见性算法进行判断,直到找到一条可见数据版本为止。

可重复读(RR)和读已提交(RC)隔离级别的区别

InnoDB的四个隔离级别分别为读未提交(RU)、可重复读(RR)、读已提交(RC)和串行化。其中可重复读(RR)和读已提交(RC)的事务隔离实际就是使用MVCC机制实现,RR之所以可重复读,是因为事务生成读视图后,在事务提交前都是使用同一读视图,因此总能读到相同的数据。读已提交总能读取到最新提交数据是因为每次读取都重新生成读视图。

你可能感兴趣的:(mysql,数据库,java,链表)