• 前提概要

    • 什么是 MVCC
    • 什么是当前读和快照读
    • 当前读、快照读和 MVCC 的关系
  • MVCC 实现原理

    • 隐式字段
    • Undo 日志
    • Read View(读视图)
    • 整体流程
  • MVCC 相关问题

    • RR 是如何在 RC 级的基础上解决不可重复读的
    • RC、RR 级别下的 InnoDB 快照读有什么不同

前提概要

什么是 MVCC?

MVCC(Multi-Version Concurrency Control,多版本并发控制)是一种并发控制的方法,一般在数据库管理系统中实现对数据库的并发访问,或在编程语言中实现事务内存。

MVCC 在 MySQL InnoDB 中的实现主要是为了提高数据库并发性能,用更好的方式去处理读 - 写冲突。其核心目标是做到即使存在读写冲突时,也能实现不加锁、非阻塞的并发读。

什么是当前读和快照读?

在学习 MVCC 多版本并发控制之前,必须先了解 MySQL InnoDB 下的当前读快照读

  • 当前读(Current Read)
    SELECT ... LOCK IN SHARE MODE(共享锁)、SELECT ... FOR UPDATEUPDATEINSERTDELETE(排他锁)这些操作都属于当前读。

    • 特点:读取的是记录的最新版本。
    • 机制:读取时还要保证其他并发事务不能修改当前记录,因此会对读取的记录进行加锁。
  • 快照读(Snapshot Read)
    像不加锁的 SELECT 操作就是快照读,即不加锁的非阻塞读。

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

简而言之,MVCC 就是为了实现读 - 写冲突不加锁,而这个“读”指的就是快照读,而非当前读。当前读实际上是一种加锁的操作,是悲观锁的实现。

当前读、快照读和 MVCC 的关系

  • 概念层面:准确地说,MVCC 多版本并发控制指的是“维持一个数据的多个版本,使得读写操作没有冲突”这么一个概念,仅仅是一个理想模型。
  • 实现层面:在 MySQL 中,实现这么一个 MVCC 理想模型,需要具体的功能支持。快照读就是 MySQL 为我们实现 MVCC 理想模型的其中一个具体非阻塞读功能。而相对而言,当前读就是悲观锁的具体功能实现。
  • 底层细节:快照读本身也是一个抽象概念。MVCC 模型在 MySQL 中的具体实现则是由 3 个隐式字段Undo 日志Read View 等去完成的(具体原理见下文)。

MVCC 能解决什么问题,好处是?

数据库并发场景主要有三种:

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

MVCC 带来的好处

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

所以 MVCC 可以为数据库解决以下问题:

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

小结

总之,MVCC 是因为设计者不满意只让数据库采用悲观锁这样性能不佳的形式去解决读 - 写冲突问题,而提出的解决方案。在数据库中,因为有了 MVCC,我们可以形成两个组合:

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

这种组合的方式就可以最大程度地提高数据库并发性能,并解决读写冲突和写写冲突导致的问题。

MVCC 实现原理

MVCC 的目的就是多版本并发控制,在数据库中的实现,就是为了解决读写冲突。它的实现原理主要是依赖记录中的 3 个隐式字段Undo 日志Read View 来实现的。

隐式字段

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

  • DB_TRX_ID:6 byte,最近修改(修改/插入)事务 ID。记录创建这条记录或最后一次修改该记录的事务 ID。
  • DB_ROLL_PTR:7 byte,回滚指针。指向这条记录的上一个版本(存储于 rollback segment 里)。
  • DB_ROW_ID:6 byte,隐含的自增 ID(隐藏主键)。如果数据表没有主键,InnoDB 会自动以 DB_ROW_ID 产生一个聚簇索引。
  • 删除标记:实际还有一个删除 flag 隐藏字段。记录被更新或删除并不代表真的删除,而是删除 flag 变了。

如上图,DB_ROW_ID 是数据库默认为该行记录生成的唯一隐式主键,DB_TRX_ID 是当前操作该记录的事务 ID,而 DB_ROLL_PTR 是一个回滚指针,用于配合 Undo 日志,指向上一个旧版本。

Undo 日志

Undo Log 主要分为两种:

  • Insert Undo Log:代表事务在 INSERT 新记录时产生的 Undo Log。只在事务回滚时需要,并且在事务提交后可以被立即丢弃。
  • Update Undo Log:事务在进行 UPDATEDELETE 时产生的 Undo Log。不仅在事务回滚时需要,在快照读时也需要。所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清除。

关于 Purge 线程

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

对 MVCC 有帮助的实质是 Update Undo Log。Undo Log 实际上就是存在 rollback segment 中旧记录链,它的执行流程如下:

  1. 比如有一个事务向 person 表插入了一条新记录,记录如下:name 为 Jerry,age 为 24 岁,隐式主键是 1,事务 ID 和回滚指针假设为 NULL。

  2. 现在来了一个事务 1 对该记录的 name 做出了修改,改为 Tom。

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

  3. 又来了个事务 2 修改 person 表的同一个记录,将 age 修改为 30 岁。

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

