事务的底层原理和MVCC(七)

在事务的实现机制上,MySQL 采用的是 WAL(Write-ahead logging,预写式
日志)机制来实现的。

在使用 WAL 的系统中,所有的修改都先被写入到日志中,然后再被应用到
系统中。通常包含 redo 和 undo 两部分信息。

redo log 称为重做日志,每当有操作时,在数据变更之前将操作写入 redo log, 这样当发生掉电之类的情况时系统可以在重启后继续操作。

undo log 称为撤销日志,当一些变更执行到一半无法完成时,可以根据撤销 日志恢复到变更之间的状态。

redo 日志

1、redo 日志占用的空间非常小
存储表空间 ID、页号、偏移量以及需要更新的值所需的存储空间是很小的。
2、redo 日志是顺序写入磁盘的
在执行事务的过程中,每执行一条语句,就可能产生若干条 redo 日志,这 些日志是按照产生的顺序写入磁盘的,也就是使用顺序 IO。

redo 日志格式
在这里插入图片描述
type:该条 redo 日志的类型,redo 日志设计大约有 53 种不同的类型日志。 space ID:表空间 ID。
page number:页号。
data:该条 redo 日志的具体内容。

redo 日志既包含物理层面的意思,也包含逻辑层面的意思。
物理层面看,这些日志都指明了对哪个表空间的哪个页进行了修改。
逻辑层面看,在系统崩溃重启时,并不能直接根据这些日志里的记载,将页 面内的某个偏移量处恢复成某个数据,而是需要调用一些事先准备好的函数,执 行完这些函数后才可以将页面恢复成系统崩溃前的样子。

只要记住:redo 日志会把事务在执行过程中对数据库所做的所有修改 都记录下来,在之后系统崩溃重启后可以把事务所做的任何修改都恢复出来。

Mini-Transaction 的概念
所以 MySQL 把对底层页面中的一次原子访问的过程称之为一个 Mini-Transaction,比如上边所说的修改一次 Max Row ID 的值算是一个 Mini-Transaction,向某个索引对应的 B+树中插入一条记录的过程也算是一个 Mini-Transaction。
一个所谓的 Mini-Transaction 可以包含一组 redo 日志,在进行崩溃恢复时这 一组 redo 日志作为一个不可分割的整体。
一个事务可以包含若干条语句,每一条语句其实是由若干个 Mini-Transaction 组成,每一个 Mini-Transaction 又可以包含若干条 redo 日志,最终形成了一个树 形结构。

redo 日志的写入过程

redo 日志刷盘时机
1、log buffer 空间不足时,log buffer 的大小是有限的(通过系统变量 innodb_log_buffer_size 指定),如果不停的往这个有限大小的 log buffer 里塞入 日志,很快它就会被填满。InnoDB 认为如果当前写入 log buffer 的 redo 日志量已 经占满了 log buffer 总容量的大约一半左右,就需要把这些日志刷新到磁盘上。
2、事务提交时,我们前边说过之所以使用 redo 日志主要是因为它占用的空 间少,还是顺序写,在事务提交时可以不把修改过的 Buffer Pool 页面刷新到磁盘, 但是为了保证持久性,必须要把修改这些页面对应的 redo 日志刷新到磁盘。
3、后台有一个线程,大约每秒都会刷新一次 log buffer 中的 redo 日志到磁 盘。
4、正常关闭服务器时等等。

Log Sequence Number
InnoDB 为记录已经写入的 redo 日志量,设计了一个称之为 Log Sequence Number 的全局变量,翻译过来就是:日志序列号,简称 LSN。规定初始的 lsn 值为 8704(也就是一条 redo 日志也没写入时,LSN 的值为 8704)。

flushed_to_disk_lsn
redo 日志是首先写到 log buffer 中,之后才会被刷新到磁盘上的 redo 日志文 件。InnoDB 中有一个称之为 buf_next_to_write 的全局变量,标记当前 log buffer 中已经有哪些日志被刷新到磁盘中了。

我们前边说 lsn 是表示当前系统中写入的 redo 日志量,这包括了写到 log buffer 而没有刷新到磁盘的日志,相应的,InnoDB 也有一个表示刷新到磁盘中的 redo 日志量的全局变量,称之为 flushed_to_disk_lsn

