MVCC学习圣经:一文穿透MySQL MVCC,吊打面试官

什么是MVCC

MVCC 机制的全称为 Multi-Version Concurrency Control,即多版本并发控制。MVCC主要是为了提升数据库并发性能而设计的,其中采用更好的方式处理了读-写并发冲突,做到即使有读写冲突时,可以实现并发执行,从而提升并发能力,确保了任何时刻的读操作都是非阻塞的。

在众多的MySQL开源存储引擎中,几乎只有InnoDB实现了MVCC机制,其他的存储引擎如:MyISAM、memory等存储引擎中并未实现MVCC。

MVCC(Multi-Version Concurrency Control,多版本并发控制)一种并发控制机制,用于解决并发事务访问数据库时可能出现的一些问题,如脏读、不可重复读和幻读。在MVCC机制中,数据库中的每个数据行都可以存在多个版本,并且每个事务看到的数据版本可能不同。具体来说,MVCC机制通过以下方式实现并发控制:

  1. 版本控制:每当对数据库中的数据行进行更新操作时,不是直接覆盖原始数据,而是创建一个新的数据版本,并将新版本的数据与事务的时间戳相关联。
  2. 快照读取:在MVCC中,读取操作不会阻塞写入操作,也不会阻塞其他读取操作。事务可以读取数据库中的数据快照,即某个时间点之前的数据版本,而不会受到其他事务的影响。
  3. 可见性判断:在执行读取操作时,事务只能看到在其开始之前已经提交的数据版本,而看不到其他事务正在修改的数据。这样可以避免脏读和不可重复读问题。
  4. 回滚操作:当事务回滚时,不会对数据库中的数据进行物理删除或修改,而是标记事务所涉及的数据版本为无效,使得其他事务无法看到该版本。

总的来说,MVCC机制通过维护多个数据版本,实现了事务的隔离性和并发性,保证了数据库的一致性和可靠性。它是许多现代数据库系统(如MySQL、PostgreSQL等)中常用的并发控制技术。

MVCC的根本目标:提升并发能力

在并发读写数据库时,读操作可能会不一致的数据(脏读)。为了避免这种情况,需要实现数据库的并发访问控制,最简单的方式就是加锁访问。由于,加锁会将读写操作串行化,所以不会出现不一致的状态。但是,读操作会被写操作阻塞,大幅降低读性能。

事务并发处理的四大场景

首先, 这里讲 事务的并发处理分为四大场景,分别是

  • 读-读
  • 写-写
  • 读-写
  • 写-读

这四种情况分别对应并发事务执行时的四种场景,为了后续分析 MVCC 机制时方便理解,因此先将这几种情况说明。

读-读场景:

读-读场景即是指多个事务/线程在并发读取一个相同的数据,比如事务 T1 正在读取 ID=16 的行记录,事务 T2 也在读取这条记录,两个事务之间是并发执行的。MySQL 执行查询语句,绝对不会对引起数据的任何变化,因此对于这种情况而言,不需要做任何操作,因为不改变数据就不会引起任何并发问题。

写-写场景

写-写场景也比较简单,也就是指多个事务之间一起对同一数据进行写操作,比如事务 T1ID=16 的行记录做修改操作,事务 T2 则对这条数据做删除操作,事务 T1 提交事务后想查询看一下,结果连这条数据都不见了,这也是所谓的脏写问题,也被称为 更新覆盖问题,对于这个问题在所有数据库、所有隔离级别中都是零容忍的存在,最低的隔离级别也要解决这个问题。

读-写、写-读场景

读-写、写-读实际上从宏观角度来看,可以理解成同一种类型的操作,但从微观角度而言则是两种不同的情况,

  • 读-写是指一个事务先开始读,然后另一个事务则过来执行写操作,
  • 写-读则相反,主要是读、写发生的前后顺序的区别。

并发事务中同时存在读、写两类操作时,这是最容易出问题的场景,脏读、不可重复读、幻读都出自于这种场景中,当有一个事务在做写操作时,读的事务中就有可能出现这一系列问题,因此数据库才会引入各种机制解决。

各并发事务场景的解决方案

对于写-写、读-写、写-读这三类存在线程安全问题的场景,最为简单粗暴的方式,通过 加锁 的方案确保线程安全。

但是,加锁会导致部分的串行化、整体串行化,因此效率会下降,而 MVCC 机制的诞生则解决了这个问题。

因此 MySQL 推出了 MVCC 机制,在读-写并存(读-写、写-读)的场景,使用局部无锁架构,提升性能。

MVCC 机制 在线程安全问题和加锁串行化之间做了一定取舍,让两者之间达到了很好的平衡,即防止了脏读、不可重复读及幻读问题的出现 又无需对并发读-写事务加锁处理。

