【进阶篇】MySQL的MVCC实现机制详解


在这里插入图片描述

0.前言

在这里插入图片描述
在数据库领域,对数据进行并发操作是常见的需求。为了保证数据的一致性和事务的隔离性,不同的数据库系统采用了不同的并发控制技术。其中,多版本并发控制(MVCC,Multiversion Concurrency Control)是MySQL中InnoDB存储引擎采用的一种非常重要的并发控制技术。

MVCC通过创建数据在某个时间点的"快照",让事务能够访问到一个一致的数据视图,而不会被其他事务操作影响。这样不仅可以提高并发性能,而且可以避免在读取数据时进行加锁,大大增强了数据库的并发处理能力。

在本篇博客中,我们深入探讨MySQL的MVCC实现机制,包括Undo日志、Read View以及事务链等概念。我们将详解MVCC如何在保证数据一致性的同时,提高数据库的并发处理能力。 理解和掌握MySQL中MVCC的工作原理,以更好地进行数据库设计和优化。

虽然我们的标题是 MySQL的MVCC实现机制详解,大家不要误认为MVCC机制是MySQL独有的。

MVCC 不是MySQL的特有机制,除了MySQL 使用了MVCC机制,其他数据库版本也使用了
以下是一些采用了多版本并发控制(MVCC)策略的数据库:

  1. PostgreSQL:它使用 MVCC 提供多个并发用户间的一致性视图。

  2. MySQL:在可重复读取隔离级别下,MySQL的InnoDB存储引擎利用 MVCC 解决读写冲突,提供快照数据而非最新数据。

  3. Oracle:尽管Oracle使用了MVCC,但其实现方法与PostgreSQL和MySQL的InnoDB不同。在Oracle中,读操作不会阻塞写操作,反之亦然。

  4. SQLite:SQLite使用了"snapshot isolation",它的核心概念与MVCC相似,都是在事务开始时提供一个快照,而非实时数据。

  5. CouchDB 和 MongoDB:这两个NoSQL数据库也采用了MVCC或类似技术。

  6. Apache HBase:作为开源的非关系型分布式数据库,HBase是Google BigTable的Java实现,也使用了MVCC。

  7. Apache Cassandra:这是Facebook开发的一款开源分布式NoSQL数据库系统,用于满足高速读写需求,如Inbox搜索,它也实现了MVCC。

  8. MariaDB:作为MySQL的一个开源分支版本,MariaDB的InnoDB存储引擎也使用了MVCC。

  9. Microsoft SQL Server:在读已提交快照和快照隔离级别下,SQL Server使用了MVCC。

1.基础介绍

1.1. 什么是MVCC?

MVCC,全称是多版本并发控制(Multi-Version Concurrency Control),是一种用来解决数据库并发问题的方法。在高并发的场景下,为了提高性能,采用MVCC是一种比单纯的加锁更有效的方式。

MVCC的工作原理是,每次对数据进行更新操作,都不会直接覆盖原有数据,而是为数据添加一个新的版本。并且,每个事务都可以看到一个一致的快照版本,这个版本在该事务启动时就已经确定。

这样,就可以实现在不阻塞读操作的前提下进行写操作,也就是实现了读写分离,提高了数据库的并发处理能力。

MVCC实现机制主要包括以下几个方面:

  1. 数据版本化:每一条数据都有一个版本号,每次修改数据,都会生成一个新的版本。查询时,只要在版本列表中找到对应的版本即可,而不需要等待数据修改完成。

  2. 读视图:每一次事务开始时,都会生成一个读视图,这个读视图记录了开始该事务时,正在执行的所有其他事务的事务ID。在进行查询操作时,只查询在读视图记录的事务ID之前开始的事务对应的数据版本。

  3. undo日志:在修改数据时,会把修改前的数据版本写入undo日志。如果有其他事务需要访问修改前的数据版本,可以直接从undo日志中获取。

MVCC只在READ COMMITTED和REPEATABLE READ两个隔离级别下工作。在这两个隔离级别下,读操作不会产生任何锁,大大提高了查询性能,降低了锁冲突的概率,从而使得更多的应用可以并发运行。

1.1. 什么是当前读和快照读?