系统第一次启动后,向 log buffer 中写入了 mtr_1、mtr_2、mtr_3 这三个 mtr 产生的 redo 日志,假设这三个 mtr 开始和结束时对应的 lsn 值分别是:
mtr_1:8716 ~ 8916
mtr_2:8916 ~ 9948
mtr_3:9948 ~ 10000
此时的 lsn 已经增长到了 10000,但是由于没有刷新操作,所以此时 flushed_to_disk_lsn 的值仍为 8704。
随后进行将 log buffer 中的 block 刷新到 redo 日志文件的操作,假设将 mtr_1 和 mtr_2 的日志刷新到磁盘,那么 flushed_to_disk_lsn 就应该增长 mtr_1 和 mtr_2 写入的日志量,所以 flushed_to_disk_lsn 的值增长到了 9948。
当有新的 redo 日志写入到 log buffer 时,首先 lsn 的值会增长, 但flushed_to_disk_lsn不变,随后随着不断有log buffer中的日志被刷新到磁盘上, flushed_to_disk_lsn 的值也跟着增长。如果两者的值相同时,说明 log buffer 中的 所有 redo 日志都已经刷新到磁盘中了。

查看系统中的各种 LSN 值
我们可以使用 SHOW ENGINE INNODB STATUS 命令查看当前 InnoDB 存储引擎 中的各种 LSN 值的情况
在这里插入图片描述

崩溃后的恢复
在服务器不挂的情况下,redo 日志简直就是个大累赘,不仅没用,反而让 性能变得更差。但是万一数据库挂了,就可以在重启时根据 redo 日志中的记录 就可以将页面恢复到系统崩溃前的状态。

崩溃后的恢复为什么不用 binlog?
1、这两者使用方式不一样
binlog 会记录表所有更改操作,包括更新删除数据,更改表结构等等,主要 用于人工恢复数据,而 redo log 对于我们是不可见的,它是 InnoDB 用于保证 crash-safe 能力的,也就是在事务提交后 MySQL 崩溃的话,可以保证事务的持久 性,即事务提交后其更改是永久性的。
一句话概括:binlog 是用作人工恢复数据,redo log 是 MySQL 自己使用, 用于保证在数据库崩溃时的事务持久性。
2、redo log 是 InnoDB 引擎特有的,binlog 是 MySQL 的 Server 层实现的, 所有引擎都可以使用。
3、redo log 是物理日志,记录的是“在某个数据页上做了什么修改”,恢复 的速度更快**;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这的 c 字段加 1 ”** ;
4、redo log 是“循环写”的日志文件,redo log 只会记录未刷盘的日志,已 经刷入磁盘的数据都会从 redo log 这个有限大小的日志文件里删除。binlog 是 追加日志,保存的是全量的日志
5、最重要的是,当数据库 crash 后,想要恢复未刷盘但已经写入 redo log 和 binlog 的数据到内存时,binlog 是无法恢复的。虽然 binlog 拥有全量的日志, 但没有一个标志让 innoDB 判断哪些数据已经入表(写入磁盘),哪些数据还没有。
比如,binlog 记录了两条日志: 给 ID=2 这一行的 c 字段加1 给 ID=2 这一行的 c 字段加1
在记录 1 入表后,记录 2 未入表时,数据库 crash。重启后,只通过 binlog 数据库无法判断这两条记录哪条已经写入磁盘,哪条没有写入磁盘,不管是两条 都恢复至内存,还是都不恢复,对 ID=2 这行数据来说,都不对。
但 redo log 不一样,只要刷入磁盘的数据,都会从 redo log 中抹掉,数据 库重启后,直接把 redo log 中的数据都恢复至内存就可以了。

undo 日志

事务回滚的需求
一个事务可以是一个只读事务,或者是一个读写事务:
如果某个事务执行过程中对某个表执行了增、删、改操作,那么 InnoDB 存储引擎就会给它分配一个独一无二的事务 id,分配方式如下:
对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执 行增、删、改操作时才会为这个事务分配一个事务 id,否则的话也是不分配事务 id 的。
有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句, 并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务 id。
这些为了回滚而记录的这些东西称之为撤销日志,英文名为 undo log/undo 日志。这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记 录,所以在查询操作执行时,并不需要记录相应的 undo 日志。