从上面可以看出,不同事务或者相同事务对同一记录的修改,会导致该记录的 Undo Log 成为一条记录版本线性表(即链表)。Undo Log 的链首就是最新的旧记录,链尾就是最早的旧记录。(当然,该 Undo Log 的节点可能是会被 purge 线程清除掉,向图中的第一条 Insert Undo Log,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里)。

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

那么这个判断条件是什么呢?

如上,它是一段 MySQL 判断可见性的一段源码,即 changes_visible 方法(不完全,但能看出大致逻辑)。该方法展示了我们拿 DB_TRX_ID 去跟 Read View 某些属性进行怎么样的比较。

在展示之前,我先简化一下 Read View。我们可以把 Read View 简单地理解成有三个全局属性:

  • trx_list:一个数值列表,用来维护 Read View 生成时刻系统正活跃的事务 ID。
  • up_limit_id:记录 trx_list 列表中事务 ID 最小的 ID。
  • low_limit_id:Read View 生成时刻系统尚未分配的下一个事务 ID,也就是目前已出现过的事务 ID 的最大值 + 1。

可见性判断逻辑如下:

  1. 首先比较 DB_TRX_ID < up_limit_id。如果小于,则当前事务能看到 DB_TRX_ID 所在的记录;如果大于等于进入下一个判断。
  2. 接下来判断 DB_TRX_ID >= low_limit_id。如果大于等于,则代表 DB_TRX_ID 所在的记录在 Read View 生成后才出现的,那对当前事务肯定不可见;如果小于则进入下一个判断。
  3. 判断 DB_TRX_ID 是否在活跃事务之中,trx_list.contains(DB_TRX_ID)

    • 如果在,则代表在 Read View 生成时刻,你这个事务还在活跃,还没有 Commit,你修改的数据,我当前事务也是看不见的。
    • 如果不在,则说明,你这个事务在 Read View 生成之前就已经 Commit 了,你修改的结果,我当前事务是能看见的。

整体流程

在了解了隐式字段、Undo Log 以及 Read View 的概念之后,就可以来看看 MVCC 实现的整体流程了。我们可以模拟一下:

  • 当事务 2 对某行数据执行了快照读,数据库为该行数据生成一个 Read View 读视图。假设当前事务 ID 为 2,此时还有事务 1 和事务 3 在活跃中,事务 4 在事务 2 快照读前一刻提交更新了。所以 Read View 记录了系统当前活跃事务 1、3 的 ID,维护在一个列表上,假设我们称为 trx_list

  • 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 如下图:

  • 我们的例子中,只有事务 4 修改过该行记录,并在事务 2 执行快照读前,就提交了事务。所以当前该行当前数据的 Undo Log 如下图所示。我们的事务 2 在快照读该行记录的时候,就会拿该行记录的 DB_TRX_ID 去跟 up_limit_idlow_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 提交的版本也是全局角度上最新的版本。

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

MVCC 相关问题

RR 是如何在 RC 级的基础上解决不可重复读的?

当前读和快照读在 RR 级别下的区别:

表 1:

表 2:

而在表 2 这里的顺序中,事务 B 在事务 A 提交后的快照读和当前读都是实时的新数据 400,这是为什么呢?

  • 这里与上表的唯一区别仅仅是表 1 的事务 B 在事务 A 修改金额前快照读过一次金额数据,而表 2 的事务 B 在事务 A 修改金额前没有进行过快照读。

所以我们知道事务中快照读的结果是非常依赖该事务首次出现快照读的地方。即某个事务中首次出现快照读的地方非常关键,它有决定该事务后续快照读结果的能力。

我们这里测试的是更新,同时删除和更新也是一样的。如果事务 B 的快照读是在事务 A 操作之后进行的,事务 B 的快照读也是能读取到最新的数据的。

RC、RR 级别下的 InnoDB 快照读有什么不同?

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

  • RR(Repeatable Read)级别

    • 在某个事务对某条记录的第一次快照读会创建一个快照及 Read View,将当前系统活跃的其他事务记录起来。
    • 此后在调用快照读的时候,还是使用的是同一个 Read View
    • 所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个 Read View,所以对之后的修改不可见。
    • 即 RR 级别下,快照读生成 Read View 时,Read View 会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于 Read View 创建的事务所做的修改均是可见。
  • RC(Read Committed)级别

    • 在事务中,每次快照读都会新生成一个快照和 Read View。
    • 这就是我们在 RC 级别下的事务中可以看到别的事务提交的更新的原因。

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

说明:本文内容基于 MySQL InnoDB 存储引擎的通用实现原理(适用于 MySQL 5.7/8.0 等常见版本)。不同版本在具体实现细节上可能略有差异,但核心 MVCC 机制保持一致。