无锁架构:COW思想

Copy-On-Write(COW,写时复制)是一种常见的并发编程思想。Copy-On-Write基本思想是,当多个线程需要对共享数据进行修改时,不直接在原始数据上进行操作,而是先将原始数据复制一份(即写时复制),然后在副本上进行Write。Copy-On-Write 通过操作写操作副本,引入 局部无锁架构,解决并且处理之间的数据冲突,提高了并发性能。Copy-On-Write的实现步骤如下:

  1. 读取数据:多个线程同时读取共享数据时,它们可以直接访问原始数据,而不需要复制。因为读取操作不会修改数据,所以可以安全地共享原始数据。
  2. 写入数据:当某个线程需要修改共享数据时,首先会将原始数据进行复制(即写时复制),然后在副本上进行修改。这样做的好处是,其他线程仍然可以继续读取原始数据,不受写入线程的影响。
  3. 更新引用:写入线程完成修改后,会更新共享数据的引用,使得其他线程后续访问时可以获取到最新的数据副本。

Copy-On-Write的优点包括:

  • 线程安全:通过复制数据副本并在副本上进行修改,避免了多线程并发修改原始数据时的数据冲突问题,从而提高了线程安全性。
  • 减少锁竞争:由于读取操作不需要加锁,所以可以减少锁竞争,提高了并发性能。
  • 节省内存:只有在有写入操作时才会进行数据复制,而读取操作可以共享原始数据,因此可以节省内存空间。

然而,Copy-On-Write也有一些缺点,主要是由于数据复制和更新引用所带来的额外开销,可能会导致内存和性能方面的消耗增加。

因此,适用场景需要根据具体情况进行评估和选择。COW思想写操作之间是要互斥的,并且每次写操作都会有一次copy,所以只适合读大于写的情况。所以,COW思想 专门用于 优化读的次数远大于写次数 的场景。比如,Java的 并发容器CopyOnWriteArrayList。

Java中的CopyOnWriteArrayList

CopyOnWriteArrayList 是jdk1.5以后并发包中提供的一种并发容器,写操作通过创建底层数组的新副本来实现,是一种读写分离的并发策略,我们也成为“写时复制容器”。

public boolean add(E e) {
   //加锁,对写操作保证线程安全
   final ReentrantLock lock = this.lock;
   lock.lock();
   try {
       Object[] elements = getArray();
       int len = elements.length;
       //拷贝原容器,长度为原容器+1
       Object[] newElements = Arrays.copyOf(elements, len + 1);
       //在新副本执行添加操作
       newElements[len] = e;
       //底层数组指向新的数组
       setArray(newElements);
       return true;
   } finally {
       lock.unlock();
   }
}

CopyOnWriteArrayList底层实现添加的原理是先copy出一个容器(可以简称副本),再往新的容器里添加这个新的数据,最后把新的容器的引用地址赋值给了之前那个旧的的容器地址,但是在添加这个数据的期间,其他线程如果要去读取数据,仍然是读取到旧的容器里的数据。

MVCC如何使用Copy-On-Write思想呢?借鸡生蛋

一图胜千言,用一张图,给大家总结一如何借鸡生蛋,实现 Copy-On-Write思想的。总体来说, MVCC Copy-On-Write思想, 包括三个组成部分:

事务要实现ACID,其中的原子性、一致性主要使用 undo-log 数据副本实现,undo-log 就是重做日志,一个事务一个 undo-log 日志副本。

多个事务的 undo-log 日志副本 (数据快照),组成了一个 副本链,如下下图:

MVCC 也就借鸡生蛋, 复用 这个 undo-log 副本链, 实现了自己 Copy-On-Write思想。

MVCC与锁的关系

再看看 一个核心问题:MVCC与锁的关系?还是一图胜千言,用一张图,给大家总结一下MVCC和锁如何结合使用,提升事务并行能力的:

MVCC(Multi-Version Concurrency Control,多版本并发控制)和锁是数据库管理系统中两种不同的并发控制机制,它们在处理事务并发访问时起着不同的作用。

  1. MVCC

    • MVCC通过维护数据的多个版本来实现并发控制,允许事务并发访问数据库而不会发生阻塞。
    • 在MVCC中,读取操作不会阻塞写入操作,也不会阻塞其他读取操作。每个事务可以看到一个一致性的数据快照,而不受其他事务的影响。
    • MVCC主要用于读取操作的并发控制,可以有效地避免脏读、不可重复读和幻读等并发问题。
    • 锁是一种悲观并发控制机制,通过在事务访问数据时对数据进行加锁,以防止其他事务对该数据进行修改或读取。
    • 在使用锁进行并发控制时,可能会出现阻塞和死锁等问题,特别是在高并发的情况下,锁的粒度过大或者锁的竞争过于激烈时,性能可能会受到影响。