在数据库中,读操作主要分为两种:当前读(Current Read)和快照读(Snapshot Read)。

  1. 当前读:就是读取记录的最新版本,也就是最新的数据。如果数据在读取过程中被其他事务修改了,那么会读取到最新的数据内容。当前读会对读取的数据加锁,阻止其他事务对数据进行修改。当前读主要出现在UPDATE、DELETE、INSERT、SELECT…FOR UPDATE这些需要进行写操作的SQL语句中。

  2. 快照读:读取的是记录在事务开始时的版本,也就是读取快照中的数据。即使在读取过程中数据被其他事务修改,读取到的数据内容也不会改变。快照读不会对读取的数据加锁,不会阻止其他事务对数据进行修改。快照读主要出现在普通的SELECT语句中。

这两种读操作的主要区别在于是否对读取的数据加锁,以及读取的是数据的哪个版本。

1.1. 当前读,快照读和MVCC的关系

当前读和快照读都是MySQL中的读操作,它们的区别在于读取的数据版本和是否加锁。然而,这两种读操作并不能完全满足并发控制的需求。这就是MVCC(多版本并发控制)的作用。

MVCC是一种用于实现事务并发控制的机制。在MVCC中,每次对数据的修改都会创建一个新的数据版本。不同的事务会看到不同版本的数据,这取决于事务开始的时间以及事务的隔离级别。

当进行快照读(非锁定读)时,MVCC允许事务读取一个旧的数据版本。这意味着在同一时间点,不同的事务可以看到同一行数据的不同版本,避免了因等待锁而导致的阻塞。这在幻读(phantom read)和非可重复读(non-repeatable read)这两种情况下特别有用。

当进行当前读(锁定读)时,如UPDATE或SELECT FOR UPDATE,会读取最新版本的数据,并对其加锁以防止其他事务进行修改。

因此,MVCC、当前读和快照读是密切相关的。MVCC通过提供一种机制,使得当前读和快照读能够在并发事务中同时有效地工作,从而提高数据库的整体性能。

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

1.1.1. 提高并发性能

MVCC允许多个读操作与写操作同时进行,无需等待锁,因此可以大大提高并发性能。

在传统的数据库并发控制中,为了保证数据的一致性,通常会使用锁来阻止其他事务在当前事务完成之前读取或修改数据。这种方式虽然可以保证数据的一致性,但是其并发性能较差,因为读操作和写操作之间存在阻塞。

而在MVCC机制中,对于读操作,系统会创建一个数据版本的快照,而不是直接对数据加锁,这样即使有其他事务正在对数据进行修改,当前事务也可以进行读操作。举例来说,假设我们有一个在线购物系统,当用户A查看某个商品的信息时,即使此时商家正在修改这个商品的价格,用户A也可以正常查看商品信息,不会被阻塞。

对于写操作,MVCC通过生成旧版本数据的拷贝来避免直接修改数据。这样当其他事务需要读取数据时,即使数据已经被修改,也可以通过读取这个旧版本数据的拷贝来获取数据,而不需要等待写操作完成。比如上述在线购物系统中,当商家修改商品价格时,如果此时有用户正在查看这个商品,那么用户看到的仍然是旧的价格,无需等待商家修改价格的操作完成。

1.1.2. 避免死锁

由于读操作不需要加锁,所以减少了产生死锁的可能。

在传统的锁定机制中,如果两个或更多的事务在相互等待对方所持有的锁,就会发生死锁。例如,如果事务A锁定了资源1并试图获得资源2,而事务B已经锁定了资源2并试图获得资源1,那么就会出现死锁,因为每个事务都在等待对方释放其需要的资源。

而在MVCC中,由于读操作不需要加锁,事务可以在不影响其他事务的情况下读取数据。这就意味着读操作不会因为等待其他事务释放锁而被阻塞,从而减少了死锁的可能性。

例如,假设有两个事务,事务A和事务B,它们都需要读取和修改同一条数据。在MVCC中,事务A可以先创建一个数据的快照进行读操作,此时即使事务B也开始修改这条数据,事务A的读操作也不会被阻塞。而当事务A要进行写操作时,只有当事务B的写操作已经完成并且事务已经提交,事务A的写操作才会被阻塞。这样就大大降低了死锁的可能性。

1.1.3. 解决脏读、不可重复读和幻读等问题