事务 id 生成机制
这个事务 id 本质上就是一个数字,它的分配策略和我们前边提到的对隐藏 列 row_id(当用户没有为表创建主键和 UNIQUE 键时 InnoDB 自动创建的列)的 分配策略大抵相同,具体策略如下:
服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个事务 id 时,就会把该变量的值当作事务 id 分配给该事务,并且把该变量自增 1。
每当这个变量的值为 256 的倍数时,就会将该变量的值刷新到系统表空间的 页号为 5 的页面中一个称之为 Max Trx ID 的属性处,这个属性占用 8 个字节的存 储空间。

trx_id 隐藏列
我们在学习 InnoDB 记录行格式的时候重点强调过:聚簇索引的记录除了会 保存完整的用户数据以外,而且还会自动添加名为 trx_id、roll_pointer 的隐藏列, 如果用户没有在表中定义主键以及 UNIQUE 键,还会自动添加一个名为 row_id 的隐藏列。
在这里插入图片描述
INSERT 操作对应的 undo 日志
当我们向表中插入一条记录时最终导致的结果就是这条记录被放到了一个 数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了,也就是说

在写对应的 undo 日志时,主要是把这条记录的主键信息记上。InnoDB 的设计了 一个类型为 TRX_UNDO_INSERT_REC 的 undo 日志。

roll_pointer 的作用
roll_pointer 本质上就是一个指向记录对应的 undo 日志的一个指针。比方说
我们向表里插入了 2 条记录,每条记录都有与其对应的一条 undo 日志。记录被 存储到了类型为 FIL_PAGE_INDEX 的页面中(就是我们前边一直所说的数据页), undo 日志被存放到了类型为 FIL_PAGE_UNDO_LOG 的页面中。roll_pointer 本质就 是一个指针,指向记录对应的 undo 日志。

MVCC

全称 Multi-Version Concurrency Control,即多版本并发控制。
MySQL 在 REPEATABLE READ 隔离级别下,是可以很大程度避免幻读问题的 发生的,MySQL 是怎么做到的?

版本链
我们知道,对于使用 InnoDB 存储引擎的表来说,它的聚簇索引记录中都包 含两个必要的隐藏列(row_id 并不是必要的,我们创建的表中有主键或者非 NULL 的 UNIQUE 键时都不会包含 row_id 列):
trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事 务 id 赋值给 trx_id 隐藏列。
roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo 日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修 改前的信息。
为了说明这个问题,我们创建一个演示表
CREATE TABLE teacher ( number INT,
name VARCHAR(100), domain varchar(100), PRIMARY KEY (number)
) Engine=InnoDB CHARSET=utf8;
然后向这个表里插入一条数据:
INSERT INTO teacher VALUES(1, ‘Jack’, ‘源码系列’);

假设插入该记录的事务 id 为 60,那么此刻该条记录的示意图如下所示:
在这里插入图片描述
假设之后两个事务 id 分别为 80、120 的事务对这条记录进行 UPDATE 操作,操 作流程如下:
在这里插入图片描述
在这里插入图片描述
每次对记录进行改动,都会记录一条 undo 日志,每条 undo 日志也都有一 个 roll_pointer 属性(INSERT 操作对应的 undo 日志没有该属性,因为该记录并没 有更早的版本),可以将这些 undo 日志都连起来,串成一个链表,所以现在的 情况就像下图一样:
在这里插入图片描述
对该记录每次更新后,都会将旧值放到一条 undo 日志中,就算是该记录的 一个旧版本,随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成 一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的 值。另外,每个版本中还包含生成该版本时对应的事务 id。于是可以利用这个记 录的版本链来控制并发事务访问相同记录的行为,那么这种机制就被称之为多版 本并发控制(Mulit-Version Concurrency Control MVCC)。