MVCC和锁之间的关系可以总结如下:

  • MVCC是一种 乐观的并发控制机制,通过多副本的版本控制来实现并发访问,而不需要对数据进行加锁。
  • 锁是一种 悲观的并发控制机制,通过对数据进行加锁来确保事务的隔离性和一致性。

MySQL事务隔离级别与MVCC

什么是事务

事务(Transaction)是数据库管理系统执行过程中的一个逻辑单位,它由一个有限的数据库操作序列构成。

这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。事务的目的是确保数据的完整性和一致性,它通过一系列的操作,将数据库从一个一致性状态转换到另一个一致性状态。

事务的ACID特性

事务通常具有以下四个特性,也被称为ACID属性:

  1. 原子性(Atomicity):事务作为一个整体执行,包含在其中的对数据库的操作要么全部执行,要么全部不执行。
  2. 一致性(Consistency):事务必须使数据库从一个一致性状态变换到另一个一致性状态。也就是说,一个事务的执行不能破坏数据库数据的完整性和一致性。
  3. 隔离性(Isolation):事务的执行不受其他事务的干扰,事务执行的中间结果对其他事务是不可见的。
  4. 持久性(Durability):一旦事务提交,则其结果就是永久性的,即使系统崩溃也不会丢失。事务的这些特性确保了即使在高并发的环境中,数据库也能保持数据的完整性和一致性。在数据库系统中,事务是通过一系列的操作来完成的,包括数据的插入、更新、删除等。如果事务中的任何操作失败,或者因为某种原因被中断,那么整个事务都会回滚(Rollback),即撤销所有已经执行的操作,使数据库回到事务开始之前的状态。如果事务中的所有操作都成功完成,那么事务会提交(Commit),所做的更改会永久保存到数据库中。

4中事务隔离级别

什么是事务个隔离级别?事务隔离级别主要定义了事务在并发执行时的行为,特别是它们如何与其他事务交互以及它们如何看到数据库中的更改。

ANSI/ISO SQL标准定义了4中事务隔离级别:未提交读(read uncommitted),提交读(read committed),重复读(repeatable read),串行读(serializable)。

  • Oracle中默认的事务隔离级别是提交读 (read committed)。
  • 对于MySQL的Innodb的默认事务隔离级别是重复读(repeated read)。

MySQL支持四种不同的事务隔离级别,每种级别都有其特定的行为和适用场景。以下是MySQL的四种事务隔离级别及其描述:

  1. READ UNCOMMITTED(读取未提交)

    • 允许读取尚未提交的数据变更。
    • 这是最低的隔离级别,它可能导致脏读、不可重复读和幻读。
    • 在这个级别,一个事务可以读取到另一个尚未提交事务的修改,这可能导致数据的不一致性。
  2. READ COMMITTED(读取已提交)

    • 只允许读取并发事务已经提交的数据。
    • 这个级别可以防止脏读,但仍可能导致不可重复读和幻读。
    • 在这个级别,每个事务只能看到它开始时的数据状态以及它提交时其他事务所做的提交。
  3. REPEATABLE READ(可重复读取)

    • 这是MySQL的默认隔离级别
    • 它确保在同一事务中多次读取同一数据时,看到的是相同的数据版本,即使其他事务在此期间修改了这些数据。
    • 尽管可以避免脏读和不可重复读,但在这个级别下仍可能出现幻读(即在一个事务中,两次相同的查询可能会返回不同的结果集,因为其他事务在此期间插入了新的记录)。
  4. SERIALIZABLE(可串行化) 选择适当的事务隔离级别需要根据应用的需求和性能考虑进行权衡。在某些情况下,可能需要更高的隔离级别来确保数据的一致性,而在其他情况下,可能需要降低隔离级别以提高性能。同时,也需要注意不同隔离级别可能带来的并发问题,如脏读、不可重复读和幻读等。

    • 这是最高的隔离级别。
    • 它通过强制事务串行执行来避免脏读、不可重复读和幻读。
    • 在这个级别,每个事务在执行时都会完全锁定它所访问的数据,从而确保数据的一致性。但这也可能导致性能下降,因为并发事务必须等待其他事务完成才能执行。

脏读(Dirty Read)

一个事务读取到另一个尚未提交事务的修改。不可重复读(Non-repeatable Read)

在同一个事务内,多次读取同一数据返回的结果有所不同。幻读(Phantom Read)

一个事务在执行两次相同的查询时,因为另一个并发事务的插入或删除操作,导致两次查询返回的结果集不同。

隔离级别、并发性、数据一致性的三角之间关系

一图胜千言,用一张图,给大家总结一下 事务隔离级别、并发性、数据一致性的三角之间关系:

事务隔离级别和并发性和数据一致性密切相关。不同的隔离级别提供了不同的并发性和数据一致性保证。

  1. 并发性

    • 并发性指的是数据库系统同时处理多个事务的能力。隔离级别越低,允许的并发操作越多,系统的并发性能越高。
    • 但是,过高的并发操作可能会导致事务之间的相互干扰,产生一些并发问题,如脏读、不可重复读和幻读。
  2. 数据一致性

    • 数据一致性指的是事务执行后,数据库中的数据是否保持一致性。隔离级别越高,数据一致性越好,但对并发操作的限制也越严格。
    • 高隔离级别可以防止一些并发问题的产生,如脏读、不可重复读和幻读,但会降低系统的并发性能。

注意:RC/RR 适用MVCC

MySQL 中仅在 RC 读已提交级别、 RR 可重复读级别才会使用 MVCC 机制。

  • 1:RU读未提交级别,不适用MVCC。

    既然都允许存在脏读问题、允许一个事务读取另一个事务未提交的数据,直接进行当前读,那自然可以直接读最新版本的数据,因此无需 MVCC 介入。

  • 2:Serializable串行化级别不存在事务并发,不适用MVCC。

如果是Serializable串行化级别,因为会将所有的并发事务串行化处理,Serializable串行化级别,不论事务是读操作,亦或是写操作,都会被排好队一个个执行,这都不存在所谓的多线程并发问题了,自然也无需MVCC介入。

MVCC机制的三个核心组件

MVCC 机制主要通过三个组件实现:

  • 隐藏字段
  • Undo-log 日志
  • ReadView

核心组件1. 隐藏字段

在Innodb存储引擎中,每一行记录中都有隐藏字段

  • 在有聚簇索引的情况下每一行记录中都会隐藏3个字段,
  • 如果没有聚簇索引的情况下每一行记录中都会隐藏4个字段。

在有聚簇索引的情况下每一行记录中都会隐藏3个字段为DB_TRX_ID,DB_ROLL_PTR、deleted_bit,

  • DB_TRX_ID:记录创建这条数据上次修改它的事务 ID,
  • DB_ROLL_PTR:回滚指针,指向这条记录的上一个版本
  • deleted_bit字段,即记录被更新或删除,这里的删除并不代表真的删除,而是将这条记录的delete flag改为true

除了上面的3个隐藏字段,没有聚簇索引还会有DB_ROW_ID这个字段。

核心组件2. undo log(回滚日志)

在事务的ACID特性中,undo log(回滚日志)主要用于实现事务的原子性、隔离性、一致性的关键组件之一。它的主要作用包括:

  1. 事务的回滚操作

    当一个事务执行过程中发生错误或者被用户显式回滚时,数据库系统需要能够 撤销该事务已经执行的操作,将数据库恢复到事务开始之前的状态。这就是回滚操作。

    undo log记录了事务执行过程中所做的所有修改操作的 逆操作,通过undo log可以快速回滚事务所做的修改,从而保证事务的原子性。

  2. 恢复和崩溃恢复

当数据库系统发生崩溃或者异常关闭时,可能会导致部分事务未提交的修改操作丢失或者部分已提交的修改操作未持久化到磁盘。

通过undo log,数据库系统可以在恢复过程中, **将未提交的修改操作回滚**,并将已提交但 **未持久化的修改操作重新应用到数据库中**,从而保证数据库的一致性和完整性。

总的来说,undo log在数据库系统中扮演着非常重要的角色,它不仅用于实现事务的回滚操作和并发控制,还用于数据库系统的恢复和崩溃恢复。通过记录事务的修改操作和逆操作,undo log确保了数据库的原子性、隔离性和一致性,是数据库系统的关键组件之一。

前面讲到, MVCC 实现了自己 Copy-On-Write思想提升并发能力的时候, 也需要数据的副本,这里既然undo-log 有了那么多副本,MVCC 就借鸡生蛋, 复用 这些数据副本。所以,undo log 中的副本,可以用于实现多版本并发控制(MVCC),提升事务的并发性能。

核心组件3. read-view

那么多的数据副本,通过对比时间戳或者版本号,看到自己能看的版本?undo log保存的是一个版本链,也就是使用DB_ROLL_PTR这个字段来连接的。多个事务的 undo-log 日志副本 (数据快照),组成了一个 副本链,如下图:

那么,如果多个事务并行的读写操作,每一个事务应该使用那个版本呢?简单来说,在MVCC中,每个事务可以有一个特定的时间戳或者版本号,而通过对比事务的时间点所能看到的数据版本的集合。一般来说,时间戳或者版本号的对比规则包括以下几个方面:

  1. 已提交数据:事务只能看到已经提交的数据版本。即如果某个数据版本的提交时间早于当前事务的开始时间,则该数据版本对事务是可见的。
  2. 未提交数据:事务不应该看到其他事务尚未提交的数据版本。即如果某个数据版本的提交时间晚于事务的开始时间,则该数据版本对事务是不可见的。
  3. 事务开始时间:事务开始时间是确定事务 read-view 的关键因素之一。事务只能看到在它开始时间之前已经提交的数据版本。
  4. 数据快照:事务读取数据时,read-view 应该是一个一致的数据快照,即事务开始时刻的数据库状态的一个一致性快照。这样可以确保事务读取的数据是在一个一致的时间点获取的。

通过遵循这些对比规则,数据库系统可以保证事务读取的数据是一致的、可靠的,并且与其他并发事务的操作相互独立。一图胜千言。

下面的图中,对于事务4来说,可以看到的数据版本,是事务1的已经提交的数据:

上图中,事务2,事务3,事务5的快照版本,事务4的是不可以看到的。

当然, 上面是通过时间比对来的,但是 mysql 的MVCC不是通过对比时间戳来实现的。MVCC 使用 一个新的组件,read-view + 一组对比规则,来计算 可见版本。

read-view 有一些列的对比规则,这些规则用于确定一个事务在读取数据时,如何与数据库中的其他事务的版本号(这里其实就是事务ID)进行比较,以确定它所能看到的数据版本。

当执行一个select语句时MVCC 会产生一致性视图 read view。那么这个read view 没有记录事务的开始时间,和截止时间 , 而是换成另一种方式去记录开始时间和截止时间,换成什么方式呢:

  • read view 记录当前活跃事务 id,组成活跃事务id数组 ,这个属性的作用,哪些事务是当前事务,也是不可见的
  • read view 记录当前最小活跃事务 id,这个属性的作用,用于判断哪些事务是已经提交了的
  • read view 记录当前的下一个事务 id,这个属性的作用,用于判断哪些事务是未来事务,也是不可见的

注意,上面是尼恩为大家总结和归纳的,比较清晰好记, mysql 的MVCC 版本的对比规则, 看上去非常、非常复杂。下面是mysql 的MVCC 的read view 版本对比规则, 确实也是一个非常复杂的对比逻辑, 很多小伙伴傻傻看不懂, 并且背诵了半天还记不住,非常痛苦。

通过 上面的这个复杂的对比流程, read-view 终于确定一个事务在执行时所能看到的数据视图。

InnoDB表的四个隐藏字段

通常情况下,当你基于 InnoDB 引擎建立一张表后, MySQL 除了会构建你显式声明的字段外,通常还会构建一些 InnoDB 引擎的隐藏字段,在 InnoDB 引擎中,隐藏字段主要有 DB_ROW_ID、DB_Deleted_Bit、DB_TRX_ID、DB_ROLL_PTR 这四个。

列名 是否必须 描述
row_id 隐藏主键,单调递增的行ID,不是必需的,占用6个字节。
deleted_bit 删除标识,占用1个字节。
trx_id 最近的更新事务Id,记录操作该行数据事务的事务ID,占用6个字节。
roll_pointer 回滚指针,指向当前记录行的Undo-log日志中的旧版本数据,占用7个字节。

隐藏的主键:row_id

对于 InnoDB 引擎的表而言,由于其表数据是按照 聚簇索引 的格式存储,因此通常都会选择主键作为聚簇索引列,然后基于主键字段构建索引树,但如若表中未定义主键,则会选择一个具备 唯一非空属性 的字段,作为聚簇索引的字段来构建树。

当两者都不存在时, InnoDB 就会隐式定义一个顺序递增的列 ROW_ID 来作为聚簇索引列。

所以, 就算你的表中未定义主键、索引,其实默认也会存在一个聚簇索引,只不过这个索引在上层无法使用,仅提供给 InnoDB 构建树结构存储表数据。

隐藏的删除标识:deleted_bit

在MySQL中,对于InnoDB中一条 delete 语句而言,当执行后并不会立马删除表的数据,而是将这条数据的 Deleted_Bit 删除标识改为 1/true,而不是不会对数据库中的数据进行物理删除。

后续的查询 SQL 检索数据时,如果检索到了这条数据,但看到隐藏字段 Deleted_Bit=1 时,就知道该数据已经被其他事务 delete 了,因此不会将这条数据纳入结果集。

Deleted_Bit 的优势:主要是能够有利于聚簇索引,比如当一个事务中删除一条数据后,后续又执行了回滚操作,假设此时是真正的删除了表数据,会发生如下两种情况:

  • ①删除表数据时,有可能会破坏索引树原本的结构,导致 叶子节点合并的情况。
  • ②事务回滚时,又需重新插入这条数据,再次插入时又会破坏前面的结构,导致 叶子节点分裂 的情况。

