浅析MySQL之MVCC机制

2023年 10月 24日 35.6k 0

文章顶部.png

忘江头像.png

一、前言

在分析 MVCC 的原理之前,我们先回顾一下 MySQL 的一些内容以及关于 MVCC 的一些简单介绍。(注:下面没有特别说明默认 MySQL 的引擎为 InnoDB )

1.1 数据库的并发场景

数据库并发场景有三种,分别是:

  • 读-读:不存在线程安全问题,不需要并发控制。
  • 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读、不可重复读、幻读等问题。
  • 写-写:有线程安全问题,可能会存在更新丢失的问题,比如第一类更新丢失、第二类更新丢失。

(第一类丢失更新:事务A回滚时,将已经提交的事务B的更新数据覆盖了;第二类丢失更新:事务A提交覆盖了事务B已经提交的数据,造成事务B所做的操作丢失)

1.2 什么是 MVCC

  • MVCC全称 Multi-Version Concurrency Control ,即多版本并发控制,MVCC 是一种并发控制的方法,一般在数据库管理系统中实现对数据库的并发访问,在编程语言中实现事务内存。
  • 多版本控制:指的是一种提高并发的技术,在最早的数据库系统中只有读-读之间可以并发,读-写、写-写之间都要阻塞。引入多版本并发控制之后,只有写-写之间相互阻塞,其他三种操作都可以并行,这样大幅的提高了 InnoDB 的并发度。在内部实现中,InnoDB 是通过 undo log 实现的,通过 undo log 可以找回数据的历史版本。找回的历史版本可以提供给用户读(按照隔离级别的定义,有些读请求只能看到比较老的数据版本),也可以在回滚的时候覆盖数据页上的数据。在 InnoDB 内部中,会记录一个全局的活跃读写事务数组,其主要用来判断事务的可见行。

一句话概述,MVCC 在 MySQL InnoDB 中的实现主要是为了提高数据库并发性能,用更好的方式去处理读写冲突,做到即使有读写冲突时,也能做到不加锁,做到非阻塞并发读。

1.3 当前读和快照读

  • 当前读

    select xxx lock in share mode; # 共享锁
    ​
    #排它锁
    select xxx for update;
    update xxx;
    delete xxx;
    insert xxx;
    

    像上面的这些操作就是一种当前读,因为它读取的是数据的最新版本,读取时还要保证其他事务不能修改当前记录,会对记录进行加锁。

  • 快照读

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

1.4 当前读和快照读与 MVCC 的关系

准确的说,MVCC 指的是 "维护一个数据的多个版本,使得读写操作没有冲突" 这么一个概念,仅仅是一个理想状态。而在 MySQL 中,实现这么一个 MVCC 理想概念,我们就需要 MySQL 提供具体的功能去实现,而快照读就是 MySQL 为我们实现 MVCC 理想模型的其中一个具体非阻塞读功能。 而相对而言,当前读就是一个悲观锁的具体功能实现,而要说的在细致一点,快照读本身也是一个抽象概念,在深入研究,MVCC 模型在 MySQL 中的具体实现则是由三个隐式字段undo log 、Read View 等去完成的。

1.5 MVCC 能解决什么问题