ReadView
在这里插入图片描述
对于使用 READ UNCOMMITTED 隔离级别的事务来说,由于可以读到未提交 事务修改过的记录,所以直接读取记录的最新版本就好了。
对于使用 SERIALIZABLE 隔离级别的事务来说,InnoDB 使用加锁的方式来访 问记录。
对于使用 READ COMMITTED 和 REPEATABLE READ 隔离级别的事务来说,都 必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修 改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是: READ COMMITTED 和 REPEATABLE READ 隔离级别在不可重复读和幻读上的区别 是从哪里来的,其实结合前面的知识,这两种隔离级别关键是需要判断一下版本 链中的哪个版本是当前事务可见的。
为此,InnoDB 提出了一个 ReadView 的概念,这个 ReadView 中主要包含 4 个比较重要的内容:
m_ids:表示在生成 ReadView 时当前系统中活跃的读写事务的事务 id 列表。 min_trx_id:表示在生成 ReadView 时当前系统中活跃的读写事务中最小的事
务 id,也就是 m_ids 中的最小值。
max_trx_id:表示生成 ReadView 时系统中应该分配给下一个事务的 id 值。 注意 max_trx_id 并不是 m_ids 中的最大值,事务 id 是递增分配的。比方说现在 有 id 为 1,2,3 这三个事务,之后 id 为 3 的事务提交了。那么一个新的读事务 在生成 ReadView 时,m_ids 就包括 1 和 2,min_trx_id 的值就是 1,max_trx_id 的值就是 4。
creator_trx_id:表示生成该 ReadView 的事务的事务 id。
1、如果被访问版本的 trx_id 属性值与 ReadView 中的 creator_trx_id 值相同, 意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
2、如果被访问版本的 trx_id 属性值小于 ReadView 中的 min_trx_id 值,表明 生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当 前事务访问。
3、如果被访问版本的 trx_id 属性值大于或等于 ReadView 中的 max_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不 可以被当前事务访问。
4、如果被访问版本的 trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id 之间(min_trx_id < trx_id < max_trx_id),那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的, 该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经 被提交,该版本可以被访问。
5、如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一 个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最 后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务 完全不可见,查询结果就不包含该记录。

在 MySQL 中,READ COMMITTED 和 REPEATABLE READ 隔离级别的的一个非 常大的区别就是它们生成 ReadView 的时机不同。

还是以表 teacher 为例,假设现在表 teacher 中只有一条由事务 id 为 60 的事务插入的一条记录,接下来看一下 READ COMMITTED 和 REPEATABLE READ所谓的生成ReadView的时机到底不同在哪里。

READ COMMITTED —— 每次读取数据前都生成一个 ReadView

REPEATABLE READ —— 在第一次读取数据时生成一个 ReadView

MVCC 下的幻读解决和幻读现象

前面我们已经知道了,REPEATABLE READ 隔离级别下 MVCC 可以解决不可重 复读问题,那么幻读呢?MVCC 是怎么解决的?幻读是一个事务按照某个相同条 件多次读取记录时,后读取时读到了之前没有读到的记录,而这个记录来自另一 个事务添加的新记录。
我们可以想想,在 REPEATABLE READ 隔离级别下的事务 T1 先根据某个搜索 条件读取到多条记录,然后事务 T2 插入一条符合相应搜索条件的记录并提交, 然后事务 T1 再根据相同搜索条件执行查询。结果会是什么?按照 ReadView 中的 比较规则:
3、如果被访问版本的 trx_id 属性值大于或等于 ReadView 中的 max_trx_id 值, 表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以 被当前事务访问。
4、如果被访问版本的 trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id 之间(min_trx_id < trx_id < max_trx_id),那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的, 该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经 被提交,该版本可以被访问。
不管事务 T2 比事务 T1 是否先开启,事务 T1 都是看不到 T2 的提交的。请自 行按照上面介绍的版本链、ReadView 以及判断可见性的规则来分析一下。
但是,在 REPEATABLE READ 隔离级别下 InnoDB 中的 MVCC 可以很大程度地 避免幻读现象,而不是完全禁止幻读。怎么回事呢?我们来看下面的情况:
在这里插入图片描述
我们首先在事务 T1 中:
select * from teacher where number = 30; 很明显,这个时候是找不到 number = 30 的记录的。 我们在事务 T2 中,执行:
在这里插入图片描述
通过执行 insert into teacher values(30,‘Luffy’,‘ELK’);,我们往表中插入了一条 number = 30 的记录。
此时回到事务 T1,执行:
在这里插入图片描述
update teacher set domain=‘RabbitMQ’ where number=30; select * from teacher where number = 30; 嗯,怎么回事?事务 T1 很明显出现了幻读现象。
在 REPEATABLE READ 隔离级别下,T1 第一次执行普通的 SELECT 语句时生成 了一个 ReadView,之后 T2 向 teacher 表中新插入一条记录并提交。
ReadView 并不能阻止 T1 执行 UPDATE 或者 DELETE 语句来改动这个新插入 的记录(由于 T2 已经提交,因此改动该记录并不会造成阻塞),但是这样一来, 这条新记录的 trx_id 隐藏列的值就变成了 T1 的事务 id。之后 T1 再使用普通的 SELECT 语句去查询这条记录时就可以看到这条记录了,也就可以把这条记录返 回给客户端。因为这个特殊现象的存在,我们也可以认为 MVCC 并不能完全禁 止幻读。