所以,当执行 delete 语句时,只会改变将隐藏字段中的删除标识( Deleted_Bit)改为 1/true,而不去执行物理删除(不去破坏索引树),如果后续事务出现回滚动作,直接将其标识再改回 0/false 即可,这样就避免了索引树的结构调整。

谁来清理过期数据呢?

为了防止“已删除”的数据占用过多的磁盘空间,同时确保清理数据时不会影响 MVCC 的正常工作,Mysql使用”Purger线程”完成“已删除”的数据的定期清理。

“Purger线程”用来定期检查数据库中的数据,并根据一些预定义的规则或条件来决定哪些数据应该被删除或清理。Purger线程的主要职责包括:

  • 检查数据库中的数据,识别哪些数据应该被清理。
  • 根据一些预定义的规则或条件来决定数据的清理方式,比如按时间戳删除过期数据或者根据某些属性标记数据为无效。
  • 执行清理操作,删除或标记需要清理的数据。
  • 定期运行,以确保数据库中的数据保持在一个合理的范围内,避免存储空间被不必要的数据占用。

Purger线程通常在后台运行,定期执行清理任务,以保持数据库的健康状态和良好的性能。

purger 线程自身也会维护一个 ReadView,如果某条数据的 Deleted_Bit=true,并且 TRX_IDpurge 线程的 ReadView 可见,那么这条数据一定是可以被安全清除的(即不会影响 MVCC 工作)。

隐藏的最近更新事务ID:trx_id

TRX_ID 全称为 transaction_id,即是事务 ID 的意思,MySQL 对于每一个创建的事务,都会为其分配一个事务 ID,事务 ID 同样遵循顺序递增的特性,即后来的事务 ID 绝对会比之前的 ID 要大,比如:

此时事务 T1 准备修改表字段的值, MySQL 会为其分配一个事务 ID=1,当事务 T2 准备向表中插入一条数据时,又会为这个事务分配一个 ID=2……如果是SELECT语句,则分配的事务ID = 0;

表中的隐藏字段 TRX_ID,记录的就是最近一次改动当前这条数据的事务 ID,这个字段是实现 MVCC 机制的核心之一。

隐藏的回滚指针:roll_ptr

ROLL_PTR 全称为 rollback_pointer,也就是回滚指针的意思,这个也是表中每条数据都会存在的一个隐藏字段。

当一个事务对一条数据做了改动后,都会将旧版本的数据放到 Undo-log 日志中,而 rollback_pointer 就是一个地址指针,指向 Undo-log 日志中旧版本的数据。

当需要回滚事务时,就可以通过这个隐藏列,来找到改动之前的旧版本数据,而 MVCC 机制也利用这点,实现了行数据的多版本。

InnoDB引擎的Undo-log日志

Undo-log可以理解成回滚日志,它存储的是老版本数据

在表记录修改之前,会先把原始数据拷贝到Undo-log里,如果事务回滚,即可以通过Undo-log来还原数据。

或者如果当前记录行不可见,可以顺着Undo-log链找到满足其可见性条件的记录行版本。

在insert/update/delete(本质也是做更新,只是更新一个特殊的删除位字段)操作时,都会产生Undo-log。在InnoDB里,Undo-log分为如下两类:

  1. insert Undo-log : 事务对insert新记录时产生的Undo-log, 只在事务回滚时需要, 并且在事务提交后就可以立即丢弃。
  2. update Undo-log : 事务对记录进行delete和update操作时产生的Undo-log,不仅在事务回滚时需要,快照读也需要,只有当数据库所使用的快照中不涉及该日志记录,对应的回滚日志才会被删除。

Undo-log有什么用途呢?

1.事务回滚时,保证原子性和一致性。 2.如果当前记录行不可见,可以顺着undo log链找到满足其可见性条件的记录行版本(用于MVCC快照读)。

我们来看如下例子,理解一下Undo-log版本链。

如上述这段 SQL 隶属于 trx_id=1T1 事务,其中对同一条数据改动了两次,那 Undo-log 日志中只会存储两条旧版本的数据,如下图:

从上图中可明显看出:不同的旧版本数据,会以 roll_ptr 回滚指针作为链接点,然后将所有的旧版本数据组成一个 单向链表

请注意:最新的旧版本数据,都会插入到 链表头中,而不是追加到链表尾部。

细说一下执行上述 update 语句的详细过程:

1.对 ID=1 这条要修改的行数据加上排他锁。2.将原本的旧数据拷贝到 Undo-logrollback Segment 区域。3.对表数据上的记录进行修改,修改完成后将隐藏字段中的 trx_id 改为当前事务 ID。4.将隐藏字段中的 roll_ptr 指向 Undo-log 中对应的旧数据,并在提交事务后释放锁。