MVCC 是一种解决读写冲突的无锁并发控制手段,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照,所以 MVCC 可以为数据库解决以下问题:

  • 在并发读数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写性能。
  • 可以解决脏读、不可重复读、幻读等事务隔离问题(不能解决更新丢失的问题)。
  • 所以说 MVCC 就是开发人员不满意只让数据库采用悲观锁(加锁)这样性能不佳的形式去解决读-写的问题而提出的解决方案,所以在数据库中,因为有了 MVCC ,所以我们可以形成两个组合:

  • MVCC + 悲观锁
  • MVCC解决读写冲突,悲观锁解决写-写冲突

  • MVCC + 乐观锁
  • MVCC解决读写冲突,乐观锁解决写-写冲突。

    二、MVCC实现原理

    2.1 隐式字段

    在一张表中,除了我们自定义的字段,实际上 MySQL 会隐式的定义一些字段。

    • DB_TRX_ID

      6byte,最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID

    • DB_ROLL_PTR

      7byte,回滚指针,指向这条记录的上一个版本(存储于 rollback segment 里)

    • DB_ROW_ID

      6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB 会自动以 DB_ROW_ID 产生一个聚簇索引

    • 实际还有一个删除 flag 隐藏字段, 即记录被更新或删除并不代表真的删除,而是删除 flag 变了

    例如下面是 person 表的某条记录,如下图,DB_ROW_ID 是数据库为改行记录生产的唯一隐式主键,DB_TRX_ID 是当前操作该记录的事务ID,而 DB_ROLL_PTR 是一个回滚指针,用于配合 undo log 日志,指向上一个版本。

    图片1

    2.2 undo log日志

    undo log 类型

    • insert undo log

      是指在 insert 操作中产生的 undo log。因为 insert 操作的记录,只对当前事务本身可见,对其他事务不可见(这是事务隔离性的要求),因此这种 undo log 可以在事务提交后直接删除。不需要进行 purge 操作

    • update undo log

      是对 deleteupdate 操作产生的 undo log。该 undo log 可能需要提供给 MVCC 机制使用,因此不能在事务提交时就进行删除,提交时放入 undo log 链表,等待 purge 线程进行最后的删除。

    purge 线程

    为了实现 InnoDB 的 MVCC 机制,更新或者删除操作都只是设置一下老记录的 deleted_bit(即前面提到的删除 flag),并不真正将过时的记录删除。为了节省磁盘空间,InnoDB 有专门的 purge 线程来清理 deleted_bit 为 true 的记录。为了不影响 MVCC 的正常工作,purge 线程自己也维护了一个 Read View(这个 Read View 相当于系统中最老活跃事务的 Read View ),如果某个记录的 deleted_bit 为true,并且 DB_TRX_ID 相对于 purge 线程的 Read View 可见,那么这条记录一定是可以被安全清除的。

    对 MVCC 有帮助的实质是 update undo log ,undo log 存在于 rollback segment 中旧记录链,它的执行流程如下:

  • 一个事务插入 person 表插入了一条新记录,记录如下,name 为 Jerry, age 为24,隐式主键是1,事务ID回滚指针我们假设为NULL。
  • 2

    2.现在来了一个 事务1 对记录的 name 进行了修改,改为了 Tom,执行过程如下:

  • 在事务1修改该行数据时,数据会先对这行记录加排它锁。
  • 然后把该行数据拷贝到 undo log 中,作为旧记录,即在 undo log 中有当前行的拷贝副本。
  • 拷贝完毕后,修改该行的 name 为 tom,并且修改隐藏字段的事务ID为当前事务1的ID,我们默认从1开始,之后递增,回滚指针指向拷贝到 undo log 的副本记录,即表示我的上一个版本就是它。
  • 事务提交后,释放排它锁。
  • 3

    3.又来了个事务2修改 person 表的同一个记录,将 age 修改为30岁,执行过程如下:

  • 在事务2修改该行数据时,数据库也先为该行加锁
  • 然后把该行数据拷贝到 undo log 中,作为旧记录,发现该行记录已经有 undo log 了,那么最新的旧数据作为链表的表头,插在该行记录的 undo log 最前面
  • 修改该行 age 为30岁,并且修改隐藏字段的事务ID为当前事务2的ID, 那就是2,回滚指针指向刚刚拷贝到 undo log 的副本记录
  • 事务提交,释放锁
  • 4

    从上面我们可以看出,不同事务或者相同事务对同一记录的修改,会导致该记录的 undo log 称为一条记录版本线性表,即链表,undo log 的表头就是最新的旧记录,(当然就像之前说的该 undo log 的节点可能会被 purge 线程清除掉,像图中的第一条 insert undo log ,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里)

    2.3 Read View

    什么是 Read View ?

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

    可见行判断

    所以我们知道 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 所在的旧记录就是当前事务能看见的最新的老版本。

    判断条件是什么?

    我们可以将 Read View 简单的理解为三个全局属性

    • trx_list 一个数值列表,用来维护 Read View 生成时刻此时系统正活跃的事务ID

    • up_limit_id 记录 trx_list 中的最小的事务 ID

    • low_limit_id 在 Read View 生成时刻系统尚未分配的下一个事务 ID,即目前(不一定是 Read View 中)已经出现过的事务 ID 最大值+1

      比较步骤

  • 首先比较 DB_TRX_ID < up_limit_id, 如果小于,则当前事务能看到 DB_TRX_ID 所在的记录,如果大于等于进入下一个判断。
  • 接下来判断 DB_TRX_ID 大于等于 low_limit_id , 如果大于等于则代表 DB_TRX_ID 所在的记录在 Read View 生成后才出现的,那对当前事务肯定不可见,如果小于则进入下一个判断。
  • 判断 DB_TRX_ID 是否在活跃事务之中,trx_list.contains( DB_TRX_ID ),如果在,则代表我 Read View 生成时刻,你这个事务还在活跃,还没有 Commit,你修改的数据,我当前事务也是看不见的;如果不在,则说明,你的这个事务在 Read View 生成之前就已经 Commit了,你修改的结果,我当前事务是能看见的。
  • 2.4 实现流程

    我们在了解了隐式字段、undo log 以及 Read View 的概念之后,我们模拟一下 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(记录 trx_list 列表中事务ID最大的ID,也有人说快照读那刻系统尚未分配的下一个事务ID也就是目前已出现过的事务ID的最大值+1,所以在这里例子中 up_limit_id 就是1,low_limit_id 就是4 + 1 = 5,trx_list 集合的值是1,3,Read View 如下图

    5

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

    6

  • 所以先拿该记录 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提交的版本也是全局角度上最新的版本。

  • 流程图

    7

    2.5 RC/RR级别快照读有什么不同

    生成 Read View 的时机不同,从而造成 RC RR 级别下快照读的结果的不同。

    • 在RR级别下的某个事务对某条记录进行的第一次快照读会创建一个快照 Read View,此后在调用快照读的时候,使用的还是同一个ReadView,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见.
    • 而在 RC 隔离级别下,事务中每次快照都会生成一个快照和 ReadView,这就是我们在 RC 级别下的事务中可以看到别的事务提交更新的原因。

    总之在 RC 隔离级别下,每次快照读都会生成最新的 ReadView;而在 RR 级别下,则是同一个事务中的第一个快照读才会创建ReadView,之后的快照读获取的都是同一个 ReadView。所以说 RR 在 RC 的基础上通过生成 Read View 的时机不同从而解决了不可重复读的问题

    总结

    本文讲解了 MySQL 中的隐式字段、undo log 日志和 Read View 的原理以及 MVCC 的实现流程,对于我们在日常的开发过程中对于数据库的并发操作以及 MySQL 的各种隔离级别有了清晰的认识。

    参考文献

    • 《MySQL InnoDB MVCC 实现》

    推荐阅读

    被忽略的缓存 -bfcache

    新一代vue状态管理工具Pinia

    Cola-StateMachine状态机的实战使用

    Redisson杂谈

    react-grid-layout 之核心代码分析与实践

    招贤纳士

    政采云技术团队(Zero),Base 杭州,一个富有激情和技术匠心精神的成长型团队。规模 500 人左右,在日常业务开发之外,还分别在云原生、区块链、人工智能、低代码平台、中间件、大数据、物料体系、工程平台、性能体验、可视化等领域进行技术探索和实践,推动并落地了一系列的内部技术产品,持续探索技术的新边界。此外,团队还纷纷投身社区建设,目前已经是 google flutter、scikit-learn、Apache Dubbo、Apache Rocketmq、Apache Pulsar、CNCF Dapr、Apache DolphinScheduler、alibaba Seata 等众多优秀开源社区的贡献者。

    如果你想改变一直被事折腾,希望开始折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊……如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的技术团队的成长过程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 zcy-tc@cai-inc.com

    微信公众号

    文章同步发布,政采云技术团队公众号,欢迎关注 文章顶部.png

    相关文章

    Oracle如何使用授予和撤销权限的语法和示例
    Awesome Project: 探索 MatrixOrigin 云原生分布式数据库
    下载丨66页PDF,云和恩墨技术通讯(2024年7月刊)
    社区版oceanbase安装
    Oracle 导出CSV工具-sqluldr2
    ETL数据集成丨快速将MySQL数据迁移至Doris数据库

    发布评论