通过在每个事务处理其自己的快照,并在需要时创建对象的新版本,MVCC能够解决脏读、不可重复读和幻读等事务隔离问题。
在MVCC(多版本并发控制)模型中,每个事务处理的都是数据库在某个时间点的快照,并且在多个事务处理同一个数据项时,会生成该数据项的新版本。这种机制能有效解决一系列事务隔离的问题:

  1. 脏读:脏读是指在一个事务处理过程里读取了另一个未提交的事务中的数据。在MVCC机制中,每个事务都只读取事务开始时的数据快照,不会读取到其他未提交事务的数据,因此避免了脏读。

  2. 不可重复读:不可重复读是指在一个事务内,多次读同一数据时,由于其他事务的提交,导致多次读取的结果不一致。MVCC通过在每个事务开始时创建数据的快照,并在事务运行过程中一直使用此快照,确保了在同一事务中多次读取同一数据总是返回同一个结果,避免了不可重复读。

  3. 幻读:幻读是指在一个事务内部,先后执行两个查询操作,第二个查询出现了第一个查询不存在的记录,或者之前的记录消失,这种现象称为幻读。可重复读(RR)隔离级别下的InnoDB能通过MVCC来防止幻读,事务会看到一个一致的快照,这个快照在事务开始时创建,并在事务运行期间不会更改,从而避免了幻读。

1.1.4. 实现非阻塞读

在MVCC模型下,读操作不会阻塞写操作,写操作也不会阻塞读操作。

1.1.5. 提供一致性视图

使用MVCC,每个事务都在其开始时获取一个快照,然后在该快照上执行所有操作,这样可以保证事务执行过程中看到的数据是一致的。

2. MVCC的实现原理

2.1. 隐式字段

InnoDB引擎在MVCC实现过程中,会在每行数据后面添加额外的系统隐藏字段;
根据官方文档中描述是三个字段 DB_ROW_IDDB_TRX_IDDB_ROLL_PTR。但是从有的博客文章中看到还有一个字段DELETED_BIT 。我在相关官方文档中没有证实。如果哪位同仁找到官方的相关描述记得@一下我,非常感谢
我在mysql5.7和8.0版本的官方文档和代码里都去求证未果。

  1. 5.7版本 mvcc 官方文档 https://dev.mysql.com/doc/refman/5.7/en/innodb-multi-versioning.html
  2. 8.0版本 mvcc 官方文档 https://dev.mysql.com/doc/refman/8.0/en/innodb-multi-versioning.html
  3. MySQL源码地址 https://github.com/mysql/mysql-server/tree/8.0
    在这里插入图片描述
    在这里插入图片描述
  • DB_ROW_ID: 6bytes,隐含的自增ID(隐藏主键)。如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引。
  • DB_TRX_ID: 6bytes,最近修改(修改/插入)事务ID。记录创建这条记录或最后一次修改该记录的事务ID。
  • DB_ROLL_PTR: 7bytes,回滚指针。指向这条记录的上一个版本(存储于Undo Log里)。
  • DELETED_BIT: 1byte,【我暂未证实存在,只是在一些博客帖子中看到有同学在解释】该字段标识该行是否被删除。记录被更新或删除,并不意味真的删除,而是删除标志位变更。

通过这种方式,InnoDB存储引擎通过DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID等隐式字段,结合Undo Log,实现了MVCC的功能,同时保证了事务的ACID性质(原子性、一致性、隔离性、持久性)。

2.1. undo日志

Undo日志在MySQL的InnoDB中是用于实现数据一致性、事务回滚以及多版本并发控制(MVCC)的重要组成部分。Undo日志主要由以下几部分组成:

  1. Undo日志记录:每个Undo日志记录包含了一个数据版本的信息。当一个事务对数据库中的某个记录进行修改时,InnoDB会在Undo日志中生成一个包含这个记录修改前信息的Undo日志记录。

  2. Undo日志段(Undo Log Segment):Undo日志记录被分组存储在Undo日志段中。每个事务在开始时会在Undo段中创建一个新的Undo日志记录,这个记录包含了事务开始时候的数据状态。

  3. 回滚段(Rollback Segment):回滚段是Undo日志段的容器,每个回滚段可以包含多个Undo日志段。InnoDB中默认存在128个回滚段。

  4. Undo表空间(Undo Tablespace):Undo表空间是存储Undo日志的物理空间,可以存储多个回滚段。

在InnoDB中,Undo日志的生命周期从事务开始到事务结束。如果一个事务在执行过程中失败或者被显式回滚,InnoDB将使用Undo日志记录来恢复数据的原始状态,确保数据的一致性。如果事务执行成功并提交,相关的Undo日志记录将被标记为可回收,并在之后的清理操作中被删除。