为什么 Undo-log 日志要设计出版本链呢?有如下两个好处:

  1. 一方面可以实现 事务点回滚
  2. 另一方面则可以实现 MVCC 机制。

与之前的删除标识类似,一条数据被 delete 后并提交了,最终会从磁盘移除,而 Undo-log 中记录的旧版本数据,同样会占用空间,因此在事务提交后也会移除,移除的工作同样由 purger 线程负责, purger 线程内部也会维护一个 ReadView,它会以此作为判断依据,来决定何时移除 Undo 记录。

快照读和当前读

快照读,就是读取快照数据,即快照生成的那一刻的数据。在不加锁的情况下,我们使常用的 普通的SELECT语句 就是快照读,如下:

SELECT * FROM USER WHERE ……

当前读,就是读取最新的数据,要读取最新提交的数据版本。我们在 加锁SELECT语句,或者对数据进行增、删、改 都会进行当前读。如下:

SELECT * FROM USER LOCK IN SHARE MODE; SELECT * FROM USER FOR UPDATE; INSERT INTO USER VALUES …… DELETE FROM USER WHERE …… UPDATE USER SET ……

在MySQL中只有在 RR和RC 这两个事务隔离级别下才会使用 快照读

在RR中,快照会在事务中第一次SELECT语句执行时生成,只有在本事务中对数据进行更改 才会更新快照。 在RC中,每次SELECT都会重新生成一个快照,总是读取最新版本数据。

MVCC核心ReadView

经过前面的分析,对于MVCC多版本并发控制,多版本是通过 Undo-log日志 实现。

先来思考如下的问题:如果 T1 事务要查询id=1的一条行数据,此时这条行数据正在被 T2 事务修改,那也就代表着这条数据可能存在多个旧版本数据, T1 事务在查询时,应该读这条数据的哪个版本呢?

此时就需要用到 ReadView,用它来做多版本的并发控制,根据查询的时机,来选择一个当前事务可见的旧版本数据读取。

什么是ReadView呢?

当一个事务在尝试读取一条数据时, MVCC 基于当前 MySQL 的运行状态生成的快照,也被称之为读视图,即 ReadView,在这个快照中记录着当前所有活跃事务的 ID(活跃事务是指还在执行的事务,即未结束(提交/回滚)的事务)。

ReadView是事务在进行快照读的时候生成的记录快照, 可以帮助我们解决可见性问题的。

ReadView的核心属性

当一个事务启动后,首次执行 select 操作时, MVCC 就会生成一个数据库当前的 ReadView,通常而言,一个事务与一个 ReadView 属于一对一的关系(不同隔离级别下也会存在细微差异), ReadView 一般包含4个核心属性:

属性 描述
creator_trx_id 代表创建当前这个 ReadView事务ID
trx_ids 表示在生成当前 ReadView 时,系统内活跃(未提交)的 事务ID 列表,它的数据结构为一个List。( 注意:这里的trx_ids中的活跃事务, 不包括当前事务自己已提交 的事务,这点非常重要)
up_limit_id 活跃的事务列表(trx_ids)中,最小的 事务ID,如果trx_ids为空,则up_limit_id 为 low_limit_id。
low_limit_id 表示在生成当前 ReadView 时,系统中要给下一个事务分配的 ID值。( 注意:它并不是目前系统中活跃事务的最大ID,因为MySQL的事务ID是按序递增的,因此当启动一个新的事务时,都会为其分配事务ID,而这个low_limit_id则是整个MySQL中要为下一个事务分配的ID值。)

我们假设目前数据库中共有 T1~T6 这6个事务, T1、T2、T4、T6 还在执行, T3 已经回滚, T5 已经提交,此时当有一条查询语句执行时,就会利用 MVCC 机制生成一个 ReadView,由于在MySQL中单纯由一条 select 语句组成的事务并不会分配事务 ID,因此默认为 0,所以目前这个ReadView的信息如下:

ReadView的读取规则

访问某条记录的时候如何判断该记录是否可见,具体规则如下:

  • 如果被访问版本的 事务ID = creator_trx_id,那么表示当前事务访问的是自己修改过的记录,那么该版本对当前事务可见;
  • 如果被访问版本的 事务ID < up_limit_id,那么表示生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。
  • 如果被访问版本的 事务ID > low_limit_id 值,那么表示生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。
  • 如果被访问版本的 事务ID在 up_limit_id和m_low_limit_id 之间,那就需要判断一下版本的事务ID是不是在 trx_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;
  • 如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。

上面这种图,网上有上万篇文章, 都是抄来抄去, 没有一篇文章做了总结和简化。

关于这个对比规则,由于逻辑复杂,导致尽管大家看了那些文章,甚至看了很多视频,还是不能理解透彻, 迷迷糊糊的,面试的时候 说不清楚,也很容易忘了。

