什么是MVCC
MVCC
机制的全称为Multi-Version Concurrency Control
,即多版本并发控制。
MVCC主要是为了提升数据库并发性能而设计的,其中采用更好的方式处理了读-写并发冲突,做到即使有读写冲突时,可以实现并发执行,从而提升并发能力,确保了任何时刻的读操作都是非阻塞的。
在众多的MySQL开源存储引擎中,几乎只有InnoDB实现了MVCC机制,其他的存储引擎如:MyISAM、memory等存储引擎中并未实现MVCC。
MVCC(Multi-Version Concurrency Control,多版本并发控制)一种并发控制机制,用于解决并发事务访问数据库时可能出现的一些问题,如脏读、不可重复读和幻读。
在MVCC机制中,数据库中的每个数据行都可以存在多个版本,并且每个事务看到的数据版本可能不同。具体来说,MVCC机制通过以下方式实现并发控制:
-
版本控制:每当对数据库中的数据行进行更新操作时,不是直接覆盖原始数据,而是创建一个新的数据版本,并将新版本的数据与事务的时间戳相关联。
-
快照读取:在MVCC中,读取操作不会阻塞写入操作,也不会阻塞其他读取操作。事务可以读取数据库中的数据快照,即某个时间点之前的数据版本,而不会受到其他事务的影响。
-
可见性判断:在执行读取操作时,事务只能看到在其开始之前已经提交的数据版本,而看不到其他事务正在修改的数据。这样可以避免脏读和不可重复读问题。
-
回滚操作:当事务回滚时,不会对数据库中的数据进行物理删除或修改,而是标记事务所涉及的数据版本为无效,使得其他事务无法看到该版本。
总的来说,MVCC机制通过维护多个数据版本,实现了事务的隔离性和并发性,保证了数据库的一致性和可靠性。它是许多现代数据库系统(如MySQL、PostgreSQL等)中常用的并发控制技术。
MVCC的根本目标:提升并发能力
在并发读写数据库时,读操作可能会不一致的数据(脏读)。
为了避免这种情况,需要实现数据库的并发访问控制,最简单的方式就是加锁访问。
由于,加锁会将读写操作串行化,所以不会出现不一致的状态。
但是,读操作会被写操作阻塞,大幅降低读性能。
事务并发处理的四大场景
首先, 这里讲 事务的并发处理分为四大场景,分别是
-
读-读
-
写-写
-
读-写
-
写-读
这四种情况分别对应并发事务执行时的四种场景,为了后续分析MVCC
机制时方便理解,因此先将这几种情况说明。
读-读场景:
读-读场景即是指多个事务/线程在并发读取一个相同的数据,比如事务T1
正在读取ID=16
的行记录,事务T2
也在读取这条记录,两个事务之间是并发执行的。
MySQL
执行查询语句,绝对不会对引起数据的任何变化,因此对于这种情况而言,不需要做任何操作,因为不改变数据就不会引起任何并发问题。
写-写场景
写-写场景也比较简单,也就是指多个事务之间一起对同一数据进行写操作,
比如事务T1
对ID=16
的行记录做修改操作,事务T2
则对这条数据做删除操作,事务T1
提交事务后想查询看一下,结果连这条数据都不见了,这也是所谓的脏写问题,也被称为更新覆盖问题,
对于这个问题在所有数据库、所有隔离级别中都是零容忍的存在,最低的隔离级别也要解决这个问题。
读-写、写-读场景
读-写、写-读实际上从宏观角度来看,可以理解成同一种类型的操作,但从微观角度而言则是两种不同的情况,
-
读-写是指一个事务先开始读,然后另一个事务则过来执行写操作
-
写-读则相反,主要是读、写发生的前后顺序的区别。
并发事务中同时存在读、写两类操作时,这是最容易出问题的场景,脏读、不可重复读、幻读都出自于这种场景中,当有一个事务在做写操作时,读的事务中就有可能出现这一系列问题,因此数据库才会引入各种机制解决。
各并发事务场景的解决方案
对于写-写、读-写、写-读这三类存在线程安全问题的场景,最为简单粗暴的方式,通过 加锁 的方案确保线程安全。
但是,加锁会导致部分的串行化、整体串行化,因此效率会下降,而MVCC
机制的诞生则解决了这个问题。
因此MySQL
推出了MVCC
机制,在读-写并存(读-写、写-读)的场景,使用局部无锁架构,提升性能。
MVCC 机制 在线程安全问题和加锁串行化之间做了一定取舍,让两者之间达到了很好的平衡,即防止了脏读、不可重复读及幻读问题的出现,
又无需对并发读-写事务加锁处理。
无锁架构:COW思想
Copy-On-Write(COW,写时复制)是一种常见的并发编程思想。
Copy-On-Write基本思想是,当多个线程需要对共享数据进行修改时,不直接在原始数据上进行操作,而是先将原始数据复制一份(即写时复制),然后在副本上进行Write。
Copy-On-Write 通过操作写操作副本,引入局部无锁架构,解决并且处理之间的数据冲突,提高了并发性能。
Copy-On-Write的实现步骤如下:
-
读取数据:多个线程同时读取共享数据时,它们可以直接访问原始数据,而不需要复制。因为读取操作不会修改数据,所以可以安全地共享原始数据。
-
写入数据:当某个线程需要修改共享数据时,首先会将原始数据进行复制(即写时复制),然后在副本上进行修改。这样做的好处是,其他线程仍然可以继续读取原始数据,不受写入线程的影响。
-
更新引用:写入线程完成修改后,会更新共享数据的引用,使得其他线程后续访问时可以获取到最新的数据副本。
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思想呢?
总体来说,MVCC Copy-On-Write思想, 包括三个组成部分:
事务要实现ACID,其中的原子性、一致性主要使用 undo-log 数据副本实现,undo-log 就是重做日志,一个事务一个 undo-log 日志副本。
多个事务的 undo-log 日志副本 (数据快照),组成了一个 副本链,如下图:
MVCC 复用 这个 undo-log 副本链,实现了 Copy-On-Write 思想。
MVCC与锁的关系
MVCC和锁结合使用,提升事务并行能力:
MVCC(Multi-Version Concurrency Control,多版本并发控制)和锁是数据库管理系统中两种不同的并发控制机制,它们在处理事务并发访问时起着不同的作用。
-
MVCC:
-
MVCC通过维护数据的多个版本来实现并发控制,允许事务并发访问数据库而不会发生阻塞。
-
在MVCC中,读取操作不会阻塞写入操作,也不会阻塞其他读取操作。每个事务可以看到一个一致性的数据快照,而不受其他事务的影响。
-
MVCC主要用于读取操作的并发控制,可以有效地避免脏读、不可重复读和幻读等并发问题。
-
-
锁:
-
锁是一种悲观并发控制机制,通过在事务访问数据时对数据进行加锁,以防止其他事务对该数据进行修改或读取。
-
在使用锁进行并发控制时,可能会出现阻塞和死锁等问题,特别是在高并发的情况下,锁的粒度过大或者锁的竞争过于激烈时,性能可能会受到影响。
-
MVCC和锁之间的关系可以总结如下:
-
MVCC是一种 乐观的并发控制机制,通过多副本的版本控制来实现并发访问,而不需要对数据进行加锁。
-
锁是一种 悲观的并发控制机制,通过对数据进行加锁来确保事务的隔离性和一致性。
很多时候,MVCC和锁可以结合使用,以实现更细粒度的并发控制,提高系统的性能和并发能力。
MySQL事务隔离级别与MVCC
什么是事务
事务(Transaction)是数据库管理系统执行过程中的一个逻辑单位,它由一个有限的数据库操作序列构成。
这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。
事务的目的是确保数据的完整性和一致性,它通过一系列的操作,将数据库从一个一致性状态转换到另一个一致性状态。
事务的ACID特性
事务通常具有以下四个特性,也被称为ACID属性:
-
原子性(Atomicity):事务作为一个整体执行,包含在其中的对数据库的操作要么全部执行,要么全部不执行。
-
一致性(Consistency):事务必须使数据库从一个一致性状态变换到另一个一致性状态。也就是说,一个事务的执行不能破坏数据库数据的完整性和一致性。
-
隔离性(Isolation):事务的执行不受其他事务的干扰,事务执行的中间结果对其他事务是不可见的。
-
持久性(Durability):一旦事务提交,则其结果就是永久性的,即使系统崩溃也不会丢失。
事务的这些特性确保了即使在高并发的环境中,数据库也能保持数据的完整性和一致性。在数据库系统中,事务是通过一系列的操作来完成的,包括数据的插入、更新、删除等。如果事务中的任何操作失败,或者因为某种原因被中断,那么整个事务都会回滚(Rollback),即撤销所有已经执行的操作,使数据库回到事务开始之前的状态。如果事务中的所有操作都成功完成,那么事务会提交(Commit),所做的更改会永久保存到数据库中。
四种事务隔离级别
什么是事务隔离级别?事务隔离级别主要定义了事务在并发执行时的行为,特别是它们如何与其他事务交互以及它们如何看到数据库中的更改。
ANSI/ISO SQL标准定义了4中事务隔离级别:未提交读(read uncommitted),提交读(read committed),重复读(repeatable read),串行读(serializable)。
-
Oracle中默认的事务隔离级别是提交读 (read committed)。
-
对于MySQL的Innodb的默认事务隔离级别是重复读(repeated read)。
MySQL支持四种不同的事务隔离级别,每种级别都有其特定的行为和适用场景。以下是MySQL的四种事务隔离级别及其描述:
-
READ UNCOMMITTED(读取未提交)
-
允许读取尚未提交的数据变更。
-
这是最低的隔离级别,它可能导致脏读、不可重复读和幻读。
-
在这个级别,一个事务可以读取到另一个尚未提交事务的修改,这可能导致数据的不一致性。
-
-
READ COMMITTED(读取已提交)
-
只允许读取并发事务已经提交的数据。
-
这个级别可以防止脏读,但仍可能导致不可重复读和幻读。
-
在这个级别,每个事务只能看到它开始时的数据状态以及它提交时其他事务所做的提交。
-
-
REPEATABLE READ(可重复读取)
-
这是MySQL的默认隔离级别。
-
它确保在同一事务中多次读取同一数据时,看到的是相同的数据版本,即使其他事务在此期间修改了这些数据。
-
尽管可以避免脏读和不可重复读,但在这个级别下仍可能出现幻读(即在一个事务中,两次相同的查询可能会返回不同的结果集,因为其他事务在此期间插入了新的记录)。
-
-
SERIALIZABLE(可串行化)
选择适当的事务隔离级别需要根据应用的需求和性能考虑进行权衡。在某些情况下,可能需要更高的隔离级别来确保数据的一致性,而在其他情况下,可能需要降低隔离级别以提高性能。同时,也需要注意不同隔离级别可能带来的并发问题,如脏读、不可重复读和幻读等。
-
这是最高的隔离级别。
-
它通过强制事务串行执行来避免脏读、不可重复读和幻读。
-
在这个级别,每个事务在执行时都会完全锁定它所访问的数据,从而确保数据的一致性。但这也可能导致性能下降,因为并发事务必须等待其他事务完成才能执行。
-
脏读(Dirty Read):
一个事务读取到另一个尚未提交事务的修改。不可重复读(Non-repeatable Read):
在同一个事务内,多次读取同一数据返回的结果有所不同。幻读(Phantom Read):
一个事务在执行两次相同的查询时,因为另一个并发事务的插入或删除操作,导致两次查询返回的结果集不同。
隔离级别、并发性、数据一致性的三角之间关系
事务隔离级别和并发性和数据一致性密切相关。不同的隔离级别提供了不同的并发性和数据一致性保证。
-
并发性:
-
并发性指的是数据库系统同时处理多个事务的能力。隔离级别越低,允许的并发操作越多,系统的并发性能越高。
-
但是,过高的并发操作可能会导致事务之间的相互干扰,产生一些并发问题,如脏读、不可重复读和幻读。
-
-
数据一致性:
-
数据一致性指的是事务执行后,数据库中的数据是否保持一致性。隔离级别越高,数据一致性越好,但对并发操作的限制也越严格。
-
高隔离级别可以防止一些并发问题的产生,如脏读、不可重复读和幻读,但会降低系统的并发性能。
-
注意: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(回滚日志)主要用于实现事务的原子性、隔离性、一致性的关键组件之一。它的主要作用包括:
-
事务的回滚操作:
当一个事务执行过程中发生错误或者被用户显式回滚时,数据库系统需要能够撤销该事务已经执行的操作,将数据库恢复到事务开始之前的状态。这就是回滚操作。
undo log记录了事务执行过程中所做的所有修改操作的逆操作,通过undo log可以快速回滚事务所做的修改,从而保证事务的原子性。
-
恢复和崩溃恢复:
当数据库系统发生崩溃或者异常关闭时,可能会导致部分事务未提交的修改操作丢失或者部分已提交的修改操作未持久化到磁盘。
通过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中,每个事务可以有一个特定的时间戳或者版本号,而通过对比事务的时间点所能看到的数据版本的集合。
一般来说,时间戳或者版本号的对比规则包括以下几个方面:
-
已提交数据:事务只能看到已经提交的数据版本。即如果某个数据版本的提交时间早于当前事务的开始时间,则该数据版本对事务是可见的。
-
未提交数据:事务不应该看到其他事务尚未提交的数据版本。即如果某个数据版本的提交时间晚于事务的开始时间,则该数据版本对事务是不可见的。
-
事务开始时间:事务开始时间是确定事务 read-view 的关键因素之一。事务只能看到在它开始时间之前已经提交的数据版本。
-
数据快照:事务读取数据时,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 的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_ID
对purge
线程的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分为如下两类:
-
insert Undo-log : 事务对insert新记录时产生的Undo-log, 只在事务回滚时需要, 并且在事务提交后就可以立即丢弃。
-
update Undo-log : 事务对记录进行delete和update操作时产生的Undo-log,不仅在事务回滚时需要,快照读也需要,只有当数据库所使用的快照中不涉及该日志记录,对应的回滚日志才会被删除。
Undo-log有什么用途呢?
1.事务回滚时,保证原子性和一致性。 2.如果当前记录行不可见,可以顺着undo log链找到满足其可见性条件的记录行版本(用于MVCC快照读)。
我们来看如下例子,理解一下Undo-log版本链。
如上述这段SQL
隶属于trx_id=1
的T1
事务,其中对同一条数据改动了两次,那Undo-log
日志中只会存储两条旧版本的数据,如下图:
从上图中可明显看出:不同的旧版本数据,会以roll_ptr
回滚指针作为链接点,然后将所有的旧版本数据组成一个单向链表
。
请注意:最新的旧版本数据,都会插入到链表头中
,而不是追加到链表尾部。
细说一下执行上述
update
语句的详细过程:
1.对ID=1
这条要修改的行数据加上排他锁。2.将原本的旧数据拷贝到
Undo-log
的rollback Segment
区域。3.对表数据上的记录进行修改,修改完成后将隐藏字段中的
trx_id
改为当前事务ID
。4.将隐藏字段中的
roll_ptr
指向Undo-log
中对应的旧数据,并在提交事务后释放锁。
为什么Undo-log
日志要设计出版本链呢?
有如下两个好处:
-
一方面可以实现
事务点回滚
; -
另一方面则可以实现
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 | 代表创建当前这个 |
trx_ids | 表示在生成当前 |
up_limit_id | 活跃的事务列表(trx_ids)中,最小的 |
low_limit_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 时生成该版本的事务已经被提交,该版本可以被访问。
总结如下:
ReadView的生成规则
在MySQL中只有在RR(可重复读)和RC(读已提交)
这两个事务隔离级别下有效,生成ReadView规则是不同的:
在RR中,
ReadView
会在事务中第一次SELECT
语句执行时生成,只有在本事务中对数据进行更改才会更新快照。在RC中,每次SELECT都会重新生成一个
ReadView
,总是读取最新版本数据。读已提交和可重复读唯一的区别在于:
1.在RC隔离级别下,是每个select都会创建最新的ReadView;
2.而在RR隔离级别下,则是当事务中的第一个select请求才创建ReadView。
经过前面的分析后已得知:
-
当一个事务尝试改动某条数据时,会将原本表中的旧数据放入
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_id
与ReadView.creator_trx_id
是否相同:-
相同:代表创建
ReadView
和修改行数据的事务是同一个,自然可以读取最新版数据。 -
不相同:代表目前要查询的数据,是被其他事务修改过的,继续往下执行。
-
-
3.判断隐藏列
trx_id
是否小于ReadView.up_limit_id
最小活跃事务ID
:-
小于:代表改动行数据的事务在创建快照前就已结束,可以读取最新版本的数据。
-
不小于:则代表改动行数据的事务还在执行,因此需要继续往下判断。
-
-
4.判断隐藏列
trx_id
是否小于ReadView.low_limit_id
这个值:-
大于或等于:代表改动行数据的事务是生成快照后才开启的,因此不能访问最新版数据。
-
小于:表示改动行数据的事务
ID
在up_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
机制。