• MySQL:事务2(MVCC)


    参考资料:

    《InnoDB 事务分析-MVCC》

    《MVCC》

    《InnoDB存储引擎对MVCC的实现》

    《正确的理解MySQL的MVCC及实现原理》

    《MySQL 8.0 MVCC 源码解析》

    相关文章:

    《mysql之事务、锁、隔离级别与MVCC》

    《MySQL:事务1(锁与隔离级别)》

            写在开头:本文为学习后的总结,可能有不到位的地方,错误的地方,欢迎各位指正。

    目录

    一、MVCC机制简介

            1、当前读与快照读

                    2、MVCC是什么

    二、MVCC的实现

            1、MVCC 的思想

            2、隐藏字段

            3、undo log与回滚链

            3.1、行记录更新过程

            3.2、undo log分类

            4、回滚链的生成

            insert 时的数据初始状态

            数据第一次被修改时 

            数据第二次被修改时

            5、Read View

            6、可见性算法

    三、MVCC作用流程

     四、补充

            1、purge线程

            2、ICP(Index Condition Pushdown)

            3、RC 和 RR 隔离级别下 MVCC 的差异


    一、MVCC机制简介

            1、当前读与快照读

    • 当前读

            像select lock in share mode(共享锁), select for update ; update, insert ,delete(排他锁)这些操作都是一种当前读,为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁

    • 快照读

            像不加锁的select操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC,可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。

            2、MVCC是什么

             多版本并发控制(Multi-Version Concurrency Control, MVCC)是一种用来解决读-写冲突的无锁并发控制,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。

            所以MVCC可以为数据库解决在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能,同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题。

            MVCC可以视为行级锁的一个变种。它在很多情况下避免了加锁操作,因此开销更低。不仅是 Mysql,包括 Oracle、PostgreSQL 等其他数据库都实现了各自的 MVCC,实现机制没有统一标准。
            
    MVCC 是 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现提交读(RC)和可重复读(RR)这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,要求很低,无需使用 MVCC。可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。

    二、MVCC的实现

            1、MVCC 的思想

            保存数据在某个时间点的快照,写操作(DELETE、INSERT、UPDATE)更新最新的版本快照;而读操作去读旧版本快照,没有互斥关系。

            MVCC 的实现依赖于:隐藏字段、Read View、undo log。

            在内部实现中,InnoDB 通过数据行的 DB_TRX_ID 和 Read View 来判断数据的可见性,如不可见,则通过数据行的DB_ROLL_PTR 找到 undo log 中的历史版本。每个事务读到的数据版本可能是不一样的。脏读和不可重复读最根本的原因是事务读取到其它事务未提交的修改。在事务进行读取操作时,为了解决脏读和不可重复读问题,MVCC 规定只能读取已经提交的快照。在同一个事务中,用户只能看到该事务创建 Read View 之前已经提交的修改和该事务本身做的修改。

            2、隐藏字段

            每行记录除了我们自定义的字段外,还有数据库隐式定义的 DB_TRX_ID, DB_ROLL_PTR等字段:

            DB_TRX_ID:6 byte,最近修改(修改/插入)事务 ID(每开始一个新事务,会递增的生成一个事务ID,用来和查询到的每行记录的版本号进行比较),记录创建这条记录/最后一次修改该记录的事务 ID。此外,delete 操作在内部被视为更新,即并不是真正的删除,只不过会在记录头 Record header 中的 deleted_flag 字段将其标记为已删除。

            DB_ROLL_PTR:7 byte,回滚指针,指向写入回滚段的 undo log 中的上一个版本。

    在这里插入图片描述

            如上图,DB_TRX_ID 是当前操作该记录的事务 ID ,而 DB_ROLL_PTR 是一个回滚指针,用于配合 undo日志,指向上一个旧版本。

            3、undo log与回滚链

            在之前的文章(《MySQL:更新过程(buffer pool与redo、bin、undo log)》)中,我们介绍了,数据更新前会将旧的记录写入 undo log做保存。MVCC 的多版本指的是多个版本的快照,快照存储在 Undo 日志中,该日志通过回滚指针DB_ROLL_PTR把一个数据行的所有快照连接起来。

            3.1、行记录更新过程

            对要更新的行记录加排他锁

    • 写 undo log:将更新前的记录写入 undo log,并构建指向该 undo log 的回滚指针 roll_ptr。
    • 更新行记录:更新行记录的 DB_TRX_ID 属性为当前的事务Id,更新 DB_ROLL_PTR 属性置为上一步骤生成的回滚指针(roll_ptr),将此次要更新的属性列更新为目标值。

            写redo log、bin log,处理结束,释放排他锁

            3.2、undo log分类

            undo log 主要分为两种:

    • insert undo log

            在 insert 操作中产生的 undo log。因为 insert 操作的记录只对事务本身可见,对其他事务不可见,故该 undo log 可以在事务提交后直接删除。不需要进行 purge 操作。

    • update undo log

            事务在进行 update 或 delete 时产生的 undo log ,该 undo log不仅在事务回滚时需要,在快照读时也需要,因此不能在事务提交时就进行删除。只有在快速读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清除。(delete时并不会将数据真正删除,只会将deleted_flag标记为已删除)。

            4、回滚链的生成

            insert 时的数据初始状态

            可以看到,DB_TRX_ID记录了当前事务的ID,因为没有旧版本记录,DB_ROLL_PTR为空。

            数据第一次被修改时 

            DB_TRX_ID更新为了当前事务的ID,DB_ROLL_PTR则指向了undo log中插入时的记录。

            数据第二次被修改时

            同数据第一次被修改时一样,DB_TRX_ID更新为了当前事务的ID,DB_ROLL_PTR则指向了undo log中第一次修改时的记录。

             不同事务或者相同事务的对同一记录行的修改,会使该记录行的 undo log 成为一条链表,链首就是最新的记录,链尾就是最早的旧记录。

            5、Read View

            Read View 就是事务进行快照读操作的时候生产的读视图 (Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照(每个事务都有一个Read View),记录并维护系统当前活跃事务的 ID (当每个事务开启时,都会被分配一个 ID , 这个 ID 是递增的,所以最新的事务,ID 值越大)。

    1. class ReadView {
    2. /* ... */
    3. private:
    4. trx_id_t m_low_limit_id; /* 大于等于这个 ID 的事务均不可见 */
    5. trx_id_t m_up_limit_id; /* 小于这个 ID 的事务均可见 */
    6. trx_id_t m_creator_trx_id; /* 创建该 Read View 的事务ID */
    7. trx_id_t m_low_limit_no; /* 事务 Number, 小于该 Number 的 Undo Logs 均可以被 Purge */
    8. ids_t m_ids; /* 创建 Read View 时的活跃事务列表 */
    9. m_closed; /* 标记 Read View 是否 close */
    10. }
    • m_low_limit_id:目前出现过的最大的事务 ID+1,即下一个将被分配的事务 ID。大于等于这个 ID 的数据版本均不可见。(这里有一个注意点,可能有朋友认为目前出现的最大事务就是当前事务,但实际上不是,因为事务ID是在开启时就决定下来了的,但Read View却不是,以RR级别为例,就是第一次查询时生成,因此当前事务未必就是当前系统中ID最大的事务)
    • m_up_limit_id:活跃事务列表 m_ids 中最小的事务 ID,如果 m_ids 为空,则 m_up_limit_id 为 m_low_limit_id。小于这个 ID 的数据版本均可见
    • m_ids:Read View 创建时其他未提交的活跃事务 ID 列表。创建 Read View时,将当前未提交事务 ID 记录下来,后续即使它们修改了记录行的值,对于当前事务也是不可见的。m_ids 不包括当前事务自己和已提交的事务(正在内存中)
    • m_creator_trx_id:创建该 Read View 的事务 ID

             这里需要注意的是,当前活跃事务列表(m_ids)并不是连续的,例如一共有依次生成的1~5共5个事务,可能2、4已经结束了,那么活跃列表(m_ids)中就只有(1,3,5),m_up_limit_id为1。

            6、可见性算法

             Read View 主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个 Read View 读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。

            Read View遵循一个可见性算法,主要是将要被修改的数据的最新记录中的 DB_TRX_ID(即当前事务 ID )取出来,与系统当前其他活跃事务的 ID 去对比(由 Read View 维护),如果 DB_TRX_ID 跟 Read View 的属性做了某些比较,不符合可见性,那就通过 DB_ROLL_PTR 回滚指针去取出 Undo Log 中的 DB_TRX_ID 再比较,即遍历链表的 DB_TRX_ID(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的 DB_TRX_ID , 那么这个 DB_TRX_ID 所在的旧记录就是当前事务能看见的最新老版本。

            可见性判断的源码如下:

    1. /* storage/innobase/include/read0types.h */
    2. bool changes_visible(trx_id_t id, const table_name_t &name) const
    3. MY_ATTRIBUTE((warn_unused_result)) {
    4. ut_ad(id > 0);
    5. /* 假如 trx_id 小于 Read View 限制的最小活跃事务ID m_up_limit_id 或者等于正在创建的事务ID
    6. * m_creator_trx_id 即满足事务的可见性. */
    7. if (id < m_up_limit_id || id == m_creator_trx_id) {
    8. /* 可见. */
    9. return (true);
    10. }
    11. /* 检查 trx_id 是否有效. */
    12. check_trx_id_sanity(id, name);
    13. if (id >= m_low_limit_id) {
    14. /* 假如 trx_id 大于等于最大活跃的事务ID m_low_limit_id, 即不可见. */
    15. return (false);
    16. } else if (m_ids.empty()) {
    17. /* 假如目前不存在活跃的事务,即可见. */
    18. return (true);
    19. }
    20. const ids_t::value_type *p = m_ids.data();
    21. /* 利用二分查找搜索活跃事务列表, 当 trx_id 在 m_up_limit_id 和 m_low_limit_id 之间
    22. * 如果 id 在 m_ids 数组中, 表明 ReadView 创建时候,事务处于活跃状态,因此记录不可见. */
    23. return (!std::binary_search(p, p + m_ids.size(), id));
    24. }

            (1)如果记录 DB_TRX_ID < m_up_limit_id,那么表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照之前就提交了,所以该记录行的值对当前事务是可见的

            (2)如果 DB_TRX_ID >= m_low_limit_id,那么表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照之后才修改该行,所以该记录行的值对当前事务不可见。跳到步骤 5

            (3)m_ids 为空,则表明在当前事务创建快照之前,修改该行的事务就已经提交了,所以该记录行的值对当前事务是可见的

            (4)如果 m_up_limit_id <= DB_TRX_ID < m_low_limit_id,表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照的时候可能处于“活动状态”或者“已提交状态”;所以就要对活跃事务列表 m_ids 进行查找(源码中是用的二分查找,因为是有序的)

                    (4.1)如果在活跃事务列表 m_ids 中能找到 DB_TRX_ID,表明:① 在当前事务创建快照前,该记录行的值被事务 ID 为 DB_TRX_ID 的事务修改了,但没有提交;或者 ② 在当前事务创建快照后,该记录行的值被事务 ID 为 DB_TRX_ID 的事务修改了。这些情况下,这个记录行的值对当前事务都是不可见的。跳到步骤 5

                    (4.2)在活跃事务列表中找不到,则表明“id 为 trx_id 的事务”在修改“该记录行的值”后,在“当前事务”创建快照前就已经提交了,所以记录行对当前事务可见

            (5)在该记录行的 DB_ROLL_PTR 指针所指向的 undo log 取出快照记录,用快照记录的 DB_TRX_ID 跳到步骤 1 重新开始判断,直到找到满足的快照版本或返回空

    三、MVCC作用流程

            下文内容参考自《正确的理解MySQL的MVCC及实现原理》

    • 当事务 2对某行数据执行了快照读,数据库为该行数据生成一个Read View读视图,假设当前事务 ID 为 2,此时还有事务1和事务3在活跃中,事务 4在事务 2快照读前一刻提交更新了,所以 Read View 记录了系统当前活跃事务 1,3 的 ID,维护在一个列表上,假设我们称为trx_list
    事务 1事务 2事务 3事务 4
    事务开始事务开始事务开始事务开始
    修改且已提交
    进行中快照读进行中
    • Read View 不仅仅会通过一个列表 trx_list 来维护事务 2执行快照读那刻系统正活跃的事务 ID 列表,还会有两个属性 up_limit_id( trx_list 列表中事务 ID 最小的 ID ),low_limit_id ( 快照读时刻系统尚未分配的下一个事务 ID ,也就是目前已出现过的事务ID的最大值 + 1 )。所以在这里例子中 up_limit_id 就是1,low_limit_id 就是 4 + 1 = 5,trx_list 集合的值是 1, 3,Read View 如下图

    • 我们的例子中,只有事务 4 修改过该行记录,并在事务 2 执行快照读前,就提交了事务,所以当前该行当前数据的 undo log 如下图所示;我们的事务 2 在快照读该行记录的时候,就会拿该行记录的 DB_TRX_ID 去跟 up_limit_id , low_limit_id 和活跃事务 ID 列表( trx_list )进行比较,判断当前事务 2能看到该记录的版本是哪个。

    • 所以先拿该记录 DB_TRX_ID 字段记录的事务 ID 4 去跟 Read View 的 up_limit_id 比较,看 4 是否小于 up_limit_id( 1 ),所以不符合条件,继续判断 4 是否大于等于 low_limit_id( 5 ),也不符合条件,最后判断 4 是否处于 trx_list 中的活跃事务, 最后发现事务 ID 为 4 的事务不在当前活跃事务列表中, 符合可见性条件,所以事务 4修改后提交的最新结果对事务 2 快照读时是可见的,所以事务 2 能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本

    在这里插入图片描述

     四、补充

            1、purge线程

            为了节省磁盘空间,InnoDB 有专门的 purge 线程来清理 deleted_flag 为 true 的记录。为了不影响 MVCC 的正常工作,purge 线程自己也维护了一个read view(这个 read view 相当于系统中最老活跃事务的 read view );如果某个记录的 deleted_flag 为 true ,并且 DB_TRX_ID 相对于 purge 线程的 read view 可见,那么这条记录一定是可以被安全清除的。

            2、ICP(Index Condition Pushdown)

            ICP 是 MySQL 5.6 引入的一个优化,根据官方的说法:ICP 可以减少存储引擎访问基表的次数 和 MySQL 访问存储引擎的次数。当判断数据可见性时,会检查下 delete_flag 是否被标记,然后根据情况走不同的逻辑。

            如果索引是聚簇索引,并且具有唯一特性(主键、唯一索引等),则直接返回。

            如果是普通索引(非唯一索引的二级索引),使用 ICP(Index Condition Pushdown)根据索引信息来判断搜索条件是否满足,主要是在回表使用聚簇索引判断前先进行过滤,这边有三种情况:        

    • ICP 判断不满足条件但没有超出扫描范围,则获取下一条记录继续查找;
    • 如果不满足条件并且超出扫描返回,则返回 DB_RECORD_NOT_FOUND;
    • 如果 ICP 判断符合条件,则会获取对应的聚簇索引来进行可见性判断。

            例如如下的people 表,索引定义为:INDEX (zipcode, lastname, firstname),对于以下这个 SQL:

    1. SELECT * FROM people
    2. WHERE zipcode='95054'
    3. AND lastname LIKE '%etrunia%'
    4. AND address LIKE '%Main Street%';

            当没有使用 ICP 时:此查询会使用该索引,但是必须扫描 people 表所有符合 zipcode='95054' 条件的记录。

            当使用 ICP 时:不仅会使用 zipcode 的条件来进行过滤,还会使用 (lastname LIKE '%etrunia%')来进行过滤,这样可以避免扫描符合 zipcode 条件而不符合 lastname 条件匹配的记录行 。

            3、RC 和 RR 隔离级别下 MVCC 的差异

            在事务隔离级别 RC 和 RR (InnoDB 存储引擎的默认事务隔离级别)下,InnoDB 存储引擎使用 MVCC(非锁定一致性读),但它们生成 Read View 的时机却不同:

    • 在 RC 隔离级别下的 每次select 查询前都生成一个Read View (m_ids 列表)
    • 在 RR 隔离级别下只在事务开始后 第一次select 数据前生成一个Read View(m_ids 列表)

            也正是 Read View 生成时机的不同,从而造成 RC , RR 级别下快照读的结果的不同(RR可以解决不可重复读而RC不行)。

  • 相关阅读:
    需求管理手册-需求工程的流程环节(4)
    如何记账能简单高效,记账全攻略来了
    Java简历与面试
    利用hive中的行转列列转行处理字段中逗号分隔的重复数据
    基于Javaweb实现人力资源管理系统
    CADEditorX ActiveX 14.1.X
    haproxy
    centOS7| 编译安装 gdal 库
    基础爬虫篇
    [附源码]计算机毕业设计JAVAjsp学生宿舍管理系统
  • 原文地址:https://blog.csdn.net/wzngzaixiaomantou/article/details/126677926