尼恩团队看不下去,用咱们的雄厚技术实力(洪荒之力), 给大家来总结和简化。具体如下:

此图,是全网的第一张彻底穿透式的解读 MVCC的对比规则的图。

通过此文,尼恩团队 第一次,帮助大家搞清楚复杂的 MVCC的底层原理。

此图很容易理解,很容易记忆。大家可收藏起来, 面试之前复习一下,一定能吊打面试官。

不对,是吊死面试官。

尼恩团队用深厚的架构功力,非常喜欢也非常善于,把复杂的问题做清晰深入的穿透式、起底式的分析:

  • 比如Netty的内存池和对象池(那个超级难,很多人穷其一生都搞不懂),
  • 比如DDD的建模和落地,
  • 比如Caffeine的底层架构,
  • 比如高性能葵花宝典
  • 比如 Thread Local 学习圣经
  • 等等等等。

这个技术难题一旦掌握,大家内力猛涨。 所以,建议大家去看看尼恩的这些核心内容。而且,尼恩团队进行一次真正的AI架构穿透,帮助大家穿透AI架构。扯远了,言归正传。

ReadView的生成规则

在MySQL中只有在 RR(可重复读)和RC(读已提交) 这两个事务隔离级别下有效,生成ReadView规则是不同的:

在RR中, ReadView 会在事务中 第一次SELECT 语句执行时生成,只有在本事务中对数据进行更改才会更新快照。 在RC中,每次SELECT都会重新生成一个 ReadView,总是读取最新版本数据。 读已提交和可重复读唯一的区别在于:

  1. 在RC隔离级别下,是每个select都会创建最新的ReadView;
  2. 而在RR隔离级别下,则是当事务中的第一个select请求才创建ReadView。

总结:MVCC实现原理

经过前面的分析后已得知:

  • 当一个事务尝试改动某条数据时,会将原本表中的旧数据放入 Undo-log 日志中。
  • 当一个事务尝试查询某条数据时, MVCC 会生成一个 ReadView 快照。其中 Undo-log 主要实现数据的多版本, ReadView 则主要实现多版本的并发控制。结合如下例子说明:
-- 事务T1:trx_id=1

UPDATE user_info  SET name = "小夏" WHERE id = 1;
UPDATE user_info  SET sex = "女" WHERE id = 1;
-- 事务T2:trx_id=2

SELECT * FROM  user_info  WHERE id = 1;

目前存在 T1、T2 两个并发事务, T1 目前在修改 ID=1 的这条数据,而 T2 则准备查询这条数据,那么 T2 在执行时具体过程如下:

  1. 当事务中出现 select 语句时,会先根据 MySQL 的当前情况生成一个 ReadView

  2. 判断行数据中的隐藏列 trx_idReadView.creator_trx_id 是否相同:

    • 相同:代表创建 ReadView 和修改行数据的事务是同一个,自然可以读取最新版数据。

    • 不相同:代表目前要查询的数据,是被其他事务修改过的,继续往下执行。

  3. 判断隐藏列 trx_id 是否小于 ReadView.up_limit_id 最小活跃事务 ID

    • 小于:代表改动行数据的事务在创建快照前就已结束,可以读取最新版本的数据。

    • 不小于:则代表改动行数据的事务还在执行,因此需要继续往下判断。

  4. 判断隐藏列 trx_id 是否小于 ReadView.low_limit_id 这个值:

    • 大于或等于:代表改动行数据的事务是生成快照后才开启的,因此不能访问最新版数据。

    • 小于:表示改动行数据的事务 IDup_limit_id、low_limit_id 之间,需要进一步判断。

  5. 如果隐藏列 trx_id 小于 low_limit_id,继续判断 trx_id 是否在 trx_ids 中:

    • 在:表示改动行数据的事务目前依旧在执行,不能访问最新版数据。

    • 不在:表示改动行数据的事务已经结束,可以访问最新版的数据。

    后经过上述一系列判断后,可以得知: 目前查询数据的事务到底能不能访问最新版的数据

    如果能,就直接拿到表中的数据并返回,反之,不能则去 Undo-log 日志中获取旧版本的数据返回。

总结

MVCC 多版本并发控制,其中的多版本主要依赖 Undo-log 日志来实现,而并发控制则通过表的 隐藏字段 + ReadView 快照来实现,通过 Undo-log 日志、 隐藏字段ReadView 快照这3点,就实现了 MVCC 机制。

说在最后:有问题找老架构取经

MVCC 相关的面试题,是非常常见的面试题。也是核心面试题。以上的内容,如果大家能对答如流,如数家珍,基本上 面试官会被你 震惊到、吸引到。最终, 让面试官爱到 “不能自已、口水直流”

原文阅读