### MVCC底层实现原理 MVCC(多版本并发控制)是一种用于提高数据库并发性能的技术,它允许事务在不互相干扰的情况下读取写入数据。以下是关于MVCC底层实现原理的具体说明: #### 1. 版本链与Undo日志 在MySQL中,每行记录不仅存储当前的有效数据,还维护了一个隐藏字段`DB_TRX_ID`表示最后一次更新该行的事务ID以及另一个隐藏字段`DB_ROLL_PTR`指向undo日志中的回滚指针[^3]。当某一行被修改时,旧版本会被保存到undo日志中,并形成一条版本链。新版本会链接到这条链表的头部。 每次执行快照读操作时,InnoDB会根据当前事务的一致性视图(ReadView),沿着版本链查找符合条件的历史版本并返回给用户。如果某个历史版本满足条件,则直接使用这个版本;如果不满足则继续沿链向下寻找更早的版本直到找到为止或者到达链尾结束搜索过程[^4]。 #### 2. ReadView 的作用 ReadView 是一种用来描述某一时刻活动状态的信息结构体,在RC RR 隔离级别下分别有不同的创建时间点规则[^5]: - **READ COMMITTED (RC)**: 每次执行 SELECT 查询前都会重新构建一个新的ReadView。 - **REPEATABLE READ (RR)**: 只有在首次遇到需要生成一致性视图的情况才会建立一次初始版的ReadView ,之后整个事务期间都将复用此单一实例。 对于每一个待检索单元来说,只有那些在其对应trx_id 落入指定范围内的记录才被认为是可见的。具体判断逻辑如下: - 小于min_trx_id 或者大于max_trx_id 则不可见; - 属于creating_trx_set 中任意成员也不可视; - 否则即为合法候选对象可以参与最终筛选环节进一步确认是否完全匹配查询需求。 #### 3. 不同隔离级别的行为差异 尽管两种主要支持MVCC功能的隔离模式——READ COMMITTED REPEATABLE READ ——均依赖上述基本框架运作,但由于它们各自定义了独特的ReadView 生产策略所以表现出的行为特性有所区别: | 方面 | RC | RR | |-----------------|-----------------------------|----------------------------| | ReadView 创建时机 | 每次查询 | 整个交易周期内唯一 | | 并发能力 | 较高 | 稍低 | | 是否能看到其他事务最新提交的结果 | 是 | 否 | 这种设计使得开发者可以根据实际应用场景灵活选择合适的选项以达到最佳平衡点之间权衡效率与一致性的目标。 ```python # 示例代码展示如何通过SQL观察MVCC的效果 CREATE TABLE test_mvcc ( id INT PRIMARY KEY, value VARCHAR(20) ); INSERT INTO test_mvcc VALUES (1, 'original'); START TRANSACTION; -- 开始第一个事务T1 SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; SELECT * FROM test_mvcc WHERE id=1; -- 返回 original UPDATE test_mvcc SET value='updated' WHERE id=1; COMMIT; START TRANSACTION; -- 开始第二个事务T2 SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; SELECT * FROM test_mvcc WHERE id=1; -- 返回 updated ROLLBACK; ```
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值