概念和理论相对比较苦涩,我们以商城系统中的商品表为示例。

场景示例

比如有个商品表。
第一步:有个事务插入商品表插入了一条新记录,记录如下,名称为萝卜, price为1,隐式主键是1,事务ID和回滚指针,我们假设为NULL。
第二步:现在来了一个事务1对该记录的name做出了修改,改为黄瓜。
第三步:又来了个事务2修改商品表的同一个记录,将price修改为2.5 。

我们可以推测出 undo日志的存储过程 和表内容变化如下

  1. 商品表的初始状态如下:
名称 价格 隐式主键 事务ID 回滚指针
萝卜 1 1 NULL NULL
  1. 然后,事务1对名称字段做出了修改。这时,对应的undo日志记录如下:
事务ID 表名 行ID 操作类型 旧值 新值
1 商品表 1 更新 萝卜 黄瓜
  1. 商品表的状态变为:
名称 价格 隐式主键 事务ID 回滚指针
黄瓜 1 1 1 指向事务1的undo日志
  1. 接着,事务2对价格字段做出了修改。对应的undo日志记录如下:
事务ID 表名 行ID 操作类型 旧值 新值
2 商品表 1 更新 1 2.5
  1. 商品表的状态变为:
名称 价格 隐式主键 事务ID 回滚指针
黄瓜 2.5 1 2 指向事务2的undo日志

这个过程中,InnoDB会为每一次的修改操作生成一条undo日志记录。如果之后需要回滚事务,InnoDB就可以利用这些undo日志记录恢复数据的原始状态。

2.1. Read View(读视图)

基本介绍

Read View或读视图是用来实现一致性非锁定读(即MVCC,多版本并发控制中的读操作)的一种机制。

当一个事务需要执行一致性非锁定读操作时,InnoDB会为该事务生成一个读视图。这个读视图包含了当前正在活动的所有事务的事务ID,这些事务在生成读视图的时间点之后如果有新的数据改动,都不会被该读视图所看到。换句话说,只有在生成读视图时已经提交的事务改动的数据,才会被该读视图看到。

具体来说,当一个事务开始执行SELECT操作时,如果该事务是在READ COMMITTED隔离级别下,那么InnoDB会在每个SELECT语句之前为该事务生成一个新的读视图。如果该事务是在REPEATABLE READ隔离级别下,那么InnoDB只会在事务开始时生成一个读视图,然后在整个事务期间都使用这个读视图。

场景示例

假设我们有一个简单的银行账户表,包含"账户ID"和"余额"两个字段:

账户ID 余额
1 500
2 1000
3 1500

现在,假设我们有两个并发的事务,事务A和事务B。

1. 事务A开始,它想要查看账户1和账户2的余额,所以它执行一个SELECT语句。由于事务A是在REPEATABLE READ隔离级别下执行的,所以InnoDB为它生成一个读视图。这个读视图捕获了数据库在事务A开始时的状态,即账户1的余额为500,账户2的余额为1000

2. 此时,事务B开始,并且它向账户1汇入了100元,然后提交了事务。数据库的实际状态变为了账户1的余额为600,账户2的余额为1000。

3. 事务A再次执行SELECT语句,想要再次查看账户1和账户2的余额。但是,由于事务A在执行SELECT时使用的是在它开始时生成的读视图,所以它看到的账户1的余额仍然是500,账户2的余额仍然是1000,而不是数据库的实际状态。这就是读视图如何实现一致性非锁定读的。

在这个例子中,尽管事务B在事务A执行过程中改变了数据库的状态,但是由于读视图的存在,事务A看到的数据仍然是一致的,不会受到事务B的影响。这就是InnoDB如何通过使用读视图来实现MVCC的。

原理

在一个readview快照中主要包括以下这些字段:
在这里插入图片描述

  1. m_ids:活跃的事务就是指还没有commit的事务。

  2. max_trx_id:例如m_ids中的事务id为(1,2,3),那么下一个应该分配的事务id就是4,max_trx_id就是4。

  3. creator_trx_id:执行select读这个操作的事务的id。

readview如何判断版本链中的哪个版本可用呢?(重点!)

从上到下分别为(1)(2)(3)(4),依次进行解释
在这里插入图片描述

trx_id表示要读取的事务id

(1)如果要读取的事务id等于进行读操作的事务id,说明是我读取我自己创建的记录,那么为什么不可以呢。

