写点什么

全网最详细 MVCC 讲解,一篇看懂

作者:码农BookSea
  • 2024-01-13
    浙江
  • 本文字数:5824 字

    阅读完需:约 19 分钟

全网最详细MVCC讲解,一篇看懂

本文已收录至 Github,推荐阅读 👉 Java随想录

微信公众号:Java随想录

摘要

在当今高度并发的数据库环境中,有效的并发控制是至关重要的。MVCC 是 MySQL 中被广泛采用的并发控制机制,它通过版本管理来实现事务的隔离性,允许读写操作同时进行,提高数据库的并发性能和响应能力。


本文将深入解析 MVCC 机制的原理,帮助读者更好地理解和应用这一关键技术。

MVCC 介绍

MVCC,全称 Multi-Version Concurrency Control,即多版本并发控制


MVCC 的目的主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁。


这里的多版本指的是数据库中同时存在多个版本的数据,并不是整个数据库的多个版本,而是某一条记录的多个版本同时存在。


并发控制的挑战


在数据库系统中,同时执行的事务可能涉及相同的数据,因此需要一种机制来保证数据的一致性,传统的锁机制可以实现并发控制,但会导致阻塞和死锁等问题。


MVCC 的优点


MVCC 机制具有以下优点:


  • 提高并发性能:读操作不会阻塞写操作,写操作也不会阻塞读操作,有效地提高数据库的并发性能。

  • 降低死锁风险:由于无需使用显式锁来进行并发控制,MVCC 可以降低死锁的风险。

当前读和快照读

在讲解 MVCC 原理之前,我们先来了解一下,当前读和快照读。


当前读


在 MySQL 中,当前读是一种读取数据的操作方式,它可以直接读取最新的数据版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。MySQL 提供了两种实现当前读的机制:


  • 一致性读(Consistent Read):

  • 默认隔离级别下(可重复读),MySQL 使用一致性读来实现当前读。

  • 在事务开始时,MySQL 会创建一个一致性视图(Consistent View),该视图反映了事务开始时刻数据库的快照。

  • 在事务执行期间,无论其他事务对数据进行了何种修改,事务始终使用一致性视图来读取数据。

  • 这样可以保证在同一个事务内多次查询返回的结果是一致的,从而实现了当前读。

  • 锁定读(Locking Read):

  • 锁定读是一种特殊情况下的当前读方式,在某些场景下使用。

  • 当使用锁定读时,MySQL 会在执行读取操作前获取共享锁或排他锁,以确保数据的一致性。

  • 共享锁(Shared Lock)允许多个事务同时读取同一数据,而排他锁(Exclusive Lock)则阻止其他事务读取或写入该数据。

  • 锁定读适用于需要严格控制并发访问的场景,但由于加锁带来的性能开销较大,建议仅在必要时使用。


下面列举的这些语法都是当前读:



当前读实际上是一种加锁的操作,是悲观锁的实现。


快照读


快照读是在读取数据时读取一个一致性视图中的数据,MySQL 使用 MVCC 机制来支持快照读。


具体而言,每个事务在开始时会创建一个一致性视图(Consistent View),该视图反映了事务开始时刻数据库的快照。这个一致性视图会记录当前事务开始时已经提交的数据版本。


当执行查询操作时,MySQL 会根据事务的一致性视图来决定可见的数据版本。只有那些在事务开始之前已经提交的数据版本才是可见的,未提交的数据或在事务开始后修改的数据则对当前事务不可见。


像不加锁的 select 操作就是快照读,即不加锁的非阻塞读。


快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。


注意:快照读的前提是隔离级别不是串行级别,在串行级别下,事务之间完全串行执行,快照读会退化为当前读


MVCC 主要就是为了实现读-写冲突不加锁,而这个读指的就是快照读,是乐观锁的实现。

MVCC 原理解析

隐式字段

MySQL 中的行数据,除了我们肉眼能看到的字段之外,其实还包含了一些隐藏字段,它们在内部使用,默认情况下不会显示给用户。



Undo Log

上文提到了 Undo 日志,这个 Undo 日志是 MVCC 能够得以实现的核心所在。


Undo 日志(Undo Log)是 MySQL 中的一种重要的事务日志,Undo 日志的作用主要有两个方面:


  • 事务回滚:当事务需要回滚时,MySQL 可以通过 Undo 日志中的旧值将数据还原到事务开始之前的状态,保证了事务回滚的一致性。

  • MVCC 实现:MVCC 是 InnoDB 存储引擎的核心特性之一。通过使用 Undo 日志,MySQL 可以为每个事务提供独立的事务视图,使得事务读取数据时能看到一致且符合隔离级别要求的数据版本。


在 InnoDB 存储引擎中,Undo 日志分为两种:插入(insert)Undo 日志 和 更新(update)Undo 日志


  • insert undo log:插入 Undo 日志是指在插入操作中生成的 Undo 日志。由于插入操作的记录只对当前事务可见,对其他事务不可见,因此在事务提交后可以直接删除,无需进行 purge 操作。

  • update undo log:更新 Undo 日志是指在更新或删除操作中生成的 Undo 日志。更新 Undo 日志可能需要提供 MVCC 机制,因此不能在事务提交时就立即删除。相反,它们会在提交时放入 Undo 日志链表中,并等待 purge 线程进行最终的删除。删除操作只是设置一下老记录的 DELETED_BIT,并不真正将过时的记录删除,为了节省磁盘空间,InnoDB 有专门的 purge 线程来清理 DELETED_BIT 为 true 的记录。


注意:由于查询操作(SELECT)并不会修改任何记录,所以在查询操作执行时,并不需要记录相应的 undo log 。


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


举个例子,比如有个事务 A 插入了一条新记录:insert into user(id, name) values(1, "小明')



现在来了一个事务 B 对该记录的 name 做出了修改,改为 "小王"。


在事务 B 修改该行数据时,数据库会先对该行加排他锁,然后把该行数据拷贝到 undo log 中作为旧记录,即在 undo log 中有当前行的拷贝副本.


拷贝完毕后,修改该行 name 为 "小王,并且修改隐藏字段的事务 ID 为当前事务 B 的 ID, 并将回滚指针指向拷贝到 undo log 的副本记录,即表示我的上一个版本就是它,事务提交后,释放锁。



此时又来了个事务 C 修改同一个记录,将 name 修改为 "小红"。


在事务 C 修改该行数据时,数据库也先为该行加锁,然后把该行数据拷贝到 undo log 中,作为旧记录,发现该行记录已经有 undo log 了,那么最新的旧数据作为链表的表头,插在该行记录的 undo log 最前面,如下图:



关于 DB_ROLL_PTR 与 Undo 日志 的配合工作,具体流程如下:


  1. 在更新或删除操作之前,MySQL 会将旧值写入 Undo 日志中。

  2. 当事务需要回滚时,MySQL 会根据事务的 Undo 日志记录,通过 DB_ROLL_PTR 找到对应的 Undo 日志。

  3. 根据 Undo 日志中记录的旧值,MySQL 将旧值恢复到相应的数据行中,实现数据的回滚操作。


比方说现在想回滚到事务 B,name 值为 "小王" 的时候,只需通过 DB_ROLL_PTR 顺着列表找到对应的 Undo 日志,将旧值恢复到数据行即可。



通过 DB_ROLL_PTR 和 Undo 日志 的配合工作,MySQL 能够有效地管理事务的一致性和隔离性。Undo 日志的使用也使得 MySQL 能够支持 MVCC,从而提供了高并发环境下的读取一致性和事务隔离性。

版本链

在 MVCC 中,对于每次更新操作,旧值会被保存到一条 undo 日志中,即使它是该记录的旧版本。随着更新次数的增加,所有的版本都会通过 roll_pointer 属性连接成一个链表,称之为版本链。


版本链的头节点代表当前记录的最新值。此外,每个版本还包含生成该版本的事务 ID。

Read View

一致性视图,全称 Read View ,是用来判断版本链中的哪个版本对当前事务是可见的


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


这里有一点要注意一下:Read View 只针对 RC 和 RR 级别


Read Uncommitted(RU)和 Serializable(串行化)是两个特殊的隔离级别,它们不需要使用 Read View 的主要原因是:


  • Read Uncommitted(RU)隔离级别: 在 RU 隔离级别下,事务可以读取其他事务尚未提交的数据,即脏读。这意味着不需要通过 Read View 来限制访问范围,事务可以自由地读取其他事务的未提交数据。由于没有对可见性进行严格控制,因此不需要创建或使用 Read View。

  • Serializable(串行化)隔离级别: 在 Serializable 隔离级别下,事务具有最高的隔离性,确保每次读取都能看到一致的快照。为了实现这种隔离级别,MySQL 使用锁机制来保证事务之间的串行执行。由于事务按顺序执行,并且不允许并发操作,所以不需要使用 Read View 进行可见性判断。


Read Uncommitted 和 Serializable 隔离级别下的事务规则不涉及基于 Read View 的可见性判断。RU 允许脏读,而 Serializable 则通过锁机制保证串行执行。因此,在这两个隔离级别下,不需要创建或使用 Read View。

Read View 可见性原则

Read View 遵循一个可见性原则,将要被修改的数据的 DB_TRX_ID 取出来,与系统当前其他活跃事务的 ID 去对比。


如果 DB_TRX_ID 跟 Read View 的属性做了某些比较,不符合可见性,那就通过 DB_ROLL_PTR 回滚指针去取出 Undo Log 中的 DB_TRX_ID 再比较。


即遍历链表的 DB_TRX_ID (从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的 DB_TRX_ID,那么这个 DB_TRX_ID 所在的记录就是当前事务能看见的最新老版本。


Read View 会维护以下几个字段:



Read View 可见性具体判断如下:


  1. 如果被访问版本的 DB_TRX_ID 属性值与 Read View 中的 m_creator_trx_id 值相同,表示当前事务正在访问自己所修改的记录,因此该版本可以被当前事务访问。

  2. 如果被访问版本的 DB_TRX_ID 属性值小于 Read View 中的 m_up_limit_id 值,说明生成该版本的事务在当前事务生成 Read View 之前已经提交,因此该版本可以被当前事务访问。

  3. 如果被访问版本的 DB_TRX_ID 属性值大于或等于 Read View 中的 m_low_limit_id 值,说明生成该版本的事务在当前事务生成 Read View 之后才提交,因此该版本不能被当前事务访问。

  4. 如果被访问版本的 DB_TRX_ID 属性值位于 Read View 的 m_up_limit_idm_low_limit_id 之间(包括边界),则需要进一步检查 DB_TRX_ID 是否在m_ids 列表中。如果在列表中,说明在创建 ReadView 时生成该版本的事务仍处于活跃状态,因此该版本不能被访问;如果不在列表中,说明在创建 Read View 时生成该版本的事务已经提交,因此该版本可以被访问。


事务可见性示意图:


RC 和 RR 下的 Read View

RC 和 RR 下生成 Read View 的时机是有所差异的:


  • RC:每次 SELECT 数据前都生成一个 ReadView。

  • RR:只在第一次读取数据时生成一个 ReadView,后面会复用第一次生成的。


正因为 RC 和 RR 生成 Read View 的时机不同,导致两个级别下看到的数据会不一致。


举例说明,假设数据初始状态如下:



有 A,B,C 三个事务,执行顺序如下:



RC 下的 Read View

T4 时刻


我们来看 T4 时刻的情况,此时 事务 A 和 事务 B 都还没提交,所以活跃的事务 ID,即 m_ids 为:[100,200],四个字段的值分别如下:



T4 时刻的版本链如下:



依据我们之前说的可见性原则,事务 C 最终看到的应该是 name = "小明" 的数据,理由如下:


最新记录的 DB_TRX_ID 为 100,既不小于 m_up_limit_id,也不大于 m_low_limit_id,也不等于 m_creator_trx_id


落在了黄区:



DB_TRX_ID 存在于 m_ids 列表中,故不可见,顺着版本链继续往下。


根据 DB_ROLL_PTR 找到 undo log 中的前一版本记录,前一条记录的 DB_TRX_ID 还是 100,还是不可见,继续往下。


继续找前一条 DB_TRX_ID为 1,满足 1 < m_up_limit_id,可见,所以事务 C 查询到数据为 name = "小明"


T6 时刻


T6 时候的版本链如下:



T6 时刻,会再次生成新的 Read View,四个字段的值分别如下:



根据可见性原则,最终 T6 时刻事务 C 查询到数据为 name = "小红"


T8 时刻


T8 时刻的版本链和 T6 时刻是一致的,不同的是 Read View,因为 T8 时刻会再生成一个 Read View,四个字段的值分别如下:



根据可见性原则,最终 T8 时刻事务 C 查询到数据为 name = "小白"


总结一下,事务 C 在 RC 级别下各个时刻看到的数据如下:



下面我们来看看,RR 级别下的表现是如何的。

RR 下的 Read View

(RR 的版本链和 RC 的版本链是一致的,区别在于 Read View)


T4 时刻


T4 时刻的情况,和 R C 的情况是一致的:



根据可见性原则,最终 T4 时刻事务 C 查询到数据为 name = "小明" ,和 RC 的 T4 时刻是一致的。


T6 时刻


RR 级别会复用 Read View,所以 T6 时刻也是:



根据可见性原则,T6 时刻我们发现事务 C 查询到的数据还是 name = "小明"


继续看 T8 时刻。


T8 时刻


T8 时刻继续复用先前的 Read View。


根据可见性原则,T8 时刻事务 C 查询到的数据依旧是 name = "小明"

小结

我们将事务 C 在 RC 和 RR 级别下看到的数据,放到一块来对比下:



可以看出二者由于生成 Read View 的时机不同,导致在各个时刻看到的数据会存在差异。


回过头来看 RC 和 RR 隔离级别的定义,会有种恍然大悟的感觉:


  • 读已提交(Read Committed):事务只能读取到已经提交的数据。

  • 可重复读(Repeatable Read):事务在整个事务期间保持一致的快照视图,不受其他事务的影响。


总之在 RC 隔离级别下,每个快照读都会生成并获取最新的 Read View;而在 RR 隔离级别下,则是只在第一个快照读创建 Read View,之后的快照读获取的都是同一个 Read View

RR 级别下能否防止幻读

严谨的说,RR 级别下只能防止部分幻读


首先,幻读通常指的是在同一个事务中,第二次查询发现了新增加的行,而第一次查询并没有返回这些新增加的行。


通过前面的例子,我们也看到了,在 RR 隔离级别下,由于一致性视图的存在,如果其他事务插入了新的行,在同一个事务中进行多次查询,这些新增的行将会被包含在事务的一致性视图中,确实可以避免部分幻读场景。


这里注意一下:MVCC 解决的只是 RR 级别下快照读的幻读问题,而当前读的幻读问题则是通过临键锁来解决的。也就是说 RR 级别下是通过 MVCC+临键锁 来解决大部分幻读问题的。


为什么说是部分解决?看下面这个例子:



假设数据初始状态如下:



T3 时刻看到的数据只有一条 name = "小明",而 T5 时刻,由于 select * from user for update 使用的是当前读,读取的是最新的数据版本,T5 时刻查询出来的数据是两条,name 分别为 "小明" 和 "小张"。


理解了上面的例子之后,再看下面这个例子:



UPDATE 语句也是当前读,也会发生幻读问题,最终看到的数据是 name 分别为 "小明" 和 "小陈"。


这里发生幻读的原因,和上面的例子是一样的,本质都是在一个事务中,即使用了快照读又使用了当前读,RR 级别下无法预防此种情况,所以说 RR 级别下无法完全解决幻读问题。

总结

综上所述,MVCC 是一种强大的并发控制机制,在高并发环境中起着重要的作用。通过了解 MVCC 的原理和实现流程,我们可以更好地理解 MySQL 的并发控制机制,理解 MVCC 的原理对于接触 MySQL 的开发人员来说是必不可少的知识点。


希望本文对各位同学有所帮助,加深对 MVCC 及其在 MySQL 中的应用的理解。感谢阅读!

发布于: 刚刚阅读数: 5
用户头像

码农BookSea

关注

Java开发工程师 2021-12-26 加入

Java开发菜鸟工程师,写博客的初衷是为了沉淀我所学习,累积我所见闻,分享我所体验。希望和更多的人交流学习。

评论

发布
暂无评论
全网最详细MVCC讲解,一篇看懂_Java_码农BookSea_InfoQ写作社区