(2)如果要读取的事务id小于最小的活跃事务id,说明要读取的事务已经提交,那么可以读取。

(3)max_trx_id表示生成readview时,分配给下一个事务的id,如果要读取的事务id大于max_trx_id,说明该id已经不在该readview版本链中了,故无法访问。

(4)m_ids中存储的是活跃事务的id,如果要读取的事务id不在活跃列表,那么就可以读取,反之不行

通过这种方式,InnoDB可以为每个事务提供一个它自己的一致性视图,这样即使数据库中的数据在事务执行过程中发生了改变,事务看到的数据也会保持一致,不会受到其他事务的影响。从而实现了事务的一致性读,也就是所谓的snapshot(快照)读,这是实现MVCC的关键。

2.1. 整体流程

MySQL的MVCC多版本并发控制主要涉及以下几个步骤:

  1. 事务启动:当一个事务启动并执行第一个操作时,系统会为该事务分配一个唯一的事务ID。

  2. 读操作:在发生读操作时,InnoDB会创建一个Read View(读视图)。这个读视图记录了启动时所有正在执行的事务ID,该事务在执行过程中,只能看到在读视图创建之前已经提交的事务所做的修改,对于在读视图创建之后其他事务所做的修改,该事务是无法看到的。

  3. 写操作:当事务进行写操作时,InnoDB不会直接覆盖旧的数据,而是将旧数据复制一份保存到undo日志中,并生成一个新的版本数据,新的数据上会记录下创建该版本的事务ID。同时,InnoDB还会在多版本链表中插入一个新的版本,链表中的版本按照事务ID从大到小的顺序进行排序。

  4. 事务提交:当事务提交时,系统会将该事务的ID号从全局的活动事务列表中删除。

  5. 版本回收:当系统判断某个版本的数据已经不再需要时(即没有任何一个活动的事务需要访问这个版本的数据),就会回收这个版本的数据以释放存储空间。
    6.整体执行流程 如下
    在这里插入图片描述

3. MVCC相关问题

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

RC(Read Committed)级别和RR(Repeatable Read)级别是两种常见的事务隔离级别。

在RC级别,每次读取都会读取到该行最新的数据,因此,一个事务在不同时间执行相同的查询,可能会得到不一样的结果,这就是所谓的“不可重复读”。

为了解决RC级别下的“不可重复读”问题,RR级别的事务在开始时会创建一个快照(snapshot),也就是一个数据的副本。在事务进行过程中,即使其他事务修改了数据,由于每次读取都是读取的这个快照,因此在一个事务内,多次读取同一数据,得到的都是一样的结果。

这就是RR级别如何在RC级别的基础上解决不可重复读问题的。这是通过牺牲一定的并发性能,增加了数据一致性。不过在很多场景下,数据一致性比并发性能更加重要,因此RR级别也被广泛使用。

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

既然我们已经了解了RC(Read Committed)和RR(Repeatable Read)的差异,那么,这两个隔离级别下的InnoDB快照读又有什么不同呢?
主要的区别在于快照读的创建时间。

  1. 在RC级别下,每一次语句执行之前都会创建一个新的Read View(快照),而无论这个事务执行了多少语句,只要它还在执行,就会持续创建新的Read View。因此,即使在同一事务中,多次读取同一行数据可能会读取到不同版本的数据,即出现“不可重复读”。

  2. 而在RR级别下,只有在事务刚开始执行第一个语句时会创建一个Read View,之后在这个事务中的所有操作,都只会看到这个Read View所代表的数据版本。其他事务在这之后做的修改,对当前事务来说是不可见的,因此在同一事务中,多次读取同一数据总是能够读到相同的结果,即保证了“可重复读”。

  3. RC和RR级别下InnoDB快照读的差异,主要是由RC和RR的隔离级别特性决定的,RR级别相比RC级别提供了更高的数据一致性,但是在并发性能上可能会有所下降。

参考资料

  1. 官方文档:MySQL官方网站提供了关于各种存储引擎的详细文档,包括InnoDB和MyISAM等。https://dev.mysql.com/doc/refman/8.0/en/storage-engines.html

  2. 书籍《高性能MySQL》是一本非常全面的关于MySQL性能优化、架构设计和内部机制的书籍,其中包含了大量关于存储引擎的内容。

  3. 知乎大佬写的 https://zhuanlan.zhihu.com/p/447372441

猜你喜欢

转载自blog.csdn.net/wangshuai6707/article/details/132711781